diff --git a/README.md b/README.md index 972114801..307263b8c 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Corbits Code defaults to **auto mode** (`auto = true`). Workspace file writes/ed Wrappers such as `bash -c '…'`, `sh`/`zsh -c`, `xargs`, and transparent prefixes (`env`, `nice`, `timeout`) are peeled so the same rules apply to the inner command. Unparseable wrappers fall through to ask rather than auto-allow. -Catastrophic patterns (`rm -rf /`, `sudo`, `curl | bash`, force-push, open-ended `find`/`rg`/`grep -r`, …) are always denied by authorization, independent of auto mode. `--dangerously-skip-permissions` is a separate escape hatch that bypasses the permission gate (not secret-guard path denies or authz hard blocks). +Catastrophic patterns (`rm -rf /`, `sudo`, `curl | bash`, force-push, open-ended `find`/`rg`/`grep -r`, …) are always denied by authorization, independent of auto mode. `--dangerously-skip-permissions` (and mid-session `/yolo` in the TUI) is a separate escape hatch that bypasses the permission gate (not secret-guard path denies or authz hard blocks). Details live in `docs/PRODUCT.md` (safety model) and `docs/ARCHITECTURE.md` (permission gate and auto-shell policy). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3248d902b..6521c665e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,7 +72,7 @@ In TUI chat mode there is no completion gate — the session stays open across t - `--config ` replaces the global settings file as the provider source (useful for CI per-run injection). A provider must be defined in a settings file; there is no env fallback. - `settings.ts` owns the schema, validators (the per-repo file rejects credentials), file loaders, and the pure `resolveProvider` precedence function. - `providers.ts` defines the `ProviderCatalogEntry` type and helpers for building TUI provider lists; `profiles.ts` handles profile-level selection logic. -- `loadConfig` is async (it reads settings files). Parses a leading `exec`/`run` subcommand, flags `--cwd`, `--config`, `--provider`, `--model`, `--force`, `--dangerously-skip-permissions`, `--auto` / `--no-auto` (auto mode defaults on); collects positional arguments as the optional initial task for the TUI or the required prompt for exec. +- `loadConfig` is async (it reads settings files). Parses a leading `exec`/`run` subcommand, flags `--cwd`, `--config`, `--provider`, `--model`, `--force`, `--dangerously-skip-permissions` (TUI mid-session twin: `/yolo`), `--auto` / `--no-auto` (auto mode defaults on); collects positional arguments as the optional initial task for the TUI or the required prompt for exec. - Both settings files are on the secret-guard denylist for path-keyed tools, so the agent cannot `read_file` its own credentials. Shell commands that reference them still require explicit operator approval. ### TUI Runner (`src/tui/runner.ts`) @@ -306,7 +306,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 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. +- **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` (or mid-session `/yolo`, which calls `setSkipPermissions`), 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()` live so outside-workspace access is not hard-denied after the gate already allowed it — without rebuilding the plugin stack. 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 8180e248b..3e7e12c71 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -127,7 +127,8 @@ src/ commands/ registry.ts Extensible slash-command registry built-in.ts /help, /model, /settings, /permissions, /plugins, - /clear, /new, /mcp (connect providers from /model) + /clear, /new, /mcp (connect providers from /model), + /yolo tui/ shell.ts Transcript, header, status line, prompt, overlays product-host.ts Creates the CliRenderer, wires the event bridge @@ -143,7 +144,7 @@ docs/ ### Auto Mode -Auto mode defaults **on** (`config.auto = true` from `loadConfig`; pass `--no-auto` to start off, or `--auto` to force on). It is toggled only via those CLI flags — there is currently no in-session key bound to it. The permission gate reads the flag (`getAuto`/`setAuto` in `src/permission/gate.ts`) on the next tool call. +Auto mode defaults **on** (`config.auto = true` from `loadConfig`; pass `--no-auto` to start off, or `--auto` to force on). It is toggled only via those CLI flags — there is currently no in-session key bound to it. The permission gate reads the flag (`getAuto`/`setAuto` in `src/permission/gate.ts`) on the next tool call. Skip-permissions (`--dangerously-skip-permissions`) has a mid-session TUI toggle: `/yolo [on|off|toggle]` (bare `/yolo` also toggles) wires `getSkipPermissions`/`setSkipPermissions` so the gate and pre-gate sandboxes honor the change on the next tool call without rebuilding plugins. When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOLS` and any `run_shell` that does not match the auto-shell policy. The policy (`autoShellRuleForCall` / `AUTO_SHELL_RULES` in `src/permission/auto-shell-policy.ts`) peels wrappers via `expandShellSubjects` (`bash`/`sh`/`zsh -c`, `xargs`, transparent prefixes), then applies: @@ -289,7 +290,7 @@ Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts` | `--model ` | provider default | Select a model for the active provider | | `--profile ` | — | Settings profile | | `--force` | false | Override an existing run state | -| `--dangerously-skip-permissions` | false | Auto-allow anything not denied by the authorization layer (gate + pre-gate workspace sandboxes; secret-guard / authz hard denies remain) | +| `--dangerously-skip-permissions` | false | Auto-allow anything not denied by the authorization layer (gate + pre-gate workspace sandboxes; secret-guard / authz hard denies remain). Mid-session TUI twin: `/yolo [on\|off\|toggle]` via `setSkipPermissions` | | `--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 | | `--help`, `-h` | — | Show help (exit 0 via `CliHelpError`) | diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 77d58cdf0..9f59ff64b 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -62,7 +62,7 @@ $ corbits exec "Add JWT auth to the API" $ corbits run "Add JWT auth to the API" ``` -Same directors, tools, permissions, MCP, plugins, and hooks as the TUI — without the OpenTUI shell. The exec bootstrap is a deliberate fork of the TUI path (not a shared factory yet); see `docs/ARCHITECTURE.md` “Exec Runner” for intentional deltas (no workflow controller; single primary send; non-interactive permission gate). Compaction continuation matches TUI so long runs do not stall after compact. Streams assistant text to stdout for scripts and CI. Non-interactive by default: actions that need operator approval are denied unless `--dangerously-skip-permissions` is set (or auto mode covers them). `ask_operator` reads a single line from stdin when available. +Same directors, tools, permissions, MCP, plugins, and hooks as the TUI — without the OpenTUI shell. The exec bootstrap is a deliberate fork of the TUI path (not a shared factory yet); see `docs/ARCHITECTURE.md` “Exec Runner” for intentional deltas (no workflow controller; single primary send; non-interactive permission gate). Compaction continuation matches TUI so long runs do not stall after compact. Streams assistant text to stdout for scripts and CI. Non-interactive by default: actions that need operator approval are denied unless `--dangerously-skip-permissions` is set (or auto mode covers them). In the TUI, `/yolo` is the mid-session twin of that flag. `ask_operator` reads a single line from stdin when available. Local multi-model capability checks use this path (`bun run eval:capability`); see `evals/capability/README.md`. @@ -77,7 +77,7 @@ Continues from the last saved state in the working directory. ## Safety Model - **Tiered permission gate** — Read-only tools (`read_file`, `search_files`, `grep`, `list_dir`) run freely. Every consequential tool (`write_file`, `edit_file`, `run_shell`, …) is gated. The operator can Allow Once or Allow Always (scoped to a file, a directory, or a command shape); "Allow Always" choices persist per working directory so repeat actions don't interrupt flow. -- **Secret guard** — Path-keyed tools (`read_file`, `write_file`, …) hard-deny sensitive files (`.env`, `id_rsa`, `*.pem`, `.aws/credentials`, `.ssh/*`, `.git-credentials`, and similar), even with approval or `--dangerously-skip-permissions`. Template files like `.env.example` are exempt. Shell commands that *reference* those paths (e.g. `bun --env-file=.env.staging run …`, `cat .env`) require explicit operator approval and never auto-run in auto mode; once approved, they proceed. Tool-result scrubbing still redacts credential-shaped output that reaches the transcript. +- **Secret guard** — Path-keyed tools (`read_file`, `write_file`, …) hard-deny sensitive files (`.env`, `id_rsa`, `*.pem`, `.aws/credentials`, `.ssh/*`, `.git-credentials`, and similar), even with approval, `--dangerously-skip-permissions`, or `/yolo`. Template files like `.env.example` are exempt. Shell commands that *reference* those paths (e.g. `bun --env-file=.env.staging run …`, `cat .env`) require explicit operator approval and never auto-run in auto mode; once approved, they proceed. Tool-result scrubbing still redacts credential-shaped output that reaches the transcript. - **Catastrophic-command deny** — Destructive shell patterns that target system roots (`rm -rf /`, home, `/etc`, …), plus `mkfs`, `dd`, `sudo`, fork bombs, `curl | bash`, force-push, … are blocked before they run. Recursive delete of ordinary workspace paths is not hard-denied but requires operator approval (never auto in auto mode). - **Constrained auto mode** — Default is on (`auto = true`). Pass `--no-auto` to start in ask mode, or `--auto` to force it on; there is currently no in-session key to toggle it. Auto mode auto-approves workspace file writes/edits/deletes and unconstrained shell without per-action prompts, but it is not a free-for-all: - **Denied** (must use `write_file` / `edit_file`): shell file mutations via output redirection, `tee`, `sed -i` / `perl -i`, interpreter inline programs or heredocs. @@ -85,12 +85,12 @@ Continues from the last saved state in the working directory. - **Wrapper peel**: `bash`/`sh`/`zsh -c`, `xargs`, and transparent prefixes (`env`, `nice`, `timeout`, …) are expanded so the same deny/ask rules see the inner payload. - Paths outside the workspace and writes under the session state root still ask; mutating MCP and unknown tools still prompt. -- **Path sandboxing** — Tool path arguments are resolved against the working directory; paths that escape it are blocked. +- **Path sandboxing** — Tool path arguments are resolved against the working directory; paths that escape it are blocked unless `--dangerously-skip-permissions` / `/yolo` is on (secret-guard and authz hard denies still apply). - **Write verification** — After every write/edit the file is re-read and compared to confirm the change actually landed. ## Slash Commands (TUI) -The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (models-only picker for connected accounts; **Alt+A** adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, and `/mcp`, plus a `/` command per available workflow. Plugins can register additional commands. +The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (models-only picker for connected accounts; **Alt+A** adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/mcp`, and `/yolo` (mid-session twin of `--dangerously-skip-permissions`; `/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/` command per available workflow. Plugins can register additional commands. Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go routes each model by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. @@ -115,7 +115,7 @@ The exact turn thresholds are model-family-dependent (tighter for models with ob **What the user sees:** In a non-interactive `corbits exec` run, a consequential action that needs approval returns a tool error explaining that approval is unavailable. -**Recovery:** Re-run interactively (TUI), pre-approve via persisted approvals, narrow the action, or re-run with `--dangerously-skip-permissions`. +**Recovery:** Re-run interactively (TUI), pre-approve via persisted approvals, narrow the action, re-run with `--dangerously-skip-permissions`, or use `/yolo` mid-session in the TUI. ### Resume after interruption diff --git a/src/agent/posix-tool-plugins.test.ts b/src/agent/posix-tool-plugins.test.ts index b350b9ed5..59b89991f 100644 --- a/src/agent/posix-tool-plugins.test.ts +++ b/src/agent/posix-tool-plugins.test.ts @@ -135,6 +135,43 @@ describe("buildCorePosixToolPlugins", () => { } }); + test("setSkipPermissions mid-session unlocks outside paths without rebuilding plugins", async () => { + const cwd = await mkdtemp(join(tmpdir(), "ic-posix-yolo-toggle-in-")); + const outside = await mkdtemp(join(tmpdir(), "ic-posix-yolo-toggle-out-")); + try { + const target = join(outside, "other.txt"); + await writeFile(target, "from-other-repo", "utf8"); + const gate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: false, + auto: true, + cwd, + }); + const runner = createPosixTools({ + cwd, + plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }), + }); + const blocked = await runner.run( + { id: "bound-1", name: "read_file", arguments: { path: target } }, + new AbortController().signal, + ); + expect(blocked.isError).toBe(true); + expect(String(blocked.content)).toMatch(/escapes working directory/); + + gate.setSkipPermissions(true); + const allowed = await runner.run( + { id: "out-1", name: "read_file", arguments: { path: target } }, + new AbortController().signal, + ); + expect(allowed.isError).not.toBe(true); + expect(String(allowed.content)).toContain("from-other-repo"); + } 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 bd0904e53..cf67887b2 100644 --- a/src/agent/posix-tool-plugins.ts +++ b/src/agent/posix-tool-plugins.ts @@ -66,9 +66,11 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP 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(); + // cwd are not hard-denied after the gate already auto-allows. Pass a live + // getter so `/yolo` mid-session unlocks (or re-enforces) bounds without + // rebuilding the plugin stack. Secret-guard and authz still hard-deny + // regardless. + const allowOutside = (): boolean => permissionGate.getSkipPermissions(); return [ resultTruncationPlugin(), toolResultSecretScrubPlugin(), diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 8af17b7c9..dc297f77f 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -214,7 +214,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise permissionGate.getSkipPermissions(), }), createUseSkillTool(cwd, skillDirs, args.telemetry), createWebFetchTool(), diff --git a/src/list-dir.test.ts b/src/list-dir.test.ts index 735ada0b4..493b02a3f 100644 --- a/src/list-dir.test.ts +++ b/src/list-dir.test.ts @@ -63,4 +63,18 @@ describe("listDirectory", () => { const out = await listDirectory(dir, "escape", { allowOutside: true }); expect(out.split("\n")).toContain("secret.txt"); }); + + test("allowOutside getter is resolved per call", async () => { + const dir = await fixture(); + const outside = await mkdtemp(join(tmpdir(), "list-dir-yolo-getter-")); + await writeFile(join(outside, "other.txt"), ""); + let allow = false; + const blocked = await listDirectory(dir, outside, { allowOutside: () => allow }); + expect(blocked).toContain("outside the workspace"); + + allow = true; + const out = await listDirectory(dir, outside, { allowOutside: () => allow }); + expect(out.split("\n")).toContain("other.txt"); + expect(out).not.toContain("outside the workspace"); + }); }); diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 501e3a164..af0592ace 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -256,11 +256,17 @@ export type PermissionGate = { // Turn auto mode on or off for the rest of the session. Live callers (slash // commands, settings) wire the toggle here so a switch takes effect on the // next tool call. There is currently no in-session key chord for this. + // `/yolo` toggles skip-permissions (getSkipPermissions / setSkipPermissions), + // not auto mode. 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. + // Whether --dangerously-skip-permissions / yolo mode is active for this session. + // Pre-gate sandboxes (path-escape, shell cwd bounds) consult this so + // outside-workspace access is not hard-denied under yolo mode. getSkipPermissions: () => boolean; + // Turn skip-permissions on or off for the rest of the session. `/yolo` in the + // TUI wires the toggle here so a switch takes effect on the next tool call — + // including pre-gate sandboxes that read getSkipPermissions live. + setSkipPermissions: (value: boolean) => void; // 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 @@ -273,7 +279,7 @@ export type PermissionGate = { }; export function createPermissionGate(options: PermissionGateOptions): PermissionGate { - const { requestApproval, persist, interactive, skipPermissions, providerName, model, cwd } = options; + const { requestApproval, persist, interactive, providerName, model, cwd } = options; const telemetry = options.telemetry ?? NOOP_TELEMETRY; const mcpTiers = options.mcpTiers ?? createMcpToolPermissionRegistry(); const resolvedCwd = cwd ?? process.cwd(); @@ -287,6 +293,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission // workspace" for a path share one authority. const grantWorkspace = (): GrantWorkspace => ({ resolvedCwd, roots: rootsProvider() }); let auto = options.auto; + let skipPermissions = options.skipPermissions; // Own a private copy so evaluating a grant never mutates the caller's array. const approvals: Approval[] = [...options.approvals]; const activeProviderModel = @@ -616,6 +623,9 @@ export function createPermissionGate(options: PermissionGateOptions): Permission auto = value; }, getSkipPermissions: () => skipPermissions, + setSkipPermissions: (value: boolean) => { + skipPermissions = value; + }, preApprove, registerMcpClient, unregisterMcpServer, diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 37fdfc904..622cea535 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -1103,6 +1103,33 @@ describe("createPermissionGate", () => { expect(asked).toBe(1); }); + test("setSkipPermissions toggles skip live", async () => { + let asked = 0; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => { asked++; return { allow: false }; }, + interactive: true, + skipPermissions: false, + auto: false, + }); + expect(gate.getSkipPermissions()).toBe(false); + const denied = await gate.evaluate({ id: "c", name: "write_file", arguments: { path: "src/a.ts" } }); + expect(denied.allowed).toBe(false); + expect(asked).toBe(1); + + gate.setSkipPermissions(true); + expect(gate.getSkipPermissions()).toBe(true); + const allowed = await gate.evaluate({ id: "c", name: "write_file", arguments: { path: "src/a.ts" } }); + expect(allowed.allowed).toBe(true); + expect(asked).toBe(1); + + gate.setSkipPermissions(false); + expect(gate.getSkipPermissions()).toBe(false); + const deniedAgain = await gate.evaluate({ id: "c", name: "write_file", arguments: { path: "src/a.ts" } }); + expect(deniedAgain.allowed).toBe(false); + expect(asked).toBe(2); + }); + test("auto mode allows shell commands without prompting (authz plugin blocks dangerous ones upstream)", async () => { let asked = 0; const gate = createPermissionGate({ diff --git a/src/plugins/delete-file-plugin.test.ts b/src/plugins/delete-file-plugin.test.ts index f86f2f6ee..6601e150d 100644 --- a/src/plugins/delete-file-plugin.test.ts +++ b/src/plugins/delete-file-plugin.test.ts @@ -109,6 +109,26 @@ describe("deleteFilePlugin", () => { await rm(outside, { recursive: true, force: true }); }); + test("allowOutside getter is resolved per call", async () => { + const outside = await mkdtemp(join(tmpdir(), "corbits-delete-yolo-getter-")); + const path = join(outside, "gone.txt"); + await writeFile(path, "gone"); + let allow = false; + const tool = deleteFilePlugin(cwd, { allowOutside: () => allow }).tools?.[0]; + if (tool === undefined) throw new Error("delete_file tool was not registered"); + + const blocked = await tool.handler(call(path), new AbortController().signal); + expect(blocked.isError).toBe(true); + expect(String(blocked.content)).toContain("resolves outside the working directory"); + expect(await exists(path)).toBe(true); + + allow = true; + 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 5f115e974..b41f6c9e7 100644 --- a/src/plugins/delete-file-plugin.ts +++ b/src/plugins/delete-file-plugin.ts @@ -42,11 +42,22 @@ function isWithin(root: string, path: string): boolean { return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); } +export type DeleteFilePluginOptions = { + // When true (yolo / --dangerously-skip-permissions), delete outside the + // working directory. A getter is resolved per call so `/yolo` mid-session + // takes effect without rebuilding the plugin stack. + allowOutside?: boolean | (() => boolean); +}; + +function resolveAllowOutside(value: boolean | (() => boolean) | undefined): boolean { + if (typeof value === "function") return value(); + return value === true; +} + export function deleteFilePlugin( cwd: string, - options: { allowOutside?: boolean } = {}, + options: DeleteFilePluginOptions = {}, ): ToolPlugin { - const allowOutside = options.allowOutside === true; const tool: ExtraTool = { definition: DELETE_FILE_DEFINITION, handler: async (call: ToolCall): Promise => { @@ -55,6 +66,7 @@ export function deleteFilePlugin( return errorResult(call.id, "delete_file requires a non-empty path"); } + const allowOutside = resolveAllowOutside(options.allowOutside); const target = resolve(cwd, args.path); try { const [physicalRoot, physicalParent] = await Promise.all([realpath(cwd), realpath(dirname(target))]); diff --git a/src/plugins/path-escape-plugin.test.ts b/src/plugins/path-escape-plugin.test.ts index cf00b2502..c792c4e67 100644 --- a/src/plugins/path-escape-plugin.test.ts +++ b/src/plugins/path-escape-plugin.test.ts @@ -150,4 +150,29 @@ describe("pathEscapePlugin", () => { const args = JSON.parse(String(result.content)) as { path: string }; expect(args.path).toBe("/project/src/index.ts"); }); + + test("allowOutside getter is resolved per call", async () => { + let allow = false; + const plugin = pathEscapePlugin("/project", () => [], { allowOutside: () => allow }); + const next = async (call: ToolCall): Promise => ({ + callId: call.id, + content: JSON.stringify(call.arguments), + }); + const handler = plugin.middleware ? plugin.middleware(next) : next; + const blocked = await handler( + makeCall("read_file", { path: "../other-repo/README.md" }), + new AbortController().signal, + ); + expect(blocked.isError).toBe(true); + expect(blocked.content).toMatch(/escapes working directory/); + + allow = true; + const allowed = await handler( + makeCall("read_file", { path: "../other-repo/README.md" }), + new AbortController().signal, + ); + expect(allowed.isError).not.toBe(true); + const args = JSON.parse(String(allowed.content)) as { path: string }; + expect(args.path).toBe("/other-repo/README.md"); + }); }); diff --git a/src/plugins/path-escape-plugin.ts b/src/plugins/path-escape-plugin.ts index e983284e4..3b86ef39f 100644 --- a/src/plugins/path-escape-plugin.ts +++ b/src/plugins/path-escape-plugin.ts @@ -9,15 +9,21 @@ 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; + // A getter is resolved per call so `/yolo` mid-session takes effect without + // rebuilding the plugin stack. + allowOutside?: boolean | (() => boolean); }; +function resolveAllowOutside(value: boolean | (() => boolean) | undefined): boolean { + if (typeof value === "function") return value(); + return value === true; +} + 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) { @@ -29,7 +35,12 @@ export function pathEscapePlugin( } let escaped: Record; try { - escaped = escapeArgs(call.arguments, cwd, rootsProvider, allowOutside); + escaped = escapeArgs( + call.arguments, + cwd, + rootsProvider, + resolveAllowOutside(options.allowOutside), + ); } catch (err) { const message = err instanceof Error ? err.message : String(err); return { callId: call.id, content: message, isError: true }; diff --git a/src/plugins/shell-guard-plugin.test.ts b/src/plugins/shell-guard-plugin.test.ts index a1de03a9a..5b847d943 100644 --- a/src/plugins/shell-guard-plugin.test.ts +++ b/src/plugins/shell-guard-plugin.test.ts @@ -340,6 +340,28 @@ describe("shellGuardPlugin", () => { expect(toolContentTrimmed(stillRoot)).toBe(realpathSync(root)); }); + test("allowOutsideCwd getter allows retaining cwd outside the session workspace", async () => { + const root = await mkdtemp(join(tmpdir(), "ic-escape-cwd-yolo-")); + let allow = false; + const handler = shellGuardPlugin(root, undefined, undefined, { + allowOutsideCwd: () => allow, + }).middleware!(fallback); + const blocked = await handler( + { id: "e1", name: "run_shell", arguments: { command: "cd .. && pwd" } }, + neverAbort(), + ); + expect(blocked.isError).toBe(true); + expect(blocked.content).toMatch(/outside the session workspace/); + + allow = true; + const allowed = await handler( + { id: "e2", name: "run_shell", arguments: { command: "cd .. && pwd" } }, + neverAbort(), + ); + expect(allowed.isError).not.toBe(true); + expect(toolContentTrimmed(allowed)).toBe(realpathSync(join(root, ".."))); + }); + test("retains cwd from cd even when the command exits non-zero", async () => { const root = await mkdtemp(join(tmpdir(), "ic-cd-fail-")); const nested = join(root, "nested"); diff --git a/src/plugins/shell-guard-plugin.ts b/src/plugins/shell-guard-plugin.ts index ccbeb8ed4..d832d32a5 100644 --- a/src/plugins/shell-guard-plugin.ts +++ b/src/plugins/shell-guard-plugin.ts @@ -337,16 +337,27 @@ function budgetExpiry(signal: AbortSignal): Promise { * 10s wall-clock budget to grep/search_files when the agent does not abort * earlier. Does not modify interchange — short-circuits before the base tool. */ +export type ShellGuardPluginOptions = { + // When true (yolo / --dangerously-skip-permissions), shell may retain a cwd + // outside the session root. A getter is resolved per call so `/yolo` + // mid-session takes effect without rebuilding the plugin stack. + allowOutsideCwd?: boolean | (() => boolean); +}; + +function resolveAllowOutsideCwd(value: boolean | (() => boolean) | undefined): boolean { + if (typeof value === "function") return value(); + return value === true; +} + export function shellGuardPlugin( cwd: string, timeoutConfig?: ShellTimeoutConfig, env?: Record, - options: { allowOutsideCwd?: boolean } = {}, + options: ShellGuardPluginOptions = {}, ): 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 @@ -376,6 +387,7 @@ export function shellGuardPlugin( typeof call.arguments.cwd === "string" && call.arguments.cwd.length > 0 ? call.arguments.cwd : undefined; + const allowOutsideCwd = resolveAllowOutsideCwd(options.allowOutsideCwd); let executionCwd = retainedShellCwd; if (perCallCwdRaw !== undefined) { try { diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 8b6f31c26..67e3894af 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -67,6 +67,78 @@ describe("removed approval command", () => { }); }); +describe("/yolo command", () => { + it("is registered", () => { + expect(getCommand("yolo")).toBeDefined(); + }); + + it("toggles skip-permissions when invoked bare", () => { + let skip = false; + const ctx: CommandContext = { + signalClear: () => {}, + getSkipPermissions: () => skip, + setSkipPermissions: (value) => { + skip = value; + }, + }; + expect(getCommand("yolo")!.handler("", ctx)).toEqual({ + type: "message", + text: "Yolo mode on — permission gate bypassed. Secret-guard and authz hard denies still apply.", + }); + expect(skip).toBe(true); + expect(getCommand("yolo")!.handler("", ctx)).toEqual({ + type: "message", + text: "Yolo mode off — permission gate restored.", + }); + expect(skip).toBe(false); + expect(getCommand("yolo")!.handler("toggle", ctx)).toEqual({ + type: "message", + text: "Yolo mode on — permission gate bypassed. Secret-guard and authz hard denies still apply.", + }); + expect(skip).toBe(true); + }); + + it("turns skip-permissions on and off explicitly", () => { + let skip = false; + const ctx: CommandContext = { + signalClear: () => {}, + getSkipPermissions: () => skip, + setSkipPermissions: (value) => { + skip = value; + }, + }; + expect(getCommand("yolo")!.handler("on", ctx)).toEqual({ + type: "message", + text: "Yolo mode on — permission gate bypassed. Secret-guard and authz hard denies still apply.", + }); + expect(skip).toBe(true); + expect(getCommand("yolo")!.handler("off", ctx)).toEqual({ + type: "message", + text: "Yolo mode off — permission gate restored.", + }); + expect(skip).toBe(false); + }); + + it("rejects unknown arguments with usage", () => { + const ctx: CommandContext = { + signalClear: () => {}, + getSkipPermissions: () => false, + setSkipPermissions: () => {}, + }; + expect(getCommand("yolo")!.handler("maybe", ctx)).toEqual({ + type: "message", + text: "Usage: /yolo [on|off|toggle]", + }); + }); + + it("says so when skip-permissions is not wired", () => { + expect(getCommand("yolo")!.handler("", makeCtx())).toEqual({ + type: "message", + text: "Yolo mode is not available in this mode.", + }); + }); +}); + describe("/model command", () => { it("is registered", () => { expect(getCommand("model")).toBeDefined(); diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index ea31c2255..54c9355db 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -188,4 +188,41 @@ export function registerBuiltInCommands(): void { }, }); + // Mid-session twin of --dangerously-skip-permissions. Does not bypass + // secret-guard path denies or authorization hard blocks. + registerCommand({ + name: "yolo", + description: "Toggle skip-permissions for this session (gate bypass; secret-guard and authz remain)", + argumentHint: "[on|off|toggle]", + subcommands: [ + { name: "on", description: "Enable skip-permissions" }, + { name: "off", description: "Disable skip-permissions" }, + { name: "toggle", description: "Toggle skip-permissions" }, + ], + handler: (args, ctx) => { + if (ctx.getSkipPermissions === undefined || ctx.setSkipPermissions === undefined) { + return { type: "message", text: "Yolo mode is not available in this mode." }; + } + const arg = args.trim().toLowerCase(); + let next: boolean; + if (arg === "on") { + next = true; + } else if (arg === "off") { + next = false; + } else if (arg.length === 0 || arg === "toggle") { + next = !ctx.getSkipPermissions(); + } else { + return { type: "message", text: "Usage: /yolo [on|off|toggle]" }; + } + ctx.setSkipPermissions(next); + if (next) { + return { + type: "message", + text: "Yolo mode on — permission gate bypassed. Secret-guard and authz hard denies still apply.", + }; + } + return { type: "message", text: "Yolo mode off — permission gate restored." }; + }, + }); + } diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index 902788e71..c9e23d4ea 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -23,6 +23,10 @@ export type CommandContext = { * the feedback body instead of a model prompt. */ beginFeedbackCapture?: () => void; + /** Whether skip-permissions (yolo) is active for this session. */ + getSkipPermissions?: () => boolean; + /** Toggle skip-permissions for the rest of the session (`/yolo`). */ + setSkipPermissions?: (value: boolean) => void; }; export type CommandResult = diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 38c22caf9..666525c7d 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -1850,6 +1850,10 @@ export async function runTUI(initialConfig: Config): Promise { const commandContext: CommandContext = { signalClear: newSession, + getSkipPermissions: () => permissionGate.getSkipPermissions(), + setSkipPermissions: (value: boolean) => { + permissionGate.setSkipPermissions(value); + }, getCostSummary: (): CostSummary => { const usage = runSink.getTokenUsage(); const lastTurnUsage = runSink.getLastTurnUsage(); diff --git a/src/util/list-dir.ts b/src/util/list-dir.ts index 0c636c4a9..bdfaf30a0 100644 --- a/src/util/list-dir.ts +++ b/src/util/list-dir.ts @@ -26,16 +26,23 @@ export const listDirDefinition: ToolDefinition = { const MAX_ENTRIES = 200; export type ListDirectoryOptions = { - // When true (--dangerously-skip-permissions), list paths outside the workspace. - allowOutside?: boolean; + // When true (--dangerously-skip-permissions / yolo), list paths outside the + // workspace. A getter is resolved per call so `/yolo` mid-session takes + // effect without rebuilding the tool. + allowOutside?: boolean | (() => boolean); }; +function resolveAllowOutside(value: boolean | (() => boolean) | undefined): boolean { + if (typeof value === "function") return value(); + return value === true; +} + export async function listDirectory( cwd: string, path: string, options: ListDirectoryOptions = {}, ): Promise { - const allowOutside = options.allowOutside === true; + const allowOutside = resolveAllowOutside(options.allowOutside); const rel = path.length > 0 ? path : "."; const abs = resolve(cwd, rel); if (!allowOutside && abs !== cwd && !abs.startsWith(cwd + sep)) {