Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.


Expand Down Expand Up @@ -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).
Expand Down
125 changes: 119 additions & 6 deletions src/permission/auto-shell-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -232,7 +234,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: [],
};

Expand Down Expand Up @@ -283,8 +285,88 @@ 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"]);

function safeWorktreeCommand(command: string): boolean | undefined {
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 (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;
// 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;

// Workspace (cwd + registered worktree roots) — always contained.
if (!isRestricted(pathArg, true)) return true;

return isPermittedSiblingWorktreePath(cwd, pathArg, rootsProvider);
}

// 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<string>,
): { 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 };
}

// `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,
cwd: string,
rootsProvider: RootsProvider,
): 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;
Expand All @@ -294,7 +376,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<string>();
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, cwd, rootsProvider);
}

// move / lock / unlock / repair / unknown — still ask until proven safe.
return false;
}

Expand All @@ -307,9 +414,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;
Expand Down Expand Up @@ -349,14 +460,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, cwd, rootsProvider) === 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, cwd, rootsProvider) === false) return WORKTREE_ASK_RULE;
}

if (matched !== undefined) return matched;
Expand Down
2 changes: 1 addition & 1 deletion src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
34 changes: 34 additions & 0 deletions src/permission/path-restriction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
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
Expand Down
Loading
Loading