From 2215a396f4f940d86a17c050e5d4b84bb56e0e07 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 07:26:20 -0700 Subject: [PATCH 1/3] Auto-allow contained git worktree ops in auto mode Dispatch and issue workflows need unattended worktree add/remove/prune for sibling paths. Force flags, absolute outside paths, globs, home expansion, and credential basenames still require operator approval. --- docs/ARCHITECTURE.md | 4 +- src/permission/auto-shell-policy.ts | 140 ++++++++++++++++++++++++++-- src/permission/permission.test.ts | 84 +++++++++++------ 3 files changed, 194 insertions(+), 34 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 95548271e..d7ebd45a4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -94,7 +94,7 @@ In TUI chat mode there is no completion gate — the session stays open across t Two directors, selected by role: -- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, multi-turn chat semantics, and an optional **goal governor** (session-scoped auto-continue until every acceptance criterion is done). It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, worktree changes, sensitive-path and opaque-wrapper shell still ask; shell file-mutation denied). It is not a separate edit/plan mode. +- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, multi-turn chat semantics, and an optional **goal governor** (session-scoped auto-continue until every acceptance criterion is done). It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode. - **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less completion with **zero tool calls in the entire run** is returned as a **never-acted** salvage report (not a successful implement); explore/read-only workers that used tools then replied with findings remain normal completes. Hard stops also fire after 2 consecutive identical tool-call fingerprints (**no-progress**), on progressive re-read pressure (**thrash** — the same path re-read past a limit amid enough tool volume, tracked by `src/subagent/thrash.ts`), or after the leaf turn budget (**turn-budget**, default 30, overridable via `task(maxTurns)`, agent profile `maxTurns`, or `settings.subagentMaxTurns`, capped at 100), each returning a structured salvage report (reason, partial findings, blockers) so a thrashing child cannot burn tokens indefinitely. A fourth hard stop, **repetition**, is detected outside the director entirely: `runSubAgent`'s stream sink watches the streamed text of the in-flight cycle for degenerate token loops (`src/subagent/repetition.ts`) — whitespace-collapsed raw text, a smallest-period KMP check over the probe tail, default window >= 16 chars repeated >= 8 times, evaluated every 256 streamed chars — and on a hit aborts the run controller mid-cycle, returning a `repetition` salvage report that leads with the looped window and warns the parent against re-dispatching the identical brief. Because directors only see completed turns, this is the only stop that can catch a loop inside a single turn that never finishes. A one-shot **report-forced** signal fires a few turns before the cap while the leaf is still tooling — it is not a stop: the director injects a wrap-up nudge and lets the leaf finish on its own, so turn-budget stays reachable for a leaf still making progress. Operator/parent cancel after any progress likewise returns a **cancelled** salvage report (partial findings + tool activity) instead of a bare cancel string; cancel before progress still surfaces as cancelled-by-operator. Optional `task(tier=)` (`fast` | `standard` | `clever`) overrides profile inference, profile tier, and the parent provider for that spawn only, and fails closed when the tier is unconfigured. The parent `task` tool keeps a session-scoped brief-dispatch ledger (`src/subagent/brief-dispatch.ts`): fingerprints cover prompt + agent + intent + success_criteria + do_not (not maxTurns/description/tier). After thrash / no-progress / repetition / never-acted salvage, an identical re-dispatch is hard-blocked for the rest of the parent chat; change at least one fingerprint field to force a re-run. Turn-budget salvage still invites a higher maxTurns for a few same-brief retries without a successful complete, then flips the parent hint to stop and change approach (soft — further identical dispatches are still admitted). A successful complete resets the same-brief retry budget. @@ -262,7 +262,7 @@ tool call - **classify** — Read-only tools (`read_file`, `search_files`, `grep`, `list_dir`) are tier `allow`; everything else is tier `ask`. Builds approval requests: shell yields one request for the full command the model asked to run (security still splits under the gate); file tools keyed on the target path; other tools keyed on tool name. - **command** — Splits chained commands for security classification and derives command-shape approval scopes. Multi-segment chains only offer an exact-command persist pattern (a prefix like `npm *` must not cover `npm i && rm -rf /` later). -- **auto-shell-policy** — Constrains `run_shell` even when auto mode would otherwise rubber-stamp it. Before matching, `expandShellSubjects` peels `bash`/`sh`/`zsh -c`, `xargs` utility tails, and transparent prefixes (`env`, `nice`, `timeout`, …) so rules see the real payload; an unparseable wrapper (variable expansion or command substitution) sets an opaque flag that forces `ask`. Effects: `deny` blocks outright (file mutations through ad-hoc tooling — output redirection, `tee`, `sed -i`/`perl -i`, interpreter inline programs or heredocs — which must instead go through `write_file`/`edit_file`); `ask` declines to auto-allow and falls through to the operator prompt (recursive `rm`, dependency installs and remote runners: npm/yarn/pnpm/bun, pip, cargo, go, brew, npx/bunx, …, git worktree add/remove/prune, shell that references a sensitive path such as `.env` or a private key, and opaque wrappers). Deny beats ask when multiple subjects match. Quoted spans are stripped before pattern matching so a quoted `>` or install word in an argument is not flagged, and program names are matched only in command position. Adding a table category is a one-line rule append in `AUTO_SHELL_RULES`. +- **auto-shell-policy** — Constrains `run_shell` even when auto mode would otherwise rubber-stamp it. Before matching, `expandShellSubjects` peels `bash`/`sh`/`zsh -c`, `xargs` utility tails, and transparent prefixes (`env`, `nice`, `timeout`, …) so rules see the real payload; an unparseable wrapper (variable expansion or command substitution) sets an opaque flag that forces `ask`. Effects: `deny` blocks outright (file mutations through ad-hoc tooling — output redirection, `tee`, `sed -i`/`perl -i`, interpreter inline programs or heredocs — which must instead go through `write_file`/`edit_file`); `ask` declines to auto-allow and falls through to the operator prompt (recursive `rm`, dependency installs and remote runners: npm/yarn/pnpm/bun, pip, cargo, go, brew, npx/bunx, …, force or uncontained `git worktree` ops, shell that references a sensitive path such as `.env` or a private key, and opaque wrappers). Contained non-force `git worktree add`/`remove`/`prune` and read-only `list` auto-allow (sibling destinations like `../corbits-dispatch-wts/…` included; absolute outside, `~`, globs, and credential basenames still ask). Deny beats ask when multiple subjects match. Quoted spans are stripped before pattern matching so a quoted `>` or install word in an argument is not flagged, and program names are matched only in command position. Adding a table category is a one-line rule append in `AUTO_SHELL_RULES`. - **gate** — Evaluates a call: `skipPermissions` allows everything; `allow`-tier passes; for `ask`-tier, checks persisted approvals, otherwise requests operator approval. Shell security classifies each chain segment (`||` / `&&` / `|` / `;` / newlines), but the operator is prompted once for the full command block — any unapproved segment fails the whole block, and execution always runs the unsplit original. Safe pipeline tails and pure shell no-ops (`true` / `false` / `:` and bare control-flow keywords stranded by chain-splitting) skip without a prompt. In a non-interactive run an unresolved `ask` becomes a denial. In auto mode: non-shell built-ins in `AUTO_ALLOWED_TOOLS` (writes/edits/deletes, `manage_tasks`, `task`, …) auto-allow when not path-restricted; for `run_shell` the gate consults the auto-shell policy — a `deny` rule fails the call, an `ask` rule skips the auto-allow shortcut and proceeds to the normal approval flow, and anything unmatched is auto-allowed. Paths outside the workspace and writes under the session state root (`~/.corbits/projects/...` and legacy `.agent-state`) still ask. Mutating MCP and unknown built-ins are not blanket-allowed. Newly granted scopes are appended in memory and persisted. - **matcher** — Approval pattern matching via `@intx/authz` `matchPattern` (`*` wildcards). Exact-command grants store a backslash before each metacharacter; those patterns match by equality after unescape (the package has no escape syntax). diff --git a/src/permission/auto-shell-policy.ts b/src/permission/auto-shell-policy.ts index 60e73d7ce..4a28c643a 100644 --- a/src/permission/auto-shell-policy.ts +++ b/src/permission/auto-shell-policy.ts @@ -232,7 +232,7 @@ const WORKTREE_ASK_RULE: AutoShellRule = { name: "git-worktree", effect: "ask", reason: - "Git worktree add, remove, and prune change the workspace boundary and need explicit operator approval in auto mode. Only read-only git worktree list can run unattended.", + "This git worktree command uses a force flag, an uncontained path, or a subcommand that still needs explicit operator approval in auto mode. Contained non-force add/remove/prune and read-only list can run unattended.", patterns: [], }; @@ -283,8 +283,109 @@ const UNBOUNDED_LISTING_ASK_RULE: AutoShellRule = { }; const WORKTREE_LIST_FLAGS = new Set(["--porcelain", "-v", "--verbose", "-z"]); +const WORKTREE_PRUNE_FLAGS = new Set(["-n", "--dry-run", "-v", "--verbose"]); +// Flags that take a following value on `git worktree add` (branch name, lock reason). +const WORKTREE_ADD_VALUE_FLAGS = new Set(["-b", "-B", "--reason"]); + +// Sibling worktree destinations must never land in home-config / credential +// stores even when the path is only one level above cwd. +const SCARY_WORKTREE_BASENAMES = new Set([ + ".ssh", + ".gnupg", + ".aws", + ".azure", + ".kube", + ".docker", + ".config", + ".Trash", + "Library", + "AppData", + ".netrc", +]); + +// Agent-owned hidden dirs that are legitimate worktree parents outside cwd. +const ALLOWED_OUTSIDE_DOTDIRS = new Set([".worktrees", ".claude", ".git"]); + +function isWorktreeForceFlag(arg: string): boolean { + return arg === "-f" || arg === "--force"; +} + +// True when the path is safe for unattended worktree add/remove: inside the +// session workspace, or a relative sibling under the parent of cwd that does +// not touch credential/home-config basenames. Globs, ~, absolute outside paths, +// and `../../…` always fail closed. +function isContainedWorktreePath( + pathArg: string, + isRestricted: (path: string, isWrite: boolean) => boolean, +): boolean { + if (!pathArg) return false; + if (/[*?\[]/.test(pathArg)) return false; + if (pathArg.startsWith("~")) return false; + + // Workspace (cwd + registered worktree roots) — always contained. + if (!isRestricted(pathArg, true)) return true; + + // Absolute path outside the workspace (e.g. /tmp/evil) — ask. + if (pathArg.startsWith("/") || /^[A-Za-z]:[\\/]/.test(pathArg)) return false; + + // Relative path that resolves outside workspace: allow only sibling trees + // (at most one `..` net step) with no scary path components. + const parts = pathArg.replace(/\\/g, "/").split("/").filter((p) => p.length > 0 && p !== "."); + let depth = 0; + for (const part of parts) { + if (part === "..") { + depth -= 1; + if (depth < -1) return false; + continue; + } + if (SCARY_WORKTREE_BASENAMES.has(part)) return false; + if (part.startsWith(".") && !ALLOWED_OUTSIDE_DOTDIRS.has(part)) return false; + depth += 1; + } + // Bare `..` (parent of cwd as the worktree path) is not a contained destination. + return depth >= 0; +} + +// Walks worktree args, recording force and every positional path. Value-taking +// flags consume the next token so branch names are not mistaken for paths. +function worktreePathArgs( + args: string[], + valueFlags: Set, +): { force: boolean; paths: string[] } { + const paths: string[] = []; + let force = false; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === "--") { + paths.push(...args.slice(i + 1)); + break; + } + if (isWorktreeForceFlag(arg)) { + force = true; + continue; + } + if (arg.startsWith("-") && arg !== "-") { + // `--flag=value` carries its value inline; no following token to skip. + if (arg.includes("=")) continue; + if (valueFlags.has(arg)) { + i += 1; + continue; + } + continue; + } + paths.push(arg); + } + return { force, paths }; +} -function safeWorktreeCommand(command: string): boolean | undefined { +// `true` = auto-allow, `false` = ask, `undefined` = not a worktree command. +// Contained non-force add/remove and ordinary prune/list auto-allow so dispatch +// can create sibling worktrees without a human click; force flags, uncontained +// paths, and uncommon subcommands still ask. +function safeWorktreeCommand( + command: string, + isRestricted: (path: string, isWrite: boolean) => boolean, +): boolean | undefined { const tokens = tokenize(command); if (tokens[0] !== "git" || !tokens.slice(1).includes("worktree")) return undefined; // Worktree policy applies only to one plain command with no git cwd override; @@ -294,7 +395,32 @@ function safeWorktreeCommand(command: string): boolean | undefined { const args = tokens.slice(3); if (subcommand === "list") return args.every((arg) => WORKTREE_LIST_FLAGS.has(arg)); - // Boundary-changing subcommands always route to ask, even when the destination is inside cwd. + + if (subcommand === "prune") { + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (WORKTREE_PRUNE_FLAGS.has(arg)) continue; + if (arg.startsWith("--expire=")) continue; + if (arg === "--expire") { + i += 1; + continue; + } + return false; + } + return true; + } + + if (subcommand === "add" || subcommand === "remove") { + const valueFlags = subcommand === "add" ? WORKTREE_ADD_VALUE_FLAGS : new Set(); + const { force, paths } = worktreePathArgs(args, valueFlags); + if (force) return false; + // add/remove require a path; no path → ask rather than guess. + if (paths.length === 0) return false; + // First positional is the worktree path; later tokens on add are commit-ish. + return isContainedWorktreePath(paths[0]!, isRestricted); + } + + // move / lock / unlock / repair / unknown — still ask until proven safe. return false; } @@ -349,14 +475,16 @@ export function autoShellRuleForCall( // Containment: a command whose path arguments resolve outside the workspace // (including through a symlink) must ask rather than auto-run, the same way - // path-arg tool calls already do. Checked per expanded subject so a wrapped - // payload (bash -c, xargs, env -S) is judged on its real target, not the wrapper. + // path-arg tool calls already do. Contained worktree ops are exempt — their + // destinations are often intentional siblings (`../corbits-dispatch-wts/…`) + // and are judged by the worktree path policy below instead. for (const subject of subjects) { + if (safeWorktreeCommand(subject, isRestricted) === true) continue; if (commandTargetsRestricted(subject, isRestricted)) return OUTSIDE_WORKSPACE_ASK_RULE; } for (const subject of subjects) { - if (safeWorktreeCommand(subject) === false) return WORKTREE_ASK_RULE; + if (safeWorktreeCommand(subject, isRestricted) === false) return WORKTREE_ASK_RULE; } if (matched !== undefined) return matched; diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 4a546738a..461329ebd 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -1088,9 +1088,9 @@ describe("createPermissionGate", () => { expect(asked).toBe(0); }); - test("auto mode prompts for git worktree add inside the workspace", async () => { - const cwd = mkdtempSync(join(tmpdir(), "corbits-worktree-policy-")); + test("auto mode auto-allows contained git worktree add/remove/prune", async () => { let asked = 0; + const cwd = mkdtempSync(join(tmpdir(), "corbits-worktree-policy-")); const gate = createPermissionGate({ approvals: [], requestApproval: async () => { asked++; return { allow: false }; }, @@ -1101,25 +1101,37 @@ describe("createPermissionGate", () => { rootsProvider: () => [], }); - for (const command of ["git worktree add feature", "git worktree add feature main"]) { + for (const command of [ + "git worktree add feature", + "git worktree add feature main", + "git worktree add -b feature-branch ../corbits-dispatch-wts/CL-5602 origin/main", + "git worktree add ../.worktrees/CL-5602", + "git worktree remove feature", + "git worktree prune", + "git worktree prune -n -v", + "git worktree prune --expire=2.weeks.ago", + ]) { asked = 0; - expect((await gate.evaluate(shellCall(command))).allowed).toBe(false); - expect(asked).toBe(1); + expect((await gate.evaluate(shellCall(command))).allowed).toBe(true); + expect(asked).toBe(0); } }); test("auto mode prompts for unsafe git worktree operations", async () => { const cwd = mkdtempSync(join(tmpdir(), "corbits-worktree-policy-")); - const outside = join(cwd, "..", "outside-worktree"); + const outsideAbs = join(tmpdir(), "outside-worktree-absolute"); const commands = [ - `git worktree add ${outside}`, + `git worktree add ${outsideAbs}`, + "git worktree add /tmp/evil-worktree", "git worktree add ~/outside", "git worktree add ~other/outside", "git worktree add feature-*", "git worktree add --force feature", "git worktree add -f feature", - "git worktree remove feature", - "git worktree prune", + "git worktree add ../.ssh/x", + "git worktree add ../../escape", + "git worktree remove --force feature", + "git worktree move feature other", "git --no-pager worktree remove feature", ]; @@ -1476,24 +1488,44 @@ describe("createPermissionGate", () => { expect(verdict.allowed).toBe(true); }); - test("auto mode peels shell -c for git worktree ask", async () => { + test("auto mode peels shell -c for contained git worktree allow and force ask", async () => { const cwd = mkdtempSync(join(tmpdir(), "corbits-worktree-wrapper-")); - let asked = 0; - const gate = createPermissionGate({ - approvals: [], - requestApproval: async () => { - asked++; - return { allow: false }; - }, - interactive: true, - skipPermissions: false, - auto: true, - cwd, - rootsProvider: () => [], - }); - const verdict = await gate.evaluate(shellCall("bash -c 'git worktree add feature'")); - expect(verdict.allowed).toBe(false); - expect(asked).toBe(1); + { + let asked = 0; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => { + asked++; + return { allow: false }; + }, + interactive: true, + skipPermissions: false, + auto: true, + cwd, + rootsProvider: () => [], + }); + const verdict = await gate.evaluate(shellCall("bash -c 'git worktree add feature'")); + expect(verdict.allowed).toBe(true); + expect(asked).toBe(0); + } + { + let asked = 0; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => { + asked++; + return { allow: false }; + }, + interactive: true, + skipPermissions: false, + auto: true, + cwd, + rootsProvider: () => [], + }); + const verdict = await gate.evaluate(shellCall("bash -c 'git worktree add -f feature'")); + expect(verdict.allowed).toBe(false); + expect(asked).toBe(1); + } }); test("auto mode asks for opaque unparseable shell wrappers", async () => { From d5cd0744207fe7466f9ce013fd99d98a3c52978b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 12:28:46 -0700 Subject: [PATCH 2/3] 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. --- src/permission/auto-shell-policy.ts | 70 +++++++++++------------------ src/permission/gate.ts | 2 +- src/permission/path-restriction.ts | 34 ++++++++++++++ src/permission/permission.test.ts | 43 +++++++++++++++++- 4 files changed, 101 insertions(+), 48 deletions(-) diff --git a/src/permission/auto-shell-policy.ts b/src/permission/auto-shell-policy.ts index 4a28c643a..399f99ad5 100644 --- a/src/permission/auto-shell-policy.ts +++ b/src/permission/auto-shell-policy.ts @@ -3,6 +3,8 @@ import { commandHasRecursiveRm, expandShellSubjects } from "../shell/run-shell-a import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js"; import { commandHasUnboundedDirectoryListing, commandTargetsRestricted } from "./classify.js"; import { splitChainedCommand, tokenize } from "./command.js"; +import { isPermittedSiblingWorktreePath } from "./path-restriction.js"; +import type { RootsProvider } from "./worktree-roots.js"; // Auto-mode shell policy: a flat table of rules that constrain what a run_shell // 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"]); // Flags that take a following value on `git worktree add` (branch name, lock reason). const WORKTREE_ADD_VALUE_FLAGS = new Set(["-b", "-B", "--reason"]); -// Sibling worktree destinations must never land in home-config / credential -// stores even when the path is only one level above cwd. -const SCARY_WORKTREE_BASENAMES = new Set([ - ".ssh", - ".gnupg", - ".aws", - ".azure", - ".kube", - ".docker", - ".config", - ".Trash", - "Library", - "AppData", - ".netrc", -]); - -// Agent-owned hidden dirs that are legitimate worktree parents outside cwd. -const ALLOWED_OUTSIDE_DOTDIRS = new Set([".worktrees", ".claude", ".git"]); - function isWorktreeForceFlag(arg: string): boolean { return arg === "-f" || arg === "--force"; } // True when the path is safe for unattended worktree add/remove: inside the -// session workspace, or a relative sibling under the parent of cwd that does -// not touch credential/home-config basenames. Globs, ~, absolute outside paths, -// and `../../…` always fail closed. +// session workspace (the unified containment authority's normal notion), or a +// not-yet-registered sibling location the same authority's narrow +// isPermittedSiblingWorktreePath rule allows (path-restriction.ts). No +// bespoke denylist or depth counter here — everything routes through that one +// authority so a path is never judged "contained" under a looser or stricter +// rule than the one gate.ts uses to decide restriction. function isContainedWorktreePath( pathArg: string, isRestricted: (path: string, isWrite: boolean) => boolean, + cwd: string, + rootsProvider: RootsProvider, ): boolean { if (!pathArg) return false; - if (/[*?\[]/.test(pathArg)) return false; + // Shell-syntax the containment check below cannot resolve correctly: + // `resolve()` treats a leading `~` as a literal path segment rather than + // expanding it, so a home-relative path would otherwise read as "inside + // cwd"; a glob is not a single concrete destination at all. + if (/[*?[]/.test(pathArg)) return false; if (pathArg.startsWith("~")) return false; // Workspace (cwd + registered worktree roots) — always contained. if (!isRestricted(pathArg, true)) return true; - // Absolute path outside the workspace (e.g. /tmp/evil) — ask. - if (pathArg.startsWith("/") || /^[A-Za-z]:[\\/]/.test(pathArg)) return false; - - // Relative path that resolves outside workspace: allow only sibling trees - // (at most one `..` net step) with no scary path components. - const parts = pathArg.replace(/\\/g, "/").split("/").filter((p) => p.length > 0 && p !== "."); - let depth = 0; - for (const part of parts) { - if (part === "..") { - depth -= 1; - if (depth < -1) return false; - continue; - } - if (SCARY_WORKTREE_BASENAMES.has(part)) return false; - if (part.startsWith(".") && !ALLOWED_OUTSIDE_DOTDIRS.has(part)) return false; - depth += 1; - } - // Bare `..` (parent of cwd as the worktree path) is not a contained destination. - return depth >= 0; + return isPermittedSiblingWorktreePath(cwd, pathArg, rootsProvider); } // Walks worktree args, recording force and every positional path. Value-taking @@ -385,6 +359,8 @@ function worktreePathArgs( function safeWorktreeCommand( command: string, isRestricted: (path: string, isWrite: boolean) => boolean, + cwd: string, + rootsProvider: RootsProvider, ): boolean | undefined { const tokens = tokenize(command); if (tokens[0] !== "git" || !tokens.slice(1).includes("worktree")) return undefined; @@ -417,7 +393,7 @@ function safeWorktreeCommand( // add/remove require a path; no path → ask rather than guess. if (paths.length === 0) return false; // First positional is the worktree path; later tokens on add are commit-ish. - return isContainedWorktreePath(paths[0]!, isRestricted); + return isContainedWorktreePath(paths[0]!, isRestricted, cwd, rootsProvider); } // move / lock / unlock / repair / unknown — still ask until proven safe. @@ -433,9 +409,13 @@ function preferRule(a: AutoShellRule | undefined, b: AutoShellRule | undefined): return a; } +const NO_ROOTS: RootsProvider = () => []; + export function autoShellRuleForCall( call: ToolCall, isRestricted: (path: string, isWrite: boolean) => boolean = () => false, + cwd: string = process.cwd(), + rootsProvider: RootsProvider = NO_ROOTS, ): AutoShellRule | undefined { if (call.name !== "run_shell") return undefined; const command = call.arguments.command; @@ -479,12 +459,12 @@ export function autoShellRuleForCall( // destinations are often intentional siblings (`../corbits-dispatch-wts/…`) // and are judged by the worktree path policy below instead. for (const subject of subjects) { - if (safeWorktreeCommand(subject, isRestricted) === true) continue; + if (safeWorktreeCommand(subject, isRestricted, cwd, rootsProvider) === true) continue; if (commandTargetsRestricted(subject, isRestricted)) return OUTSIDE_WORKSPACE_ASK_RULE; } for (const subject of subjects) { - if (safeWorktreeCommand(subject, isRestricted) === false) return WORKTREE_ASK_RULE; + if (safeWorktreeCommand(subject, isRestricted, cwd, rootsProvider) === false) return WORKTREE_ASK_RULE; } if (matched !== undefined) return matched; diff --git a/src/permission/gate.ts b/src/permission/gate.ts index ad8c1227e..6f8725849 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -356,7 +356,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission // operator prompt. Everything else auto-allows. Path-keyed secret // reads stay hard-denied by secret-guard; shell that only *mentions* // a secret path is ask so an explicit one-time approval can pass it. - const shellRule = autoShellRuleForCall(call, isRestrictedHere); + const shellRule = autoShellRuleForCall(call, isRestrictedHere, effectiveCwd, rootsProvider); if (shellRule?.effect === "deny") return { allowed: false, reason: shellRule.reason }; if (shellRule === undefined) return { allowed: true }; } else if (!restricted && AUTO_ALLOWED_TOOLS.has(call.name)) { diff --git a/src/permission/path-restriction.ts b/src/permission/path-restriction.ts index 73cf07ebd..6950df9a6 100644 --- a/src/permission/path-restriction.ts +++ b/src/permission/path-restriction.ts @@ -86,6 +86,40 @@ export function resolveWorkspacePath( return undefined; } +// Whether `path` (relative to `cwd`) names a not-yet-created sibling worktree +// location: a direct child of the parent directory of `cwd` or of a currently +// registered root — the "one new dir next to something already trusted" shape +// `git worktree add ../name` uses. This is the single containment authority's +// answer to "can auto mode create a brand-new worktree that isn't a registered +// root yet"; there is deliberately no separate basename denylist or `..` depth +// counter — the parent-directory equality check *is* the depth bound (a path +// with any extra segment resolves to a different, non-matching parent), and +// the home-directory guard below is the one home-config bag it purpose-built +// against ($HOME's own children — .ssh, .aws, .config, … must never qualify). +export function isPermittedSiblingWorktreePath( + cwd: string, + path: string, + rootsProvider: RootsProvider = () => [], + home: string = homedir(), +): boolean { + if (path.length === 0) return false; + if (/[*?[]/.test(path)) return false; + if (path.startsWith("~")) return false; + if (path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path)) return false; + + const abs = resolve(cwd, path); + const realParent = realpathOr(dirname(abs)); + const realHome = realpathOr(resolve(home)); + if (realParent === realHome) return false; + + const knownRoots = [...rootsProvider(), ...rootsProvider(true)]; + const trustedParents = new Set([ + realpathOr(resolve(cwd, "..")), + ...knownRoots.map((root) => realpathOr(dirname(root))), + ]); + return trustedParents.has(realParent); +} + function underRoot(abs: string, root: string): boolean { // realpathNearestOr on both sides so a not-yet-created state root still // compares equal to paths under it (realpathOr alone leaves the root diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 461329ebd..8c1e15969 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -1104,8 +1104,10 @@ describe("createPermissionGate", () => { for (const command of [ "git worktree add feature", "git worktree add feature main", - "git worktree add -b feature-branch ../corbits-dispatch-wts/CL-5602 origin/main", - "git worktree add ../.worktrees/CL-5602", + // Sibling worktree directly under the parent of cwd — the narrow + // isPermittedSiblingWorktreePath shape (path-restriction.ts): a brand + // new, not-yet-registered root one level up from cwd. + "git worktree add -b feature-branch ../CL-5602 origin/main", "git worktree remove feature", "git worktree prune", "git worktree prune -n -v", @@ -1117,6 +1119,36 @@ describe("createPermissionGate", () => { } }); + test("auto mode auto-allows a relative sibling worktree next to a registered root, zero cwd-sibling roots needed", async () => { + // Reproduces the product need: creating a brand-new sibling worktree that + // by definition isn't a registered root yet. Here the registered root + // lives in its own parent directory (an org-style "…/wts/" layout) + // distinct from cwd's own parent, and cwd reaches the new sibling through + // a relative "../../wts/CL-5602" path — still the narrow one-level-up + // sibling shape, just anchored at a different trusted parent than cwd's. + let asked = 0; + const base = mkdtempSync(join(tmpdir(), "corbits-worktree-org-")); + const cwd = join(base, "main-repo"); + mkdirSync(cwd); + const wtsDir = join(base, "wts"); + mkdirSync(wtsDir); + const otherRoot = join(wtsDir, "existing-wt"); + mkdirSync(otherRoot); + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => { asked++; return { allow: false }; }, + interactive: true, + skipPermissions: false, + auto: true, + cwd, + rootsProvider: () => [realpathSync(otherRoot)], + }); + + const verdict = await gate.evaluate(shellCall("git worktree add ../wts/CL-5602-new")); + expect(verdict.allowed).toBe(true); + expect(asked).toBe(0); + }); + test("auto mode prompts for unsafe git worktree operations", async () => { const cwd = mkdtempSync(join(tmpdir(), "corbits-worktree-policy-")); const outsideAbs = join(tmpdir(), "outside-worktree-absolute"); @@ -1130,6 +1162,13 @@ describe("createPermissionGate", () => { "git worktree add -f feature", "git worktree add ../.ssh/x", "git worktree add ../../escape", + // Nested siblings ("container/leaf") no longer auto-allow: the old + // basename-denylist-plus-depth-counter heuristic let these through with + // zero registered roots, but they don't fit the unified, narrow + // isPermittedSiblingWorktreePath shape (a direct child of the parent of + // cwd or of a registered root) — see path-restriction.ts. + "git worktree add -b feature-branch ../corbits-dispatch-wts/CL-5602 origin/main", + "git worktree add ../.worktrees/CL-5602", "git worktree remove --force feature", "git worktree move feature other", "git --no-pager worktree remove feature", From 4a8c8f7ac08f891c10fc48b489e407edeb7e9ddf Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 12:32:54 -0700 Subject: [PATCH 3/3] Clarify why the glob/tilde guard is duplicated in auto-shell-policy.ts --- src/permission/auto-shell-policy.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/permission/auto-shell-policy.ts b/src/permission/auto-shell-policy.ts index 399f99ad5..b577791d4 100644 --- a/src/permission/auto-shell-policy.ts +++ b/src/permission/auto-shell-policy.ts @@ -307,10 +307,15 @@ function isContainedWorktreePath( rootsProvider: RootsProvider, ): boolean { if (!pathArg) return false; - // Shell-syntax the containment check below cannot resolve correctly: - // `resolve()` treats a leading `~` as a literal path segment rather than - // expanding it, so a home-relative path would otherwise read as "inside - // cwd"; a glob is not a single concrete destination at all. + // Shell-syntax `isRestricted` below cannot resolve correctly: `resolve()` + // treats a leading `~` as a literal path segment rather than expanding it, + // so a home-relative path would otherwise read as "inside cwd" on the very + // next line; a glob is not a single concrete destination at all. This + // duplicates isPermittedSiblingWorktreePath's own guard against the same + // two forms, but that duplication is required, not incidental: this check + // has to run before the isRestricted() shortcut below even executes, while + // isPermittedSiblingWorktreePath's copy protects direct/standalone callers + // of that exported function. if (/[*?[]/.test(pathArg)) return false; if (pathArg.startsWith("~")) return false;