/** * Mechanical filesystem confinement for the worker sessions this package * starts — and only those. * * The harness has no first-class fs-policy field, but `createAgentSession` * accepts inline `extensions` that subscribe to `tool_call` and can return * `{ block: true }` before a tool runs (see the harness `protected-paths` * example). {@link worktreeConfinement} rides on that seam: a worker may touch * its own checkout and nothing else. * * The orchestrator is deliberately **unconfined** (operator ruling, #143). * An earlier release jailed it to an allowlist, which was never the boundary it * appeared to be: the gate only existed inside sessions this daemon spawns, so * every externally started orchestrator — the supported shape — ran without it * while the brief claimed the refusal was absolute. Rather than widen a jail * nobody can install everywhere, the orchestrator's boundaries are the brief it * is handed and the verb ledger every `conductor_*` call writes, and the * operator reads both. Unconfined means auditable, not licensed. * * `bash` is deliberately not confined here either: its input is an opaque shell * string, and parsing it is a false sense of security. This gate does not * contain a determined session, and no comment or doc here should claim * otherwise — closing that gap would take a least-privilege OS principal for * the worker, not a regex over `rm -rf`. * * What it does buy is a *legible* refusal that holds on hosts where no uid * split will ever be deployed: a structured `edit` outside the assigned * worktree comes back naming the worktree, which the model can act on in one * turn, instead of an `EACCES` it may read as transient and retry. */ import { existsSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; /** Tools whose structured `path` (or path-like) field we can gate. */ const GATED = new Set(["write", "edit", "read", "grep", "glob"]); /** Real path of a confinement root, or the lexical one when it does not exist. */ function realRoot(root: string): string { try { return realpathSync(resolve(root)); } catch { return resolve(root); } } /** * Resolve `candidate` against an already-realpath'd `base`, following symlinks * through every component that exists. * * A path that does not exist yet (a new write) realpaths the deepest existing * ancestor and appends the rest — the same TOCTOU posture as the harness's * own workspace confinement helper. */ function realResolve(base: string, candidate: string): string { const abs = resolve(base, candidate); const missing: string[] = []; let probe = abs; while (!existsSync(probe)) { const parent = dirname(probe); if (parent === probe) break; missing.unshift(basename(probe)); probe = parent; } let head: string; try { head = realpathSync(probe); } catch { head = probe; } return missing.length === 0 ? head : join(head, ...missing); } /** Whether an already-resolved path stays under an already-resolved root. */ function contains(rootReal: string, resolved: string): boolean { const rel = relative(rootReal, resolved); // Inside ⇒ "" or a relative path that does not climb out. Absolute `rel` is // a Windows drive mismatch; anything starting with `..` has left the root. return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)); } /** * Resolve `candidate` as a worker would, then ask whether it stays under * `root`. Symlink-aware: existing path components are realpath'd so a link * planted inside the worktree cannot escape by string-prefix tricks. */ export function isInsideWorktree(root: string, candidate: string): boolean { if (candidate.length === 0 || candidate.includes("\0")) return false; const rootReal = realRoot(root); return contains(rootReal, realResolve(rootReal, candidate)); } /** Pull the path-like field a gated tool carries, if any. */ export function pathFromToolInput(toolName: string, input: Record): string | undefined { if (!GATED.has(toolName)) return undefined; const path = input.path; if (typeof path === "string" && path.length > 0) return path; // glob / grep sometimes scope via target_directory / path_filter; only a // concrete directory root is confinable without inventing a glob parser. const target = input.target_directory ?? input.cwd; if (typeof target === "string" && target.length > 0) return target; return undefined; } export type ConfineDecision = { block: true; reason: string }; /** * Decide whether one tool_call may run. Undefined means "no opinion" (allow). * Pure so tests pin the gate without standing up a harness session. */ export function confineToolCall( root: string, toolName: string, input: Record, ): ConfineDecision | undefined { const path = pathFromToolInput(toolName, input); if (path === undefined) return undefined; if (isInsideWorktree(root, path)) return undefined; return { block: true, reason: `Blocked: ${toolName} path "${path}" is outside the worker worktree (${root}). ` + `Structured file tools may only touch the assigned checkout; use paths under it.`, }; } /** * Minimal extension surface this package needs. Kept duck-typed so the peer * harness does not have to be on disk for `tsc` — same reason `omp.ts` exists. */ export interface ConfinementPi { on( event: "tool_call", handler: ( event: { toolName: string; input: Record }, ctx: unknown, ) => ConfineDecision | undefined | Promise, ): void; } /** * Inline extension factory for `createAgentSession({ extensions: [...] })`. * Installs the worktree gate on every structured file tool_call. */ export function worktreeConfinement(root: string): (pi: ConfinementPi) => void { const rootAbs = resolve(root); return (pi) => { pi.on("tool_call", (event) => confineToolCall(rootAbs, event.toolName, event.input)); }; } /** * Tools a read-only session may call: reading, searching, and thinking. * * An allowlist rather than a blocklist, because the failure directions are not * symmetric. A tool this package has never heard of — one a newer harness adds, or * an MCP server contributes — must arrive **denied**: a blocklist would admit it by * default, and the first unknown write tool would silently reopen the hole. */ const READ_ONLY_TOOLS = new Set([ "read", "grep", "glob", "list", "ls", "todo", "think", "sequentialthinking", ]); /** * Decide whether one tool_call may run in a read-only session. * * Unlike {@link confineToolCall}, this refuses `bash` **outright** rather than * inspecting it. That is the distinction the module header draws: parsing a shell * string is a false sense of security, but declining to hand over a shell at all * is a real boundary. A probe needs to read files, so it loses nothing. */ export function readOnlyToolCall(toolName: string): ConfineDecision | undefined { if (READ_ONLY_TOOLS.has(toolName)) return undefined; return { block: true, reason: `Blocked: ${toolName} is not available to this session, which may only read. ` + `Answer from the files you can read (read, grep, glob) — there is no shell, ` + `no edit, and no network verb here.`, }; } /** * Inline extension factory installing the read-only gate (#307). * * **Where this boundary does and does not hold.** It is installed by * {@link createLocalSession}, so it covers exactly the sessions this package * spawns. That is the whole population for the probes it exists for — a probe has * no externally started shape, which is precisely what made the orchestrator's * gate unenforceable in #143 — so for those it is absolute rather than advisory. * It is not a sandbox: it constrains the session's tools, not the process. */ export function readOnlySession(): (pi: ConfinementPi) => void { return (pi) => { pi.on("tool_call", (event) => readOnlyToolCall(event.toolName)); }; }