Skip to content

Commit 208e987

Browse files
committed
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.
1 parent 2b6b0ec commit 208e987

3 files changed

Lines changed: 194 additions & 34 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ In TUI chat mode there is no completion gate — the session stays open across t
9494

9595
Two directors, selected by role:
9696

97-
- **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.
97+
- **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.
9898
- **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.
9999

100100

@@ -262,7 +262,7 @@ tool call
262262

263263
- **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.
264264
- **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).
265-
- **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`.
265+
- **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`.
266266
- **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.
267267

268268
- **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).

src/permission/auto-shell-policy.ts

Lines changed: 134 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ const WORKTREE_ASK_RULE: AutoShellRule = {
232232
name: "git-worktree",
233233
effect: "ask",
234234
reason:
235-
"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.",
235+
"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.",
236236
patterns: [],
237237
};
238238

@@ -283,8 +283,109 @@ const UNBOUNDED_LISTING_ASK_RULE: AutoShellRule = {
283283
};
284284

285285
const WORKTREE_LIST_FLAGS = new Set(["--porcelain", "-v", "--verbose", "-z"]);
286+
const WORKTREE_PRUNE_FLAGS = new Set(["-n", "--dry-run", "-v", "--verbose"]);
287+
// Flags that take a following value on `git worktree add` (branch name, lock reason).
288+
const WORKTREE_ADD_VALUE_FLAGS = new Set(["-b", "-B", "--reason"]);
289+
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+
309+
function isWorktreeForceFlag(arg: string): boolean {
310+
return arg === "-f" || arg === "--force";
311+
}
312+
313+
// 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.
317+
function isContainedWorktreePath(
318+
pathArg: string,
319+
isRestricted: (path: string, isWrite: boolean) => boolean,
320+
): boolean {
321+
if (!pathArg) return false;
322+
if (/[*?\[]/.test(pathArg)) return false;
323+
if (pathArg.startsWith("~")) return false;
324+
325+
// Workspace (cwd + registered worktree roots) — always contained.
326+
if (!isRestricted(pathArg, true)) return true;
327+
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;
347+
}
348+
349+
// Walks worktree args, recording force and every positional path. Value-taking
350+
// flags consume the next token so branch names are not mistaken for paths.
351+
function worktreePathArgs(
352+
args: string[],
353+
valueFlags: Set<string>,
354+
): { force: boolean; paths: string[] } {
355+
const paths: string[] = [];
356+
let force = false;
357+
for (let i = 0; i < args.length; i++) {
358+
const arg = args[i]!;
359+
if (arg === "--") {
360+
paths.push(...args.slice(i + 1));
361+
break;
362+
}
363+
if (isWorktreeForceFlag(arg)) {
364+
force = true;
365+
continue;
366+
}
367+
if (arg.startsWith("-") && arg !== "-") {
368+
// `--flag=value` carries its value inline; no following token to skip.
369+
if (arg.includes("=")) continue;
370+
if (valueFlags.has(arg)) {
371+
i += 1;
372+
continue;
373+
}
374+
continue;
375+
}
376+
paths.push(arg);
377+
}
378+
return { force, paths };
379+
}
286380

287-
function safeWorktreeCommand(command: string): boolean | undefined {
381+
// `true` = auto-allow, `false` = ask, `undefined` = not a worktree command.
382+
// Contained non-force add/remove and ordinary prune/list auto-allow so dispatch
383+
// can create sibling worktrees without a human click; force flags, uncontained
384+
// paths, and uncommon subcommands still ask.
385+
function safeWorktreeCommand(
386+
command: string,
387+
isRestricted: (path: string, isWrite: boolean) => boolean,
388+
): boolean | undefined {
288389
const tokens = tokenize(command);
289390
if (tokens[0] !== "git" || !tokens.slice(1).includes("worktree")) return undefined;
290391
// Worktree policy applies only to one plain command with no git cwd override;
@@ -294,7 +395,32 @@ function safeWorktreeCommand(command: string): boolean | undefined {
294395
const args = tokens.slice(3);
295396

296397
if (subcommand === "list") return args.every((arg) => WORKTREE_LIST_FLAGS.has(arg));
297-
// Boundary-changing subcommands always route to ask, even when the destination is inside cwd.
398+
399+
if (subcommand === "prune") {
400+
for (let i = 0; i < args.length; i++) {
401+
const arg = args[i]!;
402+
if (WORKTREE_PRUNE_FLAGS.has(arg)) continue;
403+
if (arg.startsWith("--expire=")) continue;
404+
if (arg === "--expire") {
405+
i += 1;
406+
continue;
407+
}
408+
return false;
409+
}
410+
return true;
411+
}
412+
413+
if (subcommand === "add" || subcommand === "remove") {
414+
const valueFlags = subcommand === "add" ? WORKTREE_ADD_VALUE_FLAGS : new Set<string>();
415+
const { force, paths } = worktreePathArgs(args, valueFlags);
416+
if (force) return false;
417+
// add/remove require a path; no path → ask rather than guess.
418+
if (paths.length === 0) return false;
419+
// First positional is the worktree path; later tokens on add are commit-ish.
420+
return isContainedWorktreePath(paths[0]!, isRestricted);
421+
}
422+
423+
// move / lock / unlock / repair / unknown — still ask until proven safe.
298424
return false;
299425
}
300426

@@ -349,14 +475,16 @@ export function autoShellRuleForCall(
349475

350476
// Containment: a command whose path arguments resolve outside the workspace
351477
// (including through a symlink) must ask rather than auto-run, the same way
352-
// path-arg tool calls already do. Checked per expanded subject so a wrapped
353-
// payload (bash -c, xargs, env -S) is judged on its real target, not the wrapper.
478+
// path-arg tool calls already do. Contained worktree ops are exempt — their
479+
// destinations are often intentional siblings (`../corbits-dispatch-wts/…`)
480+
// and are judged by the worktree path policy below instead.
354481
for (const subject of subjects) {
482+
if (safeWorktreeCommand(subject, isRestricted) === true) continue;
355483
if (commandTargetsRestricted(subject, isRestricted)) return OUTSIDE_WORKSPACE_ASK_RULE;
356484
}
357485

358486
for (const subject of subjects) {
359-
if (safeWorktreeCommand(subject) === false) return WORKTREE_ASK_RULE;
487+
if (safeWorktreeCommand(subject, isRestricted) === false) return WORKTREE_ASK_RULE;
360488
}
361489

362490
if (matched !== undefined) return matched;

0 commit comments

Comments
 (0)