.gitmodules escape the wt (unvalidated path=)core/recurse.js composes a worktree path from an UNVALIDATED .gitmodules
path= value: isMount(wtRoot, subpath) builds wtRoot + "/" + subpath then
+ "/.be" (raw concat, no safeRel), and walk/resolveRepoForPath feed the
same subpath through subs.mountWtDir = join(repo.wt, subPath) into
be.find. shared/gitmodules.js parse validates nothing (curPath =
line.slice(eq+1).trim()), so a crafted checked-out .gitmodules declaring
path = ../../../etc escapes the worktree via lstat/stat/be.find. Same
class as BE-011 (lexical/concat path build defeated by ..); found in the
BE-011 follow-up wt-open audit. Method Issues.
core/recurse.js:35 base = (wtRoot.endsWith("/")?…)+subpath; :37 p = base + "/.be"; :71/:95 be.find(subs.mountWtDir(repo, subPath)) — subPath from gitmodulesOrder(repo.wt), unconfined..gitmodules whose path= holds ../absolute segments; nothing rejects it at parse or use.path.js now offers wtJoin/resolveInTree (throw NAVESCAPE) + safeRel; recurse routes through none..gitmodules path= that resolves outside the worktree is REFUSED (NAVESCAPE / dropped), never stat'd or be.find'd.subpath/subPath in isMount, walk, resolveRepoForPath via wtJoin/resolveInTree before any concat or mountWtDir.shared/gitmodules.js parse to drop non-safeRel path entries at the source..gitmodules (path = ../../x) → refusal, not escape.shared/util/path.js. Same tree (no worktree).shared/gitmodules.js parse DROPS-and-continues any [submodule] block whose path fails safeRel (absolute/../.git/.be/reserved). A poisoned entry never reaches the walk; a bad sub is SKIPPED, the walk of the legit subs is NOT aborted. Chose drop-and-continue over throw so one crafted .gitmodules line can't DoS status/log/diff of an honest tree.core/recurse.js isMount/walk/resolveRepoForPath route every subpath/sub through path.wtJoin(wtRoot, rel) before any lstat/stat/be.find. wtJoin throws NAVESCAPE on a climb above the wt root; isMount catches it → refused as NO-MOUNT (never stats outside), walk/resolveRepoForPath catch it → skip/stop. No hand-rolled normalization; all via shared/util/path.js.resolveRepoForPath(repo, path)'s param path shadowed the new const path = require(...); renamed the param to relPath so path.wtJoin binds the module (else a TypeError on every sub descent — the log suites would have gone red)..gitmodules path=../outside escapes today (RED). test/sub/mount-escape/run.sh — drives recurse.isMount + gitmodules.paths directly; RED against original (MOUNT_esc=true, reached <wt>/../outside/.be), GREEN after.wtJoin (isMount/walk/resolveRepoForPath); harden gitmodules.parse (safeRel drop).$HOME (git-over-ssh fixture); they SKIP when $TMP is /tmp — ran both with TMP=$HOME/tmp/... to get real GREEN, not SKIP.core/recurse.js: isMount builds base via path.wtJoin (was raw wtRoot+"/"+subpath); walk + resolveRepoForPath compose subWt via wtJoin before be.find (was subs.mountWtDir); dropped the now-unused subs require, added the path require; renamed the resolveRepoForPath param path→relPath (shadow fix).shared/gitmodules.js: parse requires safeRel and gates flush() so non-safeRel path= blocks are dropped at the source.test/sub/mount-escape/run.sh (RED→GREEN repro). Nothing committed — working tree left dirty for review.