From 0f4d1fc8c722a57f389d5c9c28754ceee98f8667 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 03:34:32 -0700 Subject: [PATCH] Honor skip-permissions through pre-gate workspace sandboxes Under --dangerously-skip-permissions the gate already auto-allows, but path-escape, delete_file, list_dir, and shell cwd retention still hard- denied outside-workspace paths. Thread getSkipPermissions into those sandboxes so yolo mode can reach other repos and retain out-of-tree shell cwd, while secret-guard and authz hard denies stay in force. --- docs/ARCHITECTURE.md | 2 +- docs/IMPLEMENTATION.md | 2 +- src/agent/posix-tool-plugins.test.ts | 57 ++++++++++++++++++++++++++ src/agent/posix-tool-plugins.ts | 10 +++-- src/agent/tools.ts | 4 +- src/list-dir.test.ts | 18 ++++++++ src/permission/gate.ts | 5 +++ src/permission/permission.test.ts | 44 ++++++++++++++++++++ src/plugins/delete-file-plugin.test.ts | 14 +++++++ src/plugins/delete-file-plugin.ts | 8 +++- src/plugins/path-escape-plugin.test.ts | 32 +++++++++++++++ src/plugins/path-escape-plugin.ts | 38 +++++++++++++---- src/plugins/shell-guard-plugin.ts | 11 ++++- src/shell/persistent-shell-cwd.test.ts | 18 ++++++++ src/shell/persistent-shell-cwd.ts | 19 +++++++-- src/util/list-dir.ts | 25 ++++++++--- tests/unit/agent-tools.test.ts | 5 ++- tests/unit/tui/agent-tools.test.ts | 1 + 18 files changed, 286 insertions(+), 27 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0198b3836..eec50bd5b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -285,7 +285,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, …, 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. +- **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 under auto mode. Under `--dangerously-skip-permissions`, the gate auto-allows those same cases, and pre-gate sandboxes (path-escape, shell session cwd retention, `list_dir` / `delete_file` workspace bounds) honor `getSkipPermissions()` so outside-workspace access is not hard-denied after the gate already allowed it. Secret-guard path denies and authorization hard blocks still apply. Mutating MCP and unknown built-ins are not blanket-allowed outside skip. 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). - **authz-grants** — Maps stored approvals into `@intx/authz` `GrantRule`s and evaluates them with `evaluateGrants` (allow-only; Corbits cwd/provider-model filters applied first). Exact-escaped grants bypass the package path and use equality. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 28252eb04..54383b8c6 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -282,7 +282,7 @@ Providers and credentials are read exclusively from settings files: the global ` | `--model ` | provider default | Select a model for the active provider | | `--force` | false | Override an existing run state | -| `--dangerously-skip-permissions` | false | Auto-allow anything not denied by the authorization layer | +| `--dangerously-skip-permissions` | false | Auto-allow anything not denied by the authorization layer (gate + pre-gate workspace sandboxes; secret-guard / authz hard denies remain) | | `--auto` | true (default) | Force auto mode on (workspace writes + unconstrained shell without prompts) | | `--no-auto` | false | Start with auto mode off (ask on every consequential action); no in-session key toggles it | | `--no-workflow` | false | Deprecated no-op; workflows are manual slash commands only | diff --git a/src/agent/posix-tool-plugins.test.ts b/src/agent/posix-tool-plugins.test.ts index 3bfe1e7dd..b350b9ed5 100644 --- a/src/agent/posix-tool-plugins.test.ts +++ b/src/agent/posix-tool-plugins.test.ts @@ -78,6 +78,63 @@ describe("buildCorePosixToolPlugins", () => { } }); + test("skipPermissions allows reading a path outside the workspace", async () => { + const cwd = await mkdtemp(join(tmpdir(), "ic-posix-skip-in-")); + const outside = await mkdtemp(join(tmpdir(), "ic-posix-skip-out-")); + try { + const target = join(outside, "other.txt"); + await writeFile(target, "from-other-repo", "utf8"); + const gate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, + cwd, + }); + const runner = createPosixTools({ + cwd, + plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }), + }); + const result = await runner.run( + { id: "out-1", name: "read_file", arguments: { path: target } }, + new AbortController().signal, + ); + expect(result.isError).not.toBe(true); + expect(String(result.content)).toContain("from-other-repo"); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + + test("without skipPermissions, path-escape still blocks outside-workspace reads", async () => { + const cwd = await mkdtemp(join(tmpdir(), "ic-posix-bound-in-")); + const outside = await mkdtemp(join(tmpdir(), "ic-posix-bound-out-")); + try { + const target = join(outside, "secret.txt"); + await writeFile(target, "secret", "utf8"); + const gate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: false, + auto: true, + cwd, + }); + const runner = createPosixTools({ + cwd, + plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }), + }); + const result = await runner.run( + { id: "bound-1", name: "read_file", arguments: { path: target } }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(String(result.content)).toMatch(/escapes working directory/); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + test("reads bounded tool-output spills when session blob reader is wired", async () => { const cwd = await mkdtemp(join(tmpdir(), "ic-posix-tool-output-")); try { diff --git a/src/agent/posix-tool-plugins.ts b/src/agent/posix-tool-plugins.ts index ef6f47a7f..bd0904e53 100644 --- a/src/agent/posix-tool-plugins.ts +++ b/src/agent/posix-tool-plugins.ts @@ -65,16 +65,20 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP readFileGuard = {}, shellEnv, } = args; + // Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell + // cwd are not hard-denied after the gate already auto-allows. Secret-guard and + // authz still hard-deny regardless. + const allowOutside = permissionGate.getSkipPermissions(); return [ resultTruncationPlugin(), toolResultSecretScrubPlugin(), - pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd)), - deleteFilePlugin(cwd), + pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd), { allowOutside }), + deleteFilePlugin(cwd, { allowOutside }), toolOutputUriPlugin(), secretGuardPlugin(), authzPlugin(), permissionPlugin(permissionGate), - shellGuardPlugin(cwd, shellTimeout, shellEnv), + shellGuardPlugin(cwd, shellTimeout, shellEnv, { allowOutsideCwd: allowOutside }), readFileGuardPlugin(cwd, readFileGuard), ripgrepPlugin(cwd), // Verify wraps the line-range short-circuit (composeMiddleware runs plugins diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 341d85fe3..8af17b7c9 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -213,7 +213,9 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise { expect(out).toContain("outside the workspace"); expect(out).not.toContain("secret.txt"); }); + + test("allowOutside lists a path outside the workspace", async () => { + const dir = await fixture(); + const outside = await mkdtemp(join(tmpdir(), "list-dir-yolo-")); + await writeFile(join(outside, "other.txt"), ""); + const out = await listDirectory(dir, outside, { allowOutside: true }); + expect(out.split("\n")).toContain("other.txt"); + expect(out).not.toContain("outside the workspace"); + }); + + test("allowOutside follows a symlink that resolves outside the workspace", async () => { + const dir = await fixture(); + const outside = await mkdtemp(join(tmpdir(), "list-dir-yolo-link-")); + await writeFile(join(outside, "secret.txt"), ""); + await symlink(outside, join(dir, "escape")); + const out = await listDirectory(dir, "escape", { allowOutside: true }); + expect(out.split("\n")).toContain("secret.txt"); + }); }); diff --git a/src/permission/gate.ts b/src/permission/gate.ts index f75e22635..738bdb469 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -256,6 +256,10 @@ export type PermissionGate = { // Turn auto mode on or off for the rest of the session. SHIFT+TAB in the TUI // wires the toggle here so a switch takes effect on the next tool call. setAuto: (value: boolean) => void; + // Whether --dangerously-skip-permissions is active for this session. Immutable + // after gate construction; pre-gate sandboxes (path-escape, shell cwd bounds) + // consult this so outside-workspace access is not hard-denied under yolo mode. + getSkipPermissions: () => boolean; // Grant a session-only approval outside the normal ask flow, e.g. when the // operator already approved a literal command through ask_operator — so the // matching run_shell call that follows does not prompt a second time. The @@ -610,6 +614,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission setAuto: (value: boolean) => { auto = value; }, + getSkipPermissions: () => skipPermissions, preApprove, registerMcpClient, unregisterMcpServer, diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 39db92533..37fdfc904 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -814,6 +814,50 @@ describe("createPermissionGate", () => { expect((await gate.evaluate(shellCall("curl x"))).allowed).toBe(true); }); + test("skipPermissions auto-allows out-of-workspace path tools without asking", async () => { + let asked = 0; + const outside = mkdtempSync(join(tmpdir(), "corbits-skip-outside-")); + const target = join(outside, "other.ts"); + writeFileSync(target, ""); + const gate = createPermissionGate({ + approvals: [], + cwd: process.cwd(), + requestApproval: async () => { + asked++; + return { allow: false }; + }, + interactive: true, + skipPermissions: true, + }); + const verdict = await gate.evaluate({ + id: "c", + name: "read_file", + arguments: { path: target }, + }); + expect(verdict.allowed).toBe(true); + expect(asked).toBe(0); + expect(gate.getSkipPermissions()).toBe(true); + }); + + test("skipPermissions auto-allows git clone without asking", async () => { + let asked = 0; + const gate = createPermissionGate({ + approvals: [], + cwd: process.cwd(), + requestApproval: async () => { + asked++; + return { allow: false }; + }, + interactive: true, + skipPermissions: true, + }); + const verdict = await gate.evaluate( + shellCall("git clone https://example.com/org/repo.git /tmp/repo"), + ); + expect(verdict.allowed).toBe(true); + expect(asked).toBe(0); + }); + test("non-interactive denies an unapproved consequential call", async () => { const gate = createPermissionGate({ approvals: [], interactive: false, skipPermissions: false }); const verdict = await gate.evaluate(shellCall("curl x")); diff --git a/src/plugins/delete-file-plugin.test.ts b/src/plugins/delete-file-plugin.test.ts index 8be337d6b..f86f2f6ee 100644 --- a/src/plugins/delete-file-plugin.test.ts +++ b/src/plugins/delete-file-plugin.test.ts @@ -95,6 +95,20 @@ describe("deleteFilePlugin", () => { await rm(outside, { recursive: true, force: true }); }); + test("allowOutside deletes a file outside the working directory", async () => { + const outside = await mkdtemp(join(tmpdir(), "corbits-delete-yolo-")); + const path = join(outside, "gone.txt"); + await writeFile(path, "gone"); + const tool = deleteFilePlugin(cwd, { allowOutside: true }).tools?.[0]; + if (tool === undefined) throw new Error("delete_file tool was not registered"); + + const result = await tool.handler(call(path), new AbortController().signal); + + expect(result).toEqual({ callId: "delete-call", content: `Deleted file: ${path}` }); + expect(await exists(path)).toBe(false); + await rm(outside, { recursive: true, force: true }); + }); + test("permission denial prevents deletion", async () => { const path = join(cwd, "keep.txt"); await writeFile(path, "keep"); diff --git a/src/plugins/delete-file-plugin.ts b/src/plugins/delete-file-plugin.ts index e0266daeb..5f115e974 100644 --- a/src/plugins/delete-file-plugin.ts +++ b/src/plugins/delete-file-plugin.ts @@ -42,7 +42,11 @@ function isWithin(root: string, path: string): boolean { return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); } -export function deleteFilePlugin(cwd: string): ToolPlugin { +export function deleteFilePlugin( + cwd: string, + options: { allowOutside?: boolean } = {}, +): ToolPlugin { + const allowOutside = options.allowOutside === true; const tool: ExtraTool = { definition: DELETE_FILE_DEFINITION, handler: async (call: ToolCall): Promise => { @@ -54,7 +58,7 @@ export function deleteFilePlugin(cwd: string): ToolPlugin { const target = resolve(cwd, args.path); try { const [physicalRoot, physicalParent] = await Promise.all([realpath(cwd), realpath(dirname(target))]); - if (!isWithin(physicalRoot, physicalParent)) { + if (!allowOutside && !isWithin(physicalRoot, physicalParent)) { return errorResult(call.id, `${args.path} resolves outside the working directory`); } const info = await lstat(target); diff --git a/src/plugins/path-escape-plugin.test.ts b/src/plugins/path-escape-plugin.test.ts index 5231b32ea..cf00b2502 100644 --- a/src/plugins/path-escape-plugin.test.ts +++ b/src/plugins/path-escape-plugin.test.ts @@ -118,4 +118,36 @@ describe("pathEscapePlugin", () => { expect(result.isError).toBe(true); expect(result.content).toMatch(/escapes working directory/); }); + + test("allowOutside passes outside paths through as absolute", async () => { + const plugin = pathEscapePlugin("/project", () => [], { allowOutside: true }); + const next = async (call: ToolCall): Promise => ({ + callId: call.id, + content: JSON.stringify(call.arguments), + }); + const handler = plugin.middleware ? plugin.middleware(next) : next; + const result = await handler( + makeCall("read_file", { path: "../other-repo/README.md" }), + new AbortController().signal, + ); + expect(result.isError).not.toBe(true); + const args = JSON.parse(String(result.content)) as { path: string }; + expect(args.path).toBe("/other-repo/README.md"); + }); + + test("allowOutside still leaves in-bounds paths absolute under cwd", async () => { + const plugin = pathEscapePlugin("/project", () => [], { allowOutside: true }); + const next = async (call: ToolCall): Promise => ({ + callId: call.id, + content: JSON.stringify(call.arguments), + }); + const handler = plugin.middleware ? plugin.middleware(next) : next; + const result = await handler( + makeCall("read_file", { path: "src/index.ts" }), + new AbortController().signal, + ); + expect(result.isError).not.toBe(true); + const args = JSON.parse(String(result.content)) as { path: string }; + expect(args.path).toBe("/project/src/index.ts"); + }); }); diff --git a/src/plugins/path-escape-plugin.ts b/src/plugins/path-escape-plugin.ts index 9f4952111..e983284e4 100644 --- a/src/plugins/path-escape-plugin.ts +++ b/src/plugins/path-escape-plugin.ts @@ -1,10 +1,23 @@ +import { resolve } from "node:path"; import type { ToolPlugin } from "@intx/tools-posix"; import type { ToolCall, ToolResult } from "@intx/types/runtime"; import { isToolOutputLike } from "../util/tool-output-uri.js"; import { resolveWorkspacePath } from "../permission/path-restriction.js"; import type { RootsProvider } from "../permission/worktree-roots.js"; -export function pathEscapePlugin(cwd: string, rootsProvider: RootsProvider = () => []): ToolPlugin { +export type PathEscapeOptions = { + // When true (yolo / --dangerously-skip-permissions), paths outside the + // workspace still resolve to absolute form and pass through. Secret-guard and + // authz remain the hard-deny layers; the permission gate already auto-allows. + allowOutside?: boolean; +}; + +export function pathEscapePlugin( + cwd: string, + rootsProvider: RootsProvider = () => [], + options: PathEscapeOptions = {}, +): ToolPlugin { + const allowOutside = options.allowOutside === true; return { middleware: (next) => async (call, signal) => { if ("_raw" in call.arguments) { @@ -16,7 +29,7 @@ export function pathEscapePlugin(cwd: string, rootsProvider: RootsProvider = () } let escaped: Record; try { - escaped = escapeArgs(call.arguments, cwd, rootsProvider); + escaped = escapeArgs(call.arguments, cwd, rootsProvider, allowOutside); } catch (err) { const message = err instanceof Error ? err.message : String(err); return { callId: call.id, content: message, isError: true }; @@ -30,11 +43,12 @@ function escapeArgs( args: Record, cwd: string, rootsProvider: RootsProvider, + allowOutside: boolean, ): Record { const out: Record = {}; for (const [key, value] of Object.entries(args)) { if (typeof value === "string" && looksLikePath(key)) { - out[key] = sanitizePath(value, cwd, rootsProvider); + out[key] = sanitizePath(value, cwd, rootsProvider, allowOutside); } else { out[key] = value; } @@ -59,13 +73,23 @@ export function looksLikePath(key: string): boolean { ); } -function sanitizePath(value: string, cwd: string, rootsProvider: RootsProvider): string { +function sanitizePath( + value: string, + cwd: string, + rootsProvider: RootsProvider, + allowOutside: boolean, +): string { if (isToolOutputLike(value)) { return value; } const resolved = resolveWorkspacePath(cwd, value, rootsProvider); - if (resolved === undefined) { - throw new Error(`Path escapes working directory: ${value}`); + if (resolved !== undefined) { + return resolved; + } + if (allowOutside) { + // Same lexical resolve as resolveWorkspacePath's in-bounds branch — absolute + // so later plugins see a stable path, not a relative escape fragment. + return resolve(cwd, value); } - return resolved; + throw new Error(`Path escapes working directory: ${value}`); } diff --git a/src/plugins/shell-guard-plugin.ts b/src/plugins/shell-guard-plugin.ts index 44716d432..ccbeb8ed4 100644 --- a/src/plugins/shell-guard-plugin.ts +++ b/src/plugins/shell-guard-plugin.ts @@ -341,10 +341,12 @@ export function shellGuardPlugin( cwd: string, timeoutConfig?: ShellTimeoutConfig, env?: Record, + options: { allowOutsideCwd?: boolean } = {}, ): ToolPlugin { const defaultMs = timeoutConfig?.defaultMs ?? DEFAULT_SHELL_TIMEOUT_MS; const maxMs = timeoutConfig?.maxMs ?? MAX_SHELL_TIMEOUT_MS; const maxOutputBytes = timeoutConfig?.maxOutputBytes ?? MAX_SHELL_OUTPUT_BYTES; + const allowOutsideCwd = options.allowOutsideCwd === true; const sessionRoot = realpathSync(cwd); let retainedShellCwd = sessionRoot; // Serialize run_shell so concurrent tools cannot race retained cwd updates @@ -377,7 +379,9 @@ export function shellGuardPlugin( let executionCwd = retainedShellCwd; if (perCallCwdRaw !== undefined) { try { - executionCwd = resolvePerCallShellCwd(sessionRoot, perCallCwdRaw); + executionCwd = resolvePerCallShellCwd(sessionRoot, perCallCwdRaw, { + allowOutsideSession: allowOutsideCwd, + }); } catch (err) { return { callId: call.id, @@ -414,7 +418,10 @@ export function shellGuardPlugin( ); const parsed = parsePwdProbeOutput(output); if (perCallCwdRaw === undefined && parsed.finalCwd !== undefined) { - if (!isShellCwdWithinSession(sessionRoot, parsed.finalCwd)) { + if ( + !allowOutsideCwd && + !isShellCwdWithinSession(sessionRoot, parsed.finalCwd) + ) { return { callId: call.id, content: shellCwdEscapesSessionMessage(parsed.finalCwd), diff --git a/src/shell/persistent-shell-cwd.test.ts b/src/shell/persistent-shell-cwd.test.ts index cea2ae54b..e2a8e6593 100644 --- a/src/shell/persistent-shell-cwd.test.ts +++ b/src/shell/persistent-shell-cwd.test.ts @@ -51,6 +51,24 @@ describe("resolvePerCallShellCwd", () => { const { resolvePerCallShellCwd } = await import("./persistent-shell-cwd.js"); expect(resolvePerCallShellCwd(root, "markerdir")).toBe(realpathSync(sub)); }); + + test("rejects paths outside the session root by default", async () => { + const root = await mkdtemp(join(tmpdir(), "ic-resolve-cwd-out-")); + const parent = realpathSync(join(root, "..")); + const { resolvePerCallShellCwd } = await import("./persistent-shell-cwd.js"); + expect(() => resolvePerCallShellCwd(root, parent)).toThrow( + /outside the session workspace/, + ); + }); + + test("allowOutsideSession accepts paths outside the session root", async () => { + const root = await mkdtemp(join(tmpdir(), "ic-resolve-cwd-yolo-")); + const parent = realpathSync(join(root, "..")); + const { resolvePerCallShellCwd } = await import("./persistent-shell-cwd.js"); + expect( + resolvePerCallShellCwd(root, parent, { allowOutsideSession: true }), + ).toBe(parent); + }); }); describe("pwd probe via runGuardedShell", () => { diff --git a/src/shell/persistent-shell-cwd.ts b/src/shell/persistent-shell-cwd.ts index 7c2ff0cac..bb8f643bb 100644 --- a/src/shell/persistent-shell-cwd.ts +++ b/src/shell/persistent-shell-cwd.ts @@ -69,8 +69,18 @@ export function shellCwdEscapesSessionMessage(cwd: string): string { return `Shell cannot retain working directory outside the session workspace: ${cwd}. Stay within the project tree or use an explicit cwd argument.`; } +export type ResolvePerCallShellCwdOptions = { + // When true (--dangerously-skip-permissions), accept a cwd outside the session + // root. Default false keeps the hard session fence. + allowOutsideSession?: boolean; +}; + /** Resolve a per-call `cwd` argument against the session root (not process.cwd()). */ -export function resolvePerCallShellCwd(sessionRoot: string, cwdArg: string): string { +export function resolvePerCallShellCwd( + sessionRoot: string, + cwdArg: string, + options: ResolvePerCallShellCwdOptions = {}, +): string { const root = realpathSync(sessionRoot); const candidate = resolve(root, cwdArg); let resolved: string; @@ -79,7 +89,10 @@ export function resolvePerCallShellCwd(sessionRoot: string, cwdArg: string): str } catch { resolved = candidate; } - if (!isShellCwdWithinSession(root, resolved)) { + if ( + options.allowOutsideSession !== true && + !isShellCwdWithinSession(root, resolved) + ) { throw new Error(shellCwdEscapesSessionMessage(resolved)); } return resolved; @@ -97,4 +110,4 @@ export function assertShellCwdUsable(cwd: string): void { } throw new Error(missingShellCwdMessage(cwd)); } -} \ No newline at end of file +} diff --git a/src/util/list-dir.ts b/src/util/list-dir.ts index 37929c5f0..0c636c4a9 100644 --- a/src/util/list-dir.ts +++ b/src/util/list-dir.ts @@ -25,17 +25,27 @@ export const listDirDefinition: ToolDefinition = { const MAX_ENTRIES = 200; -export async function listDirectory(cwd: string, path: string): Promise { +export type ListDirectoryOptions = { + // When true (--dangerously-skip-permissions), list paths outside the workspace. + allowOutside?: boolean; +}; + +export async function listDirectory( + cwd: string, + path: string, + options: ListDirectoryOptions = {}, +): Promise { + const allowOutside = options.allowOutside === true; const rel = path.length > 0 ? path : "."; const abs = resolve(cwd, rel); - if (abs !== cwd && !abs.startsWith(cwd + sep)) { + if (!allowOutside && abs !== cwd && !abs.startsWith(cwd + sep)) { return `Error: ${rel} is outside the workspace.`; } // A symlink inside the workspace can resolve to a target outside it; the // string prefix check above only sees the lexical path. Resolve the real path // of both the target and the root before comparing so symlink escapes are - // refused. + // refused (unless allowOutside, which is the yolo-mode escape hatch). let realAbs: string; let realCwd: string; try { @@ -44,7 +54,7 @@ export async function listDirectory(cwd: string, path: string): Promise } catch (err) { return `Error: cannot list ${rel}: ${err instanceof Error ? err.message : String(err)}`; } - if (realAbs !== realCwd && !realAbs.startsWith(realCwd + sep)) { + if (!allowOutside && realAbs !== realCwd && !realAbs.startsWith(realCwd + sep)) { return `Error: ${rel} is outside the workspace.`; } @@ -63,7 +73,10 @@ export async function listDirectory(cwd: string, path: string): Promise return shown.join("\n") + (remaining > 0 ? `\n… (${remaining} more entries)` : ""); } -export function createListDirTool(cwd: string): AgentTool { +export function createListDirTool( + cwd: string, + options: ListDirectoryOptions = {}, +): AgentTool { return stringTool({ definition: listDirDefinition, handler: async (rawArgs: Record): Promise => { @@ -72,7 +85,7 @@ export function createListDirTool(cwd: string): AgentTool { return "Error: list_dir requires path (string) if provided."; } const path = parsed.path ?? ""; - return listDirectory(cwd, path); + return listDirectory(cwd, path, options); }, }); } diff --git a/tests/unit/agent-tools.test.ts b/tests/unit/agent-tools.test.ts index f48b6ea7b..fb690ec6f 100644 --- a/tests/unit/agent-tools.test.ts +++ b/tests/unit/agent-tools.test.ts @@ -17,7 +17,10 @@ test("createAgentToolset wires posix tools for a real cwd", async () => { } as unknown as ReturnType); const { createAgentToolset } = await import("../../src/agent/tools.js"); - const permissionGate = { check: async () => ({ allowed: true }) } as never; + const permissionGate = { + check: async () => ({ allowed: true }), + getSkipPermissions: () => false, + } as never; const toolset = await createAgentToolset({ cwd, diff --git a/tests/unit/tui/agent-tools.test.ts b/tests/unit/tui/agent-tools.test.ts index c8a04cb06..ba4103019 100644 --- a/tests/unit/tui/agent-tools.test.ts +++ b/tests/unit/tui/agent-tools.test.ts @@ -132,6 +132,7 @@ const fakePermissionGate = { preApprove: mock(() => {}), registerMcpClient: mock(() => {}), unregisterMcpServer: mock(() => {}), + getSkipPermissions: () => false, }; const callOperator = async (