Skip to content

Commit d5cd074

Browse files
committed
Route sibling git-worktree auto-allow through the unified containment authority
auto-shell-policy.ts had a second, bespoke containment notion for uncontained worktree destinations (a scary-basename denylist plus a "..\" depth counter), separate from path-restriction.ts's isRestricted. Replace it with isPermittedSiblingWorktreePath, a narrow addition to the same authority: a not-yet-created path qualifies only if it is a direct child of the parent directory of cwd or of a registered worktree root. This is stricter than the old heuristic for nested "container/leaf" destinations (now ask instead of auto-allow) but still covers the real need of creating a brand-new sibling worktree with zero registered roots.
1 parent 2215a39 commit d5cd074

4 files changed

Lines changed: 101 additions & 48 deletions

File tree

src/permission/auto-shell-policy.ts

Lines changed: 25 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { commandHasRecursiveRm, expandShellSubjects } from "../shell/run-shell-a
33
import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js";
44
import { commandHasUnboundedDirectoryListing, commandTargetsRestricted } from "./classify.js";
55
import { 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).
288290
const 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-
309292
function 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.
317303
function 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-Za-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(
385359
function 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+
436414
export 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;

src/permission/gate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
356356
// operator prompt. Everything else auto-allows. Path-keyed secret
357357
// reads stay hard-denied by secret-guard; shell that only *mentions*
358358
// a secret path is ask so an explicit one-time approval can pass it.
359-
const shellRule = autoShellRuleForCall(call, isRestrictedHere);
359+
const shellRule = autoShellRuleForCall(call, isRestrictedHere, effectiveCwd, rootsProvider);
360360
if (shellRule?.effect === "deny") return { allowed: false, reason: shellRule.reason };
361361
if (shellRule === undefined) return { allowed: true };
362362
} else if (!restricted && AUTO_ALLOWED_TOOLS.has(call.name)) {

src/permission/path-restriction.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,40 @@ export function resolveWorkspacePath(
8686
return undefined;
8787
}
8888

89+
// Whether `path` (relative to `cwd`) names a not-yet-created sibling worktree
90+
// location: a direct child of the parent directory of `cwd` or of a currently
91+
// registered root — the "one new dir next to something already trusted" shape
92+
// `git worktree add ../name` uses. This is the single containment authority's
93+
// answer to "can auto mode create a brand-new worktree that isn't a registered
94+
// root yet"; there is deliberately no separate basename denylist or `..` depth
95+
// counter — the parent-directory equality check *is* the depth bound (a path
96+
// with any extra segment resolves to a different, non-matching parent), and
97+
// the home-directory guard below is the one home-config bag it purpose-built
98+
// against ($HOME's own children — .ssh, .aws, .config, … must never qualify).
99+
export function isPermittedSiblingWorktreePath(
100+
cwd: string,
101+
path: string,
102+
rootsProvider: RootsProvider = () => [],
103+
home: string = homedir(),
104+
): boolean {
105+
if (path.length === 0) return false;
106+
if (/[*?[]/.test(path)) return false;
107+
if (path.startsWith("~")) return false;
108+
if (path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path)) return false;
109+
110+
const abs = resolve(cwd, path);
111+
const realParent = realpathOr(dirname(abs));
112+
const realHome = realpathOr(resolve(home));
113+
if (realParent === realHome) return false;
114+
115+
const knownRoots = [...rootsProvider(), ...rootsProvider(true)];
116+
const trustedParents = new Set<string>([
117+
realpathOr(resolve(cwd, "..")),
118+
...knownRoots.map((root) => realpathOr(dirname(root))),
119+
]);
120+
return trustedParents.has(realParent);
121+
}
122+
89123
function underRoot(abs: string, root: string): boolean {
90124
// realpathNearestOr on both sides so a not-yet-created state root still
91125
// compares equal to paths under it (realpathOr alone leaves the root

src/permission/permission.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,8 +1104,10 @@ describe("createPermissionGate", () => {
11041104
for (const command of [
11051105
"git worktree add feature",
11061106
"git worktree add feature main",
1107-
"git worktree add -b feature-branch ../corbits-dispatch-wts/CL-5602 origin/main",
1108-
"git worktree add ../.worktrees/CL-5602",
1107+
// Sibling worktree directly under the parent of cwd — the narrow
1108+
// isPermittedSiblingWorktreePath shape (path-restriction.ts): a brand
1109+
// new, not-yet-registered root one level up from cwd.
1110+
"git worktree add -b feature-branch ../CL-5602 origin/main",
11091111
"git worktree remove feature",
11101112
"git worktree prune",
11111113
"git worktree prune -n -v",
@@ -1117,6 +1119,36 @@ describe("createPermissionGate", () => {
11171119
}
11181120
});
11191121

1122+
test("auto mode auto-allows a relative sibling worktree next to a registered root, zero cwd-sibling roots needed", async () => {
1123+
// Reproduces the product need: creating a brand-new sibling worktree that
1124+
// by definition isn't a registered root yet. Here the registered root
1125+
// lives in its own parent directory (an org-style "…/wts/<repo>" layout)
1126+
// distinct from cwd's own parent, and cwd reaches the new sibling through
1127+
// a relative "../../wts/CL-5602" path — still the narrow one-level-up
1128+
// sibling shape, just anchored at a different trusted parent than cwd's.
1129+
let asked = 0;
1130+
const base = mkdtempSync(join(tmpdir(), "corbits-worktree-org-"));
1131+
const cwd = join(base, "main-repo");
1132+
mkdirSync(cwd);
1133+
const wtsDir = join(base, "wts");
1134+
mkdirSync(wtsDir);
1135+
const otherRoot = join(wtsDir, "existing-wt");
1136+
mkdirSync(otherRoot);
1137+
const gate = createPermissionGate({
1138+
approvals: [],
1139+
requestApproval: async () => { asked++; return { allow: false }; },
1140+
interactive: true,
1141+
skipPermissions: false,
1142+
auto: true,
1143+
cwd,
1144+
rootsProvider: () => [realpathSync(otherRoot)],
1145+
});
1146+
1147+
const verdict = await gate.evaluate(shellCall("git worktree add ../wts/CL-5602-new"));
1148+
expect(verdict.allowed).toBe(true);
1149+
expect(asked).toBe(0);
1150+
});
1151+
11201152
test("auto mode prompts for unsafe git worktree operations", async () => {
11211153
const cwd = mkdtempSync(join(tmpdir(), "corbits-worktree-policy-"));
11221154
const outsideAbs = join(tmpdir(), "outside-worktree-absolute");
@@ -1130,6 +1162,13 @@ describe("createPermissionGate", () => {
11301162
"git worktree add -f feature",
11311163
"git worktree add ../.ssh/x",
11321164
"git worktree add ../../escape",
1165+
// Nested siblings ("container/leaf") no longer auto-allow: the old
1166+
// basename-denylist-plus-depth-counter heuristic let these through with
1167+
// zero registered roots, but they don't fit the unified, narrow
1168+
// isPermittedSiblingWorktreePath shape (a direct child of the parent of
1169+
// cwd or of a registered root) — see path-restriction.ts.
1170+
"git worktree add -b feature-branch ../corbits-dispatch-wts/CL-5602 origin/main",
1171+
"git worktree add ../.worktrees/CL-5602",
11331172
"git worktree remove --force feature",
11341173
"git worktree move feature other",
11351174
"git --no-pager worktree remove feature",

0 commit comments

Comments
 (0)