@@ -3,6 +3,8 @@ import { commandHasRecursiveRm, expandShellSubjects } from "../shell/run-shell-a
33import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js" ;
44import { commandHasUnboundedDirectoryListing , commandTargetsRestricted } from "./classify.js" ;
55import { splitChainedCommand , tokenize } from "./command.js" ;
6+ import { isPermittedSiblingWorktreePath } from "./path-restriction.js" ;
7+ import type { RootsProvider } from "./worktree-roots.js" ;
68
79// Auto-mode shell policy: a flat table of rules that constrain what a run_shell
810// command may do when auto mode is on. Auto mode otherwise rubber-stamps every
@@ -287,63 +289,35 @@ const WORKTREE_PRUNE_FLAGS = new Set(["-n", "--dry-run", "-v", "--verbose"]);
287289// Flags that take a following value on `git worktree add` (branch name, lock reason).
288290const WORKTREE_ADD_VALUE_FLAGS = new Set ( [ "-b" , "-B" , "--reason" ] ) ;
289291
290- // Sibling worktree destinations must never land in home-config / credential
291- // stores even when the path is only one level above cwd.
292- const SCARY_WORKTREE_BASENAMES = new Set ( [
293- ".ssh" ,
294- ".gnupg" ,
295- ".aws" ,
296- ".azure" ,
297- ".kube" ,
298- ".docker" ,
299- ".config" ,
300- ".Trash" ,
301- "Library" ,
302- "AppData" ,
303- ".netrc" ,
304- ] ) ;
305-
306- // Agent-owned hidden dirs that are legitimate worktree parents outside cwd.
307- const ALLOWED_OUTSIDE_DOTDIRS = new Set ( [ ".worktrees" , ".claude" , ".git" ] ) ;
308-
309292function isWorktreeForceFlag ( arg : string ) : boolean {
310293 return arg === "-f" || arg === "--force" ;
311294}
312295
313296// True when the path is safe for unattended worktree add/remove: inside the
314- // session workspace, or a relative sibling under the parent of cwd that does
315- // not touch credential/home-config basenames. Globs, ~, absolute outside paths,
316- // and `../../…` always fail closed.
297+ // session workspace (the unified containment authority's normal notion), or a
298+ // not-yet-registered sibling location the same authority's narrow
299+ // isPermittedSiblingWorktreePath rule allows (path-restriction.ts). No
300+ // bespoke denylist or depth counter here — everything routes through that one
301+ // authority so a path is never judged "contained" under a looser or stricter
302+ // rule than the one gate.ts uses to decide restriction.
317303function isContainedWorktreePath (
318304 pathArg : string ,
319305 isRestricted : ( path : string , isWrite : boolean ) => boolean ,
306+ cwd : string ,
307+ rootsProvider : RootsProvider ,
320308) : boolean {
321309 if ( ! pathArg ) return false ;
322- if ( / [ * ? \[ ] / . test ( pathArg ) ) return false ;
310+ // Shell-syntax the containment check below cannot resolve correctly:
311+ // `resolve()` treats a leading `~` as a literal path segment rather than
312+ // expanding it, so a home-relative path would otherwise read as "inside
313+ // cwd"; a glob is not a single concrete destination at all.
314+ if ( / [ * ? [ ] / . test ( pathArg ) ) return false ;
323315 if ( pathArg . startsWith ( "~" ) ) return false ;
324316
325317 // Workspace (cwd + registered worktree roots) — always contained.
326318 if ( ! isRestricted ( pathArg , true ) ) return true ;
327319
328- // Absolute path outside the workspace (e.g. /tmp/evil) — ask.
329- if ( pathArg . startsWith ( "/" ) || / ^ [ A - Z a - z ] : [ \\ / ] / . test ( pathArg ) ) return false ;
330-
331- // Relative path that resolves outside workspace: allow only sibling trees
332- // (at most one `..` net step) with no scary path components.
333- const parts = pathArg . replace ( / \\ / g, "/" ) . split ( "/" ) . filter ( ( p ) => p . length > 0 && p !== "." ) ;
334- let depth = 0 ;
335- for ( const part of parts ) {
336- if ( part === ".." ) {
337- depth -= 1 ;
338- if ( depth < - 1 ) return false ;
339- continue ;
340- }
341- if ( SCARY_WORKTREE_BASENAMES . has ( part ) ) return false ;
342- if ( part . startsWith ( "." ) && ! ALLOWED_OUTSIDE_DOTDIRS . has ( part ) ) return false ;
343- depth += 1 ;
344- }
345- // Bare `..` (parent of cwd as the worktree path) is not a contained destination.
346- return depth >= 0 ;
320+ return isPermittedSiblingWorktreePath ( cwd , pathArg , rootsProvider ) ;
347321}
348322
349323// Walks worktree args, recording force and every positional path. Value-taking
@@ -385,6 +359,8 @@ function worktreePathArgs(
385359function safeWorktreeCommand (
386360 command : string ,
387361 isRestricted : ( path : string , isWrite : boolean ) => boolean ,
362+ cwd : string ,
363+ rootsProvider : RootsProvider ,
388364) : boolean | undefined {
389365 const tokens = tokenize ( command ) ;
390366 if ( tokens [ 0 ] !== "git" || ! tokens . slice ( 1 ) . includes ( "worktree" ) ) return undefined ;
@@ -417,7 +393,7 @@ function safeWorktreeCommand(
417393 // add/remove require a path; no path → ask rather than guess.
418394 if ( paths . length === 0 ) return false ;
419395 // First positional is the worktree path; later tokens on add are commit-ish.
420- return isContainedWorktreePath ( paths [ 0 ] ! , isRestricted ) ;
396+ return isContainedWorktreePath ( paths [ 0 ] ! , isRestricted , cwd , rootsProvider ) ;
421397 }
422398
423399 // move / lock / unlock / repair / unknown — still ask until proven safe.
@@ -433,9 +409,13 @@ function preferRule(a: AutoShellRule | undefined, b: AutoShellRule | undefined):
433409 return a ;
434410}
435411
412+ const NO_ROOTS : RootsProvider = ( ) => [ ] ;
413+
436414export function autoShellRuleForCall (
437415 call : ToolCall ,
438416 isRestricted : ( path : string , isWrite : boolean ) => boolean = ( ) => false ,
417+ cwd : string = process . cwd ( ) ,
418+ rootsProvider : RootsProvider = NO_ROOTS ,
439419) : AutoShellRule | undefined {
440420 if ( call . name !== "run_shell" ) return undefined ;
441421 const command = call . arguments . command ;
@@ -479,12 +459,12 @@ export function autoShellRuleForCall(
479459 // destinations are often intentional siblings (`../corbits-dispatch-wts/…`)
480460 // and are judged by the worktree path policy below instead.
481461 for ( const subject of subjects ) {
482- if ( safeWorktreeCommand ( subject , isRestricted ) === true ) continue ;
462+ if ( safeWorktreeCommand ( subject , isRestricted , cwd , rootsProvider ) === true ) continue ;
483463 if ( commandTargetsRestricted ( subject , isRestricted ) ) return OUTSIDE_WORKSPACE_ASK_RULE ;
484464 }
485465
486466 for ( const subject of subjects ) {
487- if ( safeWorktreeCommand ( subject , isRestricted ) === false ) return WORKTREE_ASK_RULE ;
467+ if ( safeWorktreeCommand ( subject , isRestricted , cwd , rootsProvider ) === false ) return WORKTREE_ASK_RULE ;
488468 }
489469
490470 if ( matched !== undefined ) return matched ;
0 commit comments