From e28dce8bb743d6df6f8edddbfda426f0bd8e269d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 05:15:43 -0700 Subject: [PATCH] fix(permissions): make yolo override auto mode --- docs/ARCHITECTURE.md | 8 +- docs/IMPLEMENTATION.md | 42 +++++----- docs/PRODUCT.md | 12 +-- src/config.test.ts | 44 ++++++++++- src/config/index.ts | 27 ++++--- src/exec/runner.ts | 3 +- src/permission/permission.test.ts | 41 +++++++--- src/permission/saved-skip-warning.ts | 5 ++ src/tui/commands/built-in.test.ts | 78 +++++++++++++++++++ src/tui/commands/built-in.ts | 2 +- src/tui/commands/registry.ts | 2 +- src/tui/runner/session.ts | 14 +--- src/tui/runner/settings-writers.ts | 20 +++++ .../wiring.skip-permissions-warning.test.ts | 45 +++++++++++ src/tui/runner/wiring.ts | 23 ++++-- tests/unit/exec/runner.test.ts | 56 +++++++++++-- 16 files changed, 344 insertions(+), 78 deletions(-) create mode 100644 src/permission/saved-skip-warning.ts create mode 100644 src/tui/runner/settings-writers.ts create mode 100644 src/tui/runner/wiring.skip-permissions-warning.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0d264445c..cb15d8954 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -87,8 +87,8 @@ 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`, `--dangerously-skip-permissions` (forces this process; TUI `/yolo` persists as the user-global default), `--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 and the project/global grant store (`.corbits/permissions.json`) are on the secret-guard denylist for path-keyed tools, so the agent cannot `read_file` its own credentials or persist standing auto-approvals. Shell commands that reference them still require explicit operator approval. +- `loadConfig` is async (it reads settings files). Parses a leading `exec`/`run` subcommand, flags `--cwd`, `--config`, `--provider`, `--model`, `--dangerously-skip-permissions` and its `--yolo` alias (both force only this process and never persist), `--auto` / `--no-auto` (auto mode defaults on); collects positional arguments as the optional initial task for the TUI or the required prompt for exec. Authorization precedence remains catastrophic authorization denial → skip → normal tier/grant/auto, so `--auto --yolo` behaves as yolo. TUI `/yolo` persists to the active settings file: with the default settings source this is the machine-wide default file, while explicit `--config ` selects that path as the active source. An ordinary TUI started without the same `--config` does not modify a custom settings file. +- The default user-global settings path, the per-repo `.corbits/settings.json`, and the project/global grant store (`.corbits/permissions.json`) are on the static secret-guard denylist for path-keyed tools, so the agent cannot `read_file` credentials there or persist standing auto-approvals. An arbitrary path selected with `--config ` is not added to that denylist at runtime. In normal and auto modes, shell commands that reference a statically protected path require explicit operator approval; yolo/skip-permissions allows those shell references after catastrophic authorization checks, while path-keyed access to statically protected paths remains hard-denied. - Credential-surface ownership: each auth store module enumerates its own files (`*_AUTH_FILENAME` / `MCP_AUTH_DIRNAME`), the data-only registry in `src/auth/credential-surface.ts` turns them into denylist patterns, `secret-guard-plugin.ts` owns matching (lexical plus realpath), and `@mention` resolution consumes the resolved check — never the registry directly. A new `*-auth.json` token store is denied only once its store module exports its filename and the registry lists it; the coverage test scans store sources for `*-auth.json` literals (registered dirnames get an includes-check instead) and fails the build until both exist. ### TUI Runner (`src/tui/runner/`) @@ -397,7 +397,7 @@ tool call - **Path Escape** (`path-escape-plugin.ts`) — Canonicalizes path-like arguments against `cwd` and blocks `..` escapes, except into a root the permission layer's worktree-roots provider allowlists (e.g. a sibling git worktree of the same repo). `tool-output://` and `archive:///` refs pass through unresolved. Runs first so later plugins see resolved paths. - **Evidence archive** (`evidence-archive-search-plugin.ts`, `evidence-archive-path-guard.ts`) — Primary-session compaction evidence is a first-class search/read surface on `search_files` / `read_file` / `grep` via `archive:///` refs. Dump paths (`evidence-archive/`, `tool-output/archive-*`) stay blocked so the on-disk sidecar is not the retrieval API. Blob keys reject `/` so they cannot nest under `tool-output`. - **Tool-output URI** (`tool-output-uri-plugin.ts`) — Normalizes mistaken `read_file` blob URIs to `tool-output:///id` (corbits-only; interchange stays unpatched). -- **Secret Guard** (`secret-guard-plugin.ts`) — Hard-denies path-keyed tool calls (`read_file`, `write_file`, …) that would put a sensitive file into (or write it from) the model context. Runs before the permission plugin, so the path-arg deny holds even under `--dangerously-skip-permissions`. Shell commands that _reference_ a sensitive path (tokenized so `cat .env`, `bun --env-file=.env run …`, and quote/env-assignment forms are detected) are not hard-denied here: they require operator approval via the permission gate, and auto mode forces an ask through the auto-shell policy (`sensitive-path` rule). Once the operator approves, the command runs. Shell detection is best-effort: token matching defeats quoting and env-assignment/redirection forms but not dynamic path construction (variable indirection, `printf` assembly). Tool-result secret scrub still redacts credential-shaped output. +- **Secret Guard** (`secret-guard-plugin.ts`) — Hard-denies path-keyed tool calls (`read_file`, `write_file`, …) that would put a sensitive file into (or write it from) the model context. Runs before the permission plugin, so the path-arg deny holds even under `--dangerously-skip-permissions`. Shell commands that _reference_ a sensitive path (tokenized so `cat .env`, `bun --env-file=.env run …`, and quote/env-assignment forms are detected) are not hard-denied here. Normal mode requires operator approval via the permission gate, and auto mode forces the same ask through the auto-shell policy (`sensitive-path` rule). Yolo/skip-permissions bypasses that prompt after catastrophic authorization checks, but path-keyed access to statically protected secret paths remains hard-denied. Shell detection is best-effort: token matching defeats quoting and env-assignment/redirection forms but not dynamic path construction (variable indirection, `printf` assembly). Tool-result secret scrub still redacts credential-shaped output. - **Authorization** (`run-shell-authz.ts`, enforced by the permission gate) — Denies catastrophic shell command patterns by regex, and hard-blocks shell `find`, head-position `rg`, and recursive `grep -r` (they can walk huge trees and OOM the host). Bounded `grep`/`search_files` tools remain practical alternatives (timeout + output caps); the patterns match those three command shapes only — an `ls -R`, `fd`, or scripted `os.walk` is just as unbounded and is not caught, so the block message tells the model not to substitute one. The gate hard-denies these at the top of its verdict path — before auto-allow, prompting, grants, and skipPermissions — so no mode or stored grant can admit them. - **Permission** (`permission-plugin.ts`) — Delegates consequential calls to the permission gate. - **Shell Guard** (`shell-guard-plugin.ts`) — Corbits Code-only replacement for stock `run_shell` (interchange stays unpatched): 120s foreground default (`settings.shell.timeoutMs` overrides that default only; a positive per-call `timeout` is the bound with no ceiling — `maxTimeoutMs` does not clamp it), background `run_shell` has no default (only a per-call timeout arms a timer), 512KB display cap with head+tail retention (the process keeps running when the cap is hit), process-group kill on timeout, abort, and plugin dispose (live children tracked in the plugin and reaped by `posixTools.dispose`), and `background: true` — the call returns a `shell_id` at once (registry in `src/shell/background-shell.ts`), the process group keeps running past the turn, completion is process-exit (stdio-close is not required), delivered on a later turn via `buildShellBackgroundMessage`, and `shell_collect` retrieves or cancels with a 300s wait cap (schema advertised by `advertiseShellGuardTimeout` only when `shell_collect` is mounted; evaluated by the permission chain at start time like any shell call). Foreground expiry is exit 124 + `timedOut:true` plus a nudge to retry with `background:true`. Also applies a 10s wall-clock budget to `grep`/`search_files`. Ripgrep detached spawns are not tracked. @@ -412,7 +412,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`, `spawn_agent`, `wait_agents`, …) 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 on path-arg tools are denied at authorize time (the same sandbox path-escape enforces at execution, so the gate does not show an Accept overlay that cannot succeed). Writes under the in-workspace session state root (legacy `.agent-state`) still ask under auto mode. Under `--dangerously-skip-permissions` (forces this process) or `/yolo` (persists as the user-global default via `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. +- **gate** — Evaluates a call in order: catastrophic authorization denial, `skipPermissions`, then the normal tier, grant, and auto policies. Catastrophic commands remain denied under every mode. Skip mode, enabled for the process by `--dangerously-skip-permissions` or `--yolo` and persisted to the active settings source by TUI `/yolo` (machine-wide only for the default user-global source, not an explicit `--config` source), allows the call before normal policy evaluation. Therefore `--auto --yolo` behaves as yolo: auto-mode file-mutation asks and denials do not run. Otherwise, `allow`-tier passes; for `ask`-tier, the gate checks persisted approvals and then 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`, `spawn_agent`, `wait_agents`, …) 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 on path-arg tools are denied at authorize time (the same sandbox path-escape enforces at execution, so the gate does not show an Accept overlay that cannot succeed). Writes under the in-workspace session state root (legacy `.agent-state`) still ask under auto mode. Under process-only `--dangerously-skip-permissions` / `--yolo` or TUI `/yolo` persisted in the active settings source (machine-wide only for the default user-global source), 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. Static secret-guard denies for path-keyed tools and authorization hard blocks still apply. Sensitive shell references require approval in normal and auto modes, but skip mode allows them without approval after catastrophic authorization checks. Mutating MCP and unknown built-ins are not blanket-allowed outside skip. Newly granted scopes are appended in memory and persisted. - **Reactor-gated sessions (main session; `reactorGated: true`).** The gate's decision logic lives in one `decide()` used by both consumers: `evaluate()` (the middleware path below, still used by sub-agents) and `authorizeCall()`, which expresses the decision as the vendored reactor's before-tool authz effect (`src/permission/reactor-authorize.ts` bridges it into `env.authorize`). An `ask` there suspends the call as a reactor `PendingOperation` keyed by a correlationId (persisted through the context store's existing `pendingOperations`); `send()` settles as `suspended` and `src/session/approval-resume.ts` rebuilds the operator request from the approval snapshot, resolves it through the same `requestApproval` seam the TUI overlay uses, and delivers the decision to the reactor on the correlationId signal channel — an approved decision grants a one-shot bypass and the exact parked call re-dispatches; a rejected one answers it with an error result. `inFlight` occupancy owns idle rebuild: the TUI stays busy across the overlay and waits until the correlated resume is accepted (`message.received` / `message.correlated`) or a generation bump `settleAll`s the waiter. Delivery generation owns session identity: interrupt, `/clear`, and `/new` abort the outstanding overlay, skip minting a grant, drop the decision, and surface an operator notice rather than delivering into a rebuilt agent. Under reactor gating the middleware/MCP `gateToolCall` is an execution backstop, not a second copy of `env.authorize`: it consumes the `authorizeCall` verdict only when id, name, and arguments match, and does not re-decide. Deny still blocks and does not call `next`; an `ask` or `allow` skips the middleware prompt so an approved re-dispatch never re-asks. A reused `codex-proxy` id cannot apply an outer `shell` allow to an inner `run_shell` deny. Inner posix runs whose outer tool is not `run_shell` (Codex `apply_patch` proxy) never pass `env.authorize`, so `gateToolCall` decides on that cache miss and still blocks a deny. The headless denial and the stricter chained-command hard-deny are preserved as deny effects (upstream `block`s) decided inside the same `decide()`. - **Approval resume identity.** Before opening the operator gate, resume captures the session generation and agent/store pair and resolves the correlation exactly once through `ContextStore.load().pendingOperations` to one approval operation's `suspendedCall.id`. Missing or duplicate mappings produce no gate or delivery; store errors propagate with registration cleanup. Every decision path first checks captured history for an exact-call approval timeout, and operator decisions check again after the gate. While the overlay is open, resume watches history for that exact-call timeout and aborts the overlay signal so the gate auto-denies, occupancy (`inFlight`) unsticks, and no late decision is delivered. Identical tool names and arguments never establish identity. Cancellation during lookup cannot deliver a rejection. This suppresses observed exact timeouts, not all expired correlations: the reactor removes correlation state before the queued timeout result publishes, and expiration can also race the final history check or TUI delivery queue. Atomic stale-decision admission remains reactor-owned work tracked separately in CL-8000. - **Worker reactor ownership.** `workerPermissionGate` is a reactor-gated view over the parent's live permission gate: grants and policy are shared, not copied or toggled. Worker posix plugins and inherited MCP tools are bound to that view at worker start, so they take the reactor-gated `gateToolCall` path because the view reports `isReactorGated()` — they do not close over the parent's middleware-gated `isReactorGated()`. Deny still blocks; ask/allow skip the middleware prompt. `authorizeCall` on the view never emits `ask` — unresolved approvals become denials that name the permission subject, without invoking an approval callback or suspending, even with an interactive parent; the parent can obtain a grant and retry. Worker control-plane tools (`submit_result`, `ask_director`, and nested fleet verbs other than `spawn_agent`) allow without a parent grant. Authorization and tool execution run under the same async-local worker identity and cwd. Fleet authority remains an independent restriction, not an alternative permission grant. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index ae42aa2b8..c89685079 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -174,7 +174,7 @@ Intent defaults: `intent=implement` → director `builder`; `explore` → `explo ### 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. `--dangerously-skip-permissions` still forces this process. `/yolo [on|off|toggle]` (bare `/yolo` also toggles) persists as the user-global default and wires `getSkipPermissions`/`setSkipPermissions` so the gate and pre-gate sandboxes honor the change on the next tool call without rebuilding plugins. `/yolo` writes the same `config.globalSettingsPath` target as the other `/settings`-style toggles, including a `--config` override. Secret-guard and authz still apply. `loadConfig` tracks `skipPermissionsFromSettings` (true only when the effective value came from persisted settings, not the CLI flag) so `runTUI` can show a startup notice and `exec` can print an equivalent stderr warning for the otherwise-silent persisted default. +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. `--dangerously-skip-permissions` and its `--yolo` alias force skip-permissions for this process only; skip-permissions wins when combined with auto, so `--auto --yolo` runs in yolo mode. `/yolo [on|off|toggle]` (bare `/yolo` also toggles) persists the skip-permissions setting to the active settings file and wires `getSkipPermissions`/`setSkipPermissions` so the gate and pre-gate sandboxes honor the change on the next tool call without rebuilding plugins. The active source defaults to the user-global `~/.corbits/settings.json`, making that default machine-wide. Explicit `--config ` selects that file instead, so `/yolo` writes the custom file; a later ordinary TUI launch without the same `--config` uses the user-global source and does not modify the custom file. Secret-guard and authz still apply. `loadConfig` tracks `skipPermissionsFromSettings` (true only when the effective value came from persisted settings, not either CLI flag) so `runTUI` can show a startup notice and `exec` can print an equivalent stderr warning for the otherwise-silent persisted setting. 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: @@ -338,7 +338,7 @@ Credentials and provider definitions come exclusively from the settings files. E OpenAI-compatible `baseURL` values are normalized during provider resolution. A plain base URL such as `https://provider.example.com/v1` is preserved, a trailing slash is removed, and a pasted full chat-completions endpoint such as `https://provider.example.com/v1/chat/completions` is reduced to `https://provider.example.com/v1` before the runtime appends `/chat/completions`. Invalid non-URL values fail with an explicit baseURL error. -`--config ` replaces the global settings file as the provider source (useful for CI to inject a provider per run). The per-repo `.corbits/settings.json` selection still applies on top of a `--config` source (definitions come from `--config`, selection from the local file; CLI `--provider`/`--model` override both). A provider must be defined in one of these settings files; there is no environment-variable fallback. +`--config ` replaces the user-global settings file as the active settings source (useful for CI to inject a provider per run); TUI settings persistence, including `/yolo`, writes that active file. The per-repo `.corbits/settings.json` selection still applies on top of a `--config` source (definitions come from `--config`, selection from the local file; CLI `--provider`/`--model` override both). A provider must be defined in one of these settings files; there is no environment-variable fallback. `--config` composes with, rather than replaces, the home-level OAuth profile catalog: codex/xai credentials live in `~/.corbits/codex-auth.json` and `xai-auth.json`, entirely separate from settings.json, and are merged into the resolved provider catalog on every run regardless of `--config` (CL-6973). A `--config` file that names a `codex/*` or `xai/*` provider by ID does not by itself grant that provider's credentials — those come from the OAuth store whenever a matching profile exists there, independent of which settings file supplied the provider definitions. The only way to fully exclude the home OAuth catalog is the programmatic `globalSettingsPath` option to `loadConfig`, used by tests for full isolation; it is not exposed as a CLI flag. @@ -389,25 +389,25 @@ Providers and credentials are read exclusively from settings files: the global ` Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts` (that constant is the source of truth; keep this table in sync when flags change). -| Verb / Flag | Default | Description | -| -------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| _(no verb)_ | — | Interactive session; optional trailing task text | -| `exec` / `run` / `-p` | — | Run a prompt (non-interactive / one-shot). `-p` is the same path as `exec` and may appear in any flag position. | -| `resume` / `continue` | — | Open the session picker for this folder (project-keyed to this checkout's git toplevel). Lists the 10 most recently persisted sessions, completed included. Type to filter. | -| `--resume []` | — | Interactive: open the session picker, or reopen a specific session when an id is given. With `exec` / `-p` the id is required, that session is continued, and the new prompt is sent (no picker). | -| `resume ` | — | Reopen a specific session in the TUI (does not auto-send a prompt) | -| `exec --resume ` | — | Headless: load that session and send ``, then exit. Missing or unreadable ids error and do not create a session. `--resume` without an id errors. | -| `-p --resume ` | — | Same headless continue path as `exec --resume` | -| `resume --pick` / `--list` | — | Interactive session picker | -| `--cwd ` | `process.cwd()` | Working directory | -| `--config ` | `~/.corbits/settings.json` | Settings file to use for provider definitions; composes with (does not exclude) home-level codex/xai OAuth credentials | -| `--provider ` | from settings | Select a configured provider | -| `--model ` | provider default | Select a model for the active provider | -| `--profile ` | — | Settings profile | -| `--dangerously-skip-permissions` | false | Auto-allow anything not denied by the authorization layer (gate + pre-gate workspace sandboxes; secret-guard / authz hard denies remain). This launch flag still forces this process; `/yolo [on\|off\|toggle]` persists as the user-global default 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`) | +| Verb / Flag | Default | Description | +| ------------------------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _(no verb)_ | — | Interactive session; optional trailing task text | +| `exec` / `run` / `-p` | — | Run a prompt (non-interactive / one-shot). `-p` is the same path as `exec` and may appear in any flag position. | +| `resume` / `continue` | — | Open the session picker for this folder (project-keyed to this checkout's git toplevel). Lists the 10 most recently persisted sessions, completed included. Type to filter. | +| `--resume []` | — | Interactive: open the session picker, or reopen a specific session when an id is given. With `exec` / `-p` the id is required, that session is continued, and the new prompt is sent (no picker). | +| `resume ` | — | Reopen a specific session in the TUI (does not auto-send a prompt) | +| `exec --resume ` | — | Headless: load that session and send ``, then exit. Missing or unreadable ids error and do not create a session. `--resume` without an id errors. | +| `-p --resume ` | — | Same headless continue path as `exec --resume` | +| `resume --pick` / `--list` | — | Interactive session picker | +| `--cwd ` | `process.cwd()` | Working directory | +| `--config ` | `~/.corbits/settings.json` | Active settings file for provider definitions and TUI persistence such as `/yolo`; composes with (does not exclude) home-level codex/xai OAuth credentials | +| `--provider ` | from settings | Select a configured provider | +| `--model ` | provider default | Select a model for the active provider | +| `--profile ` | — | Settings profile | +| `--dangerously-skip-permissions`, `--yolo` | false | Process-only aliases that auto-allow anything not denied by the authorization layer (gate + pre-gate workspace sandboxes; secret-guard / authz hard denies remain). `/yolo [on\|off\|toggle]` persists to the active settings file instead. | +| `--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`) | Positional arguments after flags are joined into the optional initial task delivered when the TUI mounts. With no positional task, the operator starts from an empty prompt. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index e22a39d6b..d4573d83d 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -66,7 +66,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. Bootstrap shares `src/session/assemble-runtime.ts` with the TUI; see `docs/ARCHITECTURE.md` “Exec Runner” for intentional deltas (no workflow controller; single primary send; non-interactive permission gate; `ask_operator` unmounted when non-TTY). 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, a persisted `/yolo` default is on, or auto mode covers them. `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply. `ask_operator` is not advertised on non-TTY exec; TTY exec still reads a single line from stdin. +Same directors, tools, permissions, MCP, plugins, and hooks as the TUI — without the OpenTUI shell. Bootstrap shares `src/session/assemble-runtime.ts` with the TUI; see `docs/ARCHITECTURE.md` “Exec Runner” for intentional deltas (no workflow controller; single primary send; non-interactive permission gate; `ask_operator` unmounted when non-TTY). 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` or its `--yolo` alias is set, the active settings file has persisted yolo on, or auto mode covers them. Both CLI flags force skip-permissions for this process only; skip-permissions wins when combined with auto, so `--auto --yolo` runs in yolo mode. Secret-guard and authz still apply. `ask_operator` is not advertised on non-TTY exec; TTY exec still reads a single line from stdin. Local multi-model capability checks use this path (`bun run eval:capability`); see `evals/capability/README.md`. @@ -92,7 +92,7 @@ the file path and parse details. ## 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 applies for the rest of the session; Corbits also tries to remember it on disk so later sessions don't re-ask. If that write fails, the grant still holds this session and the operator is told remember did not stick. -- **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. +- **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`, `--yolo`, or `/yolo`. Template files like `.env.example` are exempt. In normal and auto modes, shell commands that _reference_ sensitive paths (e.g. `bun --env-file=.env.staging run …`, `cat .env`) require explicit operator approval. Yolo/skip-permissions modes allow those shell references without approval after catastrophic authorization checks; the path-keyed secret guard remains a hard deny. 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. @@ -100,12 +100,12 @@ the file path and parse details. - **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. - Path-arg tools that escape the workspace are denied at authorize time (yolo still allows them). Writes under the in-workspace session state root still ask; mutating MCP and unknown tools still prompt. Shell that targets an outside path still asks. -- **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). +- **Path sandboxing** — Tool path arguments are resolved against the working directory; paths that escape it are blocked unless `--dangerously-skip-permissions`, `--yolo`, or persisted `/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; the result returned to the model (and shown to the operator) includes a bounded diff of the changed region — `write_file`, `edit_file`, `delete_file`, and each op inside `apply_patch` — so a follow-up `read_file` is never needed just to confirm an edit landed. A whole-file rewrite's diff is truncated (and says so) rather than blowing the result size cap. ## 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** or `/connect` adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/compact` (fold conversation context now, optional trailing instructions to the summarizer; does not wait for the 60% occupancy governor; idle success shows the fold and does not start a new turn), `/mcp` (enable, disable, or remove servers), `/handoff [optional instructions]` (folds context through the shared operator pipeline, then immediately starts the next turn with the instructions as the inbound content — default copy when omitted; unlike `/compact`, which stops after the fold, handoff always re-infers, so the operator can pivot goals without `/clear`; a handoff issued mid-tool-batch queues behind the in-flight batch and whichever boundary fires first runs the single fold), and `/yolo` (persists as the user-global skip-permissions default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply; `/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/` command per available workflow. When a session starts with the persisted default already on, the TUI shows a startup notice ("Permission prompts are disabled by your saved default…") so the silent machine-wide default is never invisible; `corbits exec` prints the equivalent warning to stderr. 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** or `/connect` adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/compact` (fold conversation context now, optional trailing instructions to the summarizer; does not wait for the 60% occupancy governor; idle success shows the fold and does not start a new turn), `/mcp` (enable, disable, or remove servers), `/handoff [optional instructions]` (folds context through the shared operator pipeline, then immediately starts the next turn with the instructions as the inbound content — default copy when omitted; unlike `/compact`, which stops after the fold, handoff always re-infers, so the operator can pivot goals without `/clear`; a handoff issued mid-tool-batch queues behind the in-flight batch and whichever boundary fires first runs the single fold), and `/yolo` (`/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/` command per available workflow. `/yolo` persists skip-permissions to the active settings file. That file is the user-global `~/.corbits/settings.json` by default, making the setting machine-wide; explicit `--config ` selects a different active file, and `/yolo` writes that file. An ordinary TUI launch without the same `--config` returns to the user-global source and does not modify the custom file. `--dangerously-skip-permissions` and `--yolo` are process-only aliases. Secret-guard and authz still apply. When a session starts with its active persisted setting already on, the TUI and `corbits exec` warn that permission prompts are disabled by saved settings at the active settings path and direct the operator to edit that file to re-enable them. Plugins can register additional commands. **Default skills** exist out of the gate as first-party slash **actions**, not director names: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`, `/lexicon`. Each one is a how-to playbook — the slash sends the skill body to the primary, which follows the steps. Skills do not assign identity or route the fleet; that stays on director system prompts. `/review` classifies the target first, then dispatches a selected fleet; `/pull-request-review` is worktree checkout plus a surface pass, loading `/review` for quality rules only; `/scribe` is how to maintain PRODUCT / ARCHITECTURE / IMPLEMENTATION; `/implement` is the per-commit review/build/critique loop — it does not steal planning from `/plan`. Substantial Builder work consumes a counsel / `/plan` plan first; tiny parent-DIY stays plan-optional. `/plan` authors an eng change plan (files, AC, non-goals, risks, ordered steps) and does not implement. `/create-issue` remains the tracker command: Linear MCP when available; otherwise it `ask_operator`s for the platform (GitHub etc.) and persists `Preferred issue tracker` in `.corbits/MEMORY.md` (GitHub via `gh issue create`). `/lexicon` owns director-prompt drift and size against the agents repo at a pinned commit. There is no first-party dispatch skill — Skywalker orchestrates natively. `git-rebase`, `linear-issue-workflow`, `style`, `philosophy`, `native-integration`, `typescript`, `ponytail`, and `opsh` stay `use_skill` only (`user-invocable: false`). Bake-only bars such as `idiot-proof` and `native-runtime` are not slashes and are not listed for `use_skill`. Draper and emil are not slashes; they remain closed directors via `spawn_agent(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `skill_search` (descriptions) then `use_skill` (body). Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone. @@ -133,7 +133,7 @@ The exact turn threshold is model-family-dependent (tighter for models with obse **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, re-run with `--dangerously-skip-permissions`, or use `/yolo` in the TUI (persists as the user-global default). +**Recovery:** Re-run interactively (TUI), pre-approve via persisted approvals, narrow the action, re-run with process-only `--dangerously-skip-permissions` / `--yolo`, or use `/yolo` in the TUI to persist to the active settings file. ### Resume after interruption @@ -145,7 +145,7 @@ line instead of a path dump. ## Configuration -Providers and models are configured in `~/.corbits/settings.json` (holds providers + credentials), with a selection-only per-repo `.corbits/settings.json` override. Select at launch with `--provider` / `--model`, or point at an alternate file with `--config `. `--config` only overrides where provider _definitions_ come from; it composes with, rather than replaces, credentials for codex/xai OAuth-profile providers, which live in separate home-level auth stores (`~/.corbits/codex-auth.json`, `xai-auth.json`) and are merged into the catalog regardless of `--config`. Credentials are read only from these settings files and the OAuth auth stores — there is no environment-variable override and `.env` files are not loaded, so a stale or exported key can't shadow the configured provider. The agent is denied read access to both settings files. +Providers and models are configured in the active settings file, which defaults to `~/.corbits/settings.json` (providers + credentials), with a selection-only per-repo `.corbits/settings.json` override. Select at launch with `--provider` / `--model`, or use `--config ` to select an alternate active file for provider definitions and TUI settings persistence such as `/yolo`. `--config` composes with, rather than replaces, credentials for codex/xai OAuth-profile providers, which live in separate home-level auth stores (`~/.corbits/codex-auth.json`, `xai-auth.json`) and are merged into the catalog regardless of `--config`. Credentials are read only from these settings files and the OAuth auth stores — there is no environment-variable override and `.env` files are not loaded, so a stale or exported key can't shadow the configured provider. Static secret-guard protection denies path-keyed read access to the default user-global `~/.corbits/settings.json` and per-repo `.corbits/settings.json`. An arbitrary active path selected with `--config ` is not added to that denylist at runtime. ## Optional Capabilities (plugins) diff --git a/src/config.test.ts b/src/config.test.ts index a7a135134..24ecd9e32 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,6 +1,13 @@ import { defined } from "../tests/helpers/defined.js"; import { afterEach, beforeEach, describe, test, expect } from "bun:test"; -import { mkdtemp, mkdir, writeFile, rm, readdir } from "node:fs/promises"; +import { + mkdtemp, + mkdir, + readFile, + writeFile, + rm, + readdir, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -1225,6 +1232,19 @@ describe("loadConfig", () => { await expectCliHelp(["-h"]); }); + test("--help explains process-only yolo and its precedence over auto", () => { + expect(CLI_HELP_TEXT).toContain("--dangerously-skip-permissions, --yolo"); + expect(CLI_HELP_TEXT).toContain("this process only (--yolo alias)"); + expect(CLI_HELP_TEXT).toContain("--auto --yolo uses yolo mode"); + expect(CLI_HELP_TEXT).toContain( + "/yolo in the TUI persists the active settings file", + ); + expect(CLI_HELP_TEXT).toContain( + "the default settings file is machine-wide", + ); + expect(CLI_HELP_TEXT).toContain("--config selects another source"); + }); + test("--help after flags throws CliHelpError", async () => { await expectCliHelp(["--auto", "--help"]); await expectCliHelp(["--auto", "-h"]); @@ -1349,6 +1369,28 @@ describe("loadConfig", () => { } }); + test("exec --auto --yolo enables process-only skip without changing settings", async () => { + const cwd = await emptyCwd(); + try { + const globalPath = await writeGlobalSettings(cwd); + const settingsBefore = await readFile(globalPath); + const config = await loadConfig( + ["exec", "--cwd", cwd, "--auto", "--yolo", "ship", "it"], + { globalSettingsPath: globalPath }, + ); + + assertConfigured(config); + expect(config.command).toBe("exec"); + expect(config.task).toBe("ship it"); + expect(config.auto).toBe(true); + expect(config.dangerouslySkipPermissions).toBe(true); + expect(config.skipPermissionsFromSettings).toBe(false); + expect(await readFile(globalPath)).toEqual(settingsBefore); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("seeds dangerouslySkipPermissions from global settings without the CLI flag", async () => { const cwd = await emptyCwd(); try { diff --git a/src/config/index.ts b/src/config/index.ts index cbcca2f94..c99dc9572 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -579,9 +579,9 @@ export interface Config { // Experimental prompt shrink after an Anthropic cache expiry. Off unless // settings set anthropicCachePrompt. anthropicCachePrompt: boolean; - // True when dangerouslySkipPermissions came from the persisted global - // default rather than this invocation's CLI flag. Entry points use this to - // surface a startup notice since the persisted default is otherwise silent. + // True when dangerouslySkipPermissions came from the active settings source + // rather than this invocation's CLI flag. Entry points use this to surface a + // startup notice since the persisted value is otherwise silent. skipPermissionsFromSettings: boolean; auto: boolean; /** @@ -594,6 +594,7 @@ export interface Config { * product agent path (`corbits exec "prompt"`). Same directors/tools/permissions. */ command: "tui" | "exec"; + /** Active settings source, including an explicit --config path. */ globalSettingsPath: string; globalDefaultProvider?: string; // Every provider available to switch to at runtime. From the settings file @@ -699,10 +700,12 @@ Flags: -p one-shot prompt (same as exec / run) --resume [] interactive picker, or reopen a session; with exec/-p the id is required --director exec-only: run as this director (default: skywalker) - --dangerously-skip-permissions - skip permission prompts for this run only; - /yolo in the TUI instead persists the default - machine-wide in ~/.corbits/settings.json + --dangerously-skip-permissions, --yolo + skip permission prompts for this process only (--yolo alias); + --auto --yolo uses yolo mode (catastrophic denials remain); + /yolo in the TUI persists the active settings file; + the default settings file is machine-wide; + --config selects another source --auto / --no-auto auto mode on/off --help, -h show this help `; @@ -734,7 +737,7 @@ export class CliUserError extends Error { } export interface LoadConfigOptions { - // Override the global settings file location (for tests / non-standard homes). + // Override the default settings source (for tests / non-standard homes). globalSettingsPath?: string; // Override the home directory used for project-key session roots (tests). // Production callers leave this unset so sessions resolve under ~/.corbits. @@ -883,7 +886,7 @@ export async function loadConfig( continue; } - if (arg === "--dangerously-skip-permissions") { + if (arg === "--dangerously-skip-permissions" || arg === "--yolo") { dangerouslySkipPermissions = true; continue; } @@ -950,8 +953,8 @@ export async function loadConfig( ...options.pricing, }); - // Resolve both settings targets from the same effective global path. The - // local schema must never be read from or written to that global target. + // Resolve both settings targets from the same active source. The local schema + // must never be read from or written to the active global-schema target. const effectiveSettingsPath = configPath ?? options.globalSettingsPath ?? globalSettingsPath(); const localSettingsFile = resolveLocalSettingsPath( @@ -995,7 +998,7 @@ export async function loadConfig( { persist: true }, ); - // Track whether the effective value came from the persisted global default + // Track whether the effective value came from the active settings source // rather than this invocation's --dangerously-skip-permissions flag, so the // TUI/exec entry points can surface a startup notice for the silent case. const skipPermissionsFromSettings = diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 8757ae19c..0a7e42e48 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -67,6 +67,7 @@ import type { ApprovalOutcome, PermissionRequest, } from "../permission/types.js"; +import { savedSkipPermissionsWarning } from "../permission/saved-skip-warning.js"; import { createAgentToolset, type AgentToolset, @@ -598,7 +599,7 @@ export async function runExec(config: Config): Promise { if (config.skipPermissionsFromSettings) { stderr.write( - "Warning: permission prompts are disabled by your saved default (/yolo off to re-enable).\n", + `${savedSkipPermissionsWarning(config.globalSettingsPath)}\n`, ); } diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 5fb6b946c..e44eda2d5 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -2401,6 +2401,28 @@ describe("createPermissionGate", () => { expect(asked).toBe(0); }); + test("skipPermissions overrides auto shell policy but not catastrophic denial", async () => { + let asked = 0; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => { + asked++; + return { allow: false }; + }, + interactive: true, + skipPermissions: true, + reactorGated: false, + auto: true, + }); + + expect((await gate.evaluate(shellCall("echo hi > src/a.ts"))).allowed).toBe( + true, + ); + expect((await gate.evaluate(shellCall("cat .env"))).allowed).toBe(true); + expect((await gate.evaluate(shellCall("rm -rf /"))).allowed).toBe(false); + expect(asked).toBe(0); + }); + // SECURITY: headless (interactive=false, no requestApproval) with an unapproved // ask-tier tool must produce a hard denial. Silent allow would be catastrophic // because automated pipelines often run headless and must not silently gain @@ -3614,15 +3636,16 @@ describe("createPermissionGate restricted paths", () => { expect(asked).toBe(0); }); - // Auto-allowing gitignored reads at the gate does not widen what the model can - // see via path-keyed tools: the secret-guard plugin hard-blocks sensitive-file - // reads/writes independent of any gate decision. Shell commands that mention - // those paths are ask-gated instead (see classify-security tests). - test(".env reads are still hard-blocked by the secret-guard plugin even though the gate auto-allows gitignored reads", async () => { - const gate = restrictedGate(() => { - throw new Error( - "the plugin should block before the gate is ever consulted for approval", - ); + // Skip mode does not widen what the model can see via path-keyed tools: the + // secret-guard plugin hard-blocks sensitive-file reads/writes independent of + // the gate decision. + test(".env path reads remain hard-blocked by the secret-guard plugin under skipPermissions", async () => { + const gate = createPermissionGate({ + approvals: [], + cwd, + interactive: false, + skipPermissions: true, + reactorGated: false, }); const gateVerdict = await gate.evaluate({ id: "c", diff --git a/src/permission/saved-skip-warning.ts b/src/permission/saved-skip-warning.ts new file mode 100644 index 000000000..307357acf --- /dev/null +++ b/src/permission/saved-skip-warning.ts @@ -0,0 +1,5 @@ +export function savedSkipPermissionsWarning( + globalSettingsPath: string, +): string { + return `Warning: permission prompts are disabled by saved settings at ${globalSettingsPath}; edit that file to re-enable.`; +} diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index dad510051..5aafa1aa2 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { defined } from "../../../tests/helpers/defined.js"; +import { loadConfig } from "../../config/index.js"; +import { globalSettingsPath } from "../../config/settings.js"; +import { createCommandLayer } from "../runner/commands.js"; +import { createTUISettingsWriters } from "../runner/settings-writers.js"; +import type { RunnerServices, RunnerState } from "../runner/state.js"; import { getCommand } from "./registry.js"; import type { CommandContext } from "./registry.js"; import { registerBuiltInCommands } from "./built-in.js"; @@ -110,6 +118,76 @@ describe("/yolo command", () => { expect(getCommand("yolo")).toBeDefined(); }); + it("persists through the command layer to the active custom settings file", async () => { + const home = await mkdtemp(join(tmpdir(), "corbits-yolo-command-")); + const customSettingsPath = join(home, "custom-settings.json"); + const defaultSettingsPath = globalSettingsPath(home); + const defaultBytes = + '{\n "providers": {},\n "showPromptCost": false\n}\n'; + let skipPermissions = false; + + try { + await writeFile( + customSettingsPath, + JSON.stringify({ + defaultProvider: "test", + providers: { + test: { + baseURL: "https://example.test/v1", + apiKey: "test-key", + models: ["test-model"], + }, + }, + }), + ); + await mkdir(dirname(defaultSettingsPath), { recursive: true }); + await writeFile(defaultSettingsPath, defaultBytes); + const config = await loadConfig( + ["--cwd", home, "--config", customSettingsPath], + { + globalSettingsPath: defaultSettingsPath, + pricing: { + fetchImpl: (() => + Promise.reject(new Error("offline"))) as unknown as typeof fetch, + }, + }, + ); + expect(config.globalSettingsPath).toBe(customSettingsPath); + const { globalSettingsWriter } = createTUISettingsWriters(config); + const state = { + config, + host: { shell: { modelLabel: "test · test-model · yolo" } }, + } as unknown as RunnerState; + const services = { + globalSettingsWriter, + permissionGate: { + getSkipPermissions: () => skipPermissions, + setSkipPermissions: (value: boolean) => { + skipPermissions = value; + }, + }, + } as unknown as RunnerServices; + const { commandContext } = createCommandLayer(state, services); + + expect( + defined(getCommand("yolo"), "yolo").handler("on", commandContext), + ).toEqual({ + type: "message", + text: "Yolo mode on — permission prompts skipped. Saved as the default.", + }); + await globalSettingsWriter.enqueue(async () => undefined); + + expect( + JSON.parse(await readFile(customSettingsPath, "utf8")), + ).toMatchObject({ + dangerouslySkipPermissions: true, + }); + expect(await readFile(defaultSettingsPath, "utf8")).toBe(defaultBytes); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it("toggles skip-permissions when invoked bare", () => { let skip = false; const ctx: CommandContext = { diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 35b427365..4341bee0e 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -256,7 +256,7 @@ export function registerBuiltInCommands(): void { }, }); - // Persist as user-global default, not session-only. + // Persist to the active settings source, not only the running session. registerCommand({ name: "yolo", description: "Skip permission prompts (persists as the default)", diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index 5003ec895..67bb128e2 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -26,7 +26,7 @@ export interface CommandContext { beginFeedbackCapture?: () => void; /** Whether skip-permissions (yolo) is active for this session. */ getSkipPermissions?: () => boolean; - /** Live-flip skip-permissions and persist `/yolo` as the user-global default. */ + /** Live-flip skip-permissions and persist `/yolo` to the active settings source. */ setSkipPermissions?: (value: boolean) => void; /** * Fold conversation context now, bypassing the occupancy governor. diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 5cfb73f61..542ee3818 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -12,16 +12,11 @@ import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { - localSettingsPath, shellTimeoutFromSettings, toolWatchdogFromSettings, } from "../../config/settings.js"; import { isCodexProviderName } from "../../config/codex-providers.js"; import { peekSourceCredentialSecret } from "../../config/source-credentials.js"; -import { - createGlobalSettingsWriter, - createLocalSettingsWriter, -} from "../../mcp/add-server.js"; import { getProcessAdmissionQueue } from "../../subagent/admission.js"; import { createSubAgentSessionStore } from "../../subagent/index.js"; import { @@ -130,6 +125,7 @@ import { type TUIStart, } from "./state.js"; import { createParkedOverlayAbortBinding } from "./parked-overlay-abort.js"; +import { createTUISettingsWriters } from "./settings-writers.js"; export async function assembleTUISession( state: RunnerState, @@ -138,12 +134,8 @@ export async function assembleTUISession( ): Promise { const config = state.config; const emitter = new EventEmitter(); - const globalSettingsWriter = createGlobalSettingsWriter( - config.globalSettingsPath, - ); - const localSettingsWriter = createLocalSettingsWriter( - localSettingsPath(config.cwd), - ); + const { globalSettingsWriter, localSettingsWriter } = + createTUISettingsWriters(config); const initialHookEnabled: Record = Object.fromEntries( Object.entries(config.settings?.hooks ?? {}).map(([id, v]) => [ id, diff --git a/src/tui/runner/settings-writers.ts b/src/tui/runner/settings-writers.ts new file mode 100644 index 000000000..a0cf2e6c6 --- /dev/null +++ b/src/tui/runner/settings-writers.ts @@ -0,0 +1,20 @@ +import type { Config } from "../../config/index.js"; +import { localSettingsPath } from "../../config/settings.js"; +import { + createGlobalSettingsWriter, + createLocalSettingsWriter, +} from "../../mcp/add-server.js"; + +export function createTUISettingsWriters( + config: Pick, +): { + globalSettingsWriter: ReturnType; + localSettingsWriter: ReturnType; +} { + return { + globalSettingsWriter: createGlobalSettingsWriter(config.globalSettingsPath), + localSettingsWriter: createLocalSettingsWriter( + localSettingsPath(config.cwd), + ), + }; +} diff --git a/src/tui/runner/wiring.skip-permissions-warning.test.ts b/src/tui/runner/wiring.skip-permissions-warning.test.ts new file mode 100644 index 000000000..d5fa9962a --- /dev/null +++ b/src/tui/runner/wiring.skip-permissions-warning.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { createAppShell } from "../shell/index.js"; +import { shellInternals } from "../shell/internals.js"; +import { withTestRenderer } from "../harness.js"; +import { surfaceSavedSkipPermissionsWarning } from "./wiring.js"; + +const OPTIONS = { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle" as const, +}; + +async function surfacedWarning(globalSettingsPath: string): Promise { + return withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, OPTIONS); + try { + surfaceSavedSkipPermissionsWarning(shell, { + globalSettingsPath, + skipPermissionsFromSettings: true, + }); + return shellInternals(shell)?.landingDeferredRows.at(-1)?.text ?? ""; + } finally { + shell.dispose(); + } + }); +} + +describe("saved skip-permissions startup warning", () => { + test("identifies a custom config path without false default provenance", async () => { + const warning = await surfacedWarning("/tmp/custom-corbits-settings.json"); + + expect(warning).toContain("/tmp/custom-corbits-settings.json"); + expect(warning).toContain("edit that file to re-enable"); + expect(warning).not.toMatch(/machine-wide|saved default|\/yolo off/i); + }); + + test("identifies the default settings path", async () => { + const warning = await surfacedWarning( + "/home/operator/.corbits/settings.json", + ); + + expect(warning).toContain("/home/operator/.corbits/settings.json"); + expect(warning).toContain("edit that file to re-enable"); + }); +}); diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index 473aaead3..fd6aa0837 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -52,6 +52,7 @@ import { setEffortCycleHandler, setMentionSuggestionSource, setPromptRecognitionSource, + type AppShell, } from "../shell/internals.js"; import { setPromptModelLabel, @@ -77,6 +78,7 @@ import { type RunnerState, } from "./state.js"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; +import { savedSkipPermissionsWarning } from "../../permission/saved-skip-warning.js"; import { buildFleetDryContinuationMessage, buildMailboxMailMessage, @@ -84,6 +86,20 @@ import { const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); +export function surfaceSavedSkipPermissionsWarning( + shell: AppShell, + config: Pick< + RunnerState["config"], + "globalSettingsPath" | "skipPermissionsFromSettings" + >, +): void { + if (!config.skipPermissionsFromSettings) return; + surfaceSystemNotice( + shell, + savedSkipPermissionsWarning(config.globalSettingsPath), + ); +} + /** * One tick of the periodic fleet stall poll. * @@ -590,12 +606,7 @@ export function wirePostStartup( // The persisted /yolo default is otherwise silent: nothing on screen would // otherwise tell the operator that permission prompts are off for a repo // they never ran --dangerously-skip-permissions or /yolo in. - if (state.config.skipPermissionsFromSettings) { - surfaceSystemNotice( - hostOf(state).shell, - "Permission prompts are disabled by your saved default (/yolo off to re-enable).", - ); - } + surfaceSavedSkipPermissionsWarning(hostOf(state).shell, state.config); // Soft upgrade check: never blocks startup; offline / rate-limit is a quiet skip. // surfaceSystemNotice keeps the landing hero up and flushes into the transcript diff --git a/tests/unit/exec/runner.test.ts b/tests/unit/exec/runner.test.ts index 7206f4be5..26149fabd 100644 --- a/tests/unit/exec/runner.test.ts +++ b/tests/unit/exec/runner.test.ts @@ -1,9 +1,10 @@ +import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import type { AgentTool } from "@intx/agent"; import type { InferenceSource } from "@intx/types/runtime"; -import type { Config } from "../../../src/config/index.js"; +import { loadConfig, type Config } from "../../../src/config/index.js"; import { disposeExecRuntime, formatCaughtError, @@ -351,7 +352,7 @@ describe("runExec", () => { } }); - test("dispose failure after toolset exists is once-only and forces a nonzero exit", async () => { + test("dispose failure is once-only and warns with the settings source", async () => { const previous = getActiveRun(); clearActiveRun(); const { cwd, home, cleanup } = createTempDirs( @@ -416,24 +417,69 @@ describe("runExec", () => { async () => { const { runExec: runExecUnderMock } = await import("../../../src/exec/runner.js"); + const defaultSettingsPath = join(home, "settings.json"); const result = await runExecUnderMock({ ...bareConfig("do the thing"), cwd, sessionId, director: "builder", - globalSettingsPath: join(home, "settings.json"), + globalSettingsPath: defaultSettingsPath, providers: [], + skipPermissionsFromSettings: true, }); expect(result.exitCode).toBe(1); expect(result.status).toBe("failed"); expect(result.error).toMatch( /plugin dispose failed|runtime dispose failed/i, ); - expect(stderrChunks.join("")).toMatch( - /runtime dispose failed/i, + const stderrOutput = stderrChunks.join(""); + expect(stderrOutput).toContain( + `Warning: permission prompts are disabled by saved settings at ${defaultSettingsPath}; edit that file to re-enable.\n`, ); + expect(stderrOutput).not.toContain("/yolo"); + expect(stderrOutput).toMatch(/runtime dispose failed/i); expect(disposeCalls).toBe(1); expect(getActiveDisposeHost()).toBeNull(); + + stderrChunks.length = 0; + const customSettingsPath = join(home, "custom-settings.json"); + await writeFile( + customSettingsPath, + JSON.stringify({ + defaultProvider: "test", + providers: { + test: { + baseURL: "https://example.test/v1", + apiKey: "test-key", + models: ["test"], + }, + }, + dangerouslySkipPermissions: true, + }), + ); + const customConfig = await loadConfig([ + "exec", + "--cwd", + cwd, + "--config", + customSettingsPath, + "do the thing", + ]); + const customResult = await runExecUnderMock({ + ...customConfig, + sessionId: `${sessionId}-custom`, + director: "builder", + }); + expect(customResult.exitCode).toBe(1); + const customStderrOutput = stderrChunks.join(""); + expect(customStderrOutput).toContain( + `Warning: permission prompts are disabled by saved settings at ${customSettingsPath}; edit that file to re-enable.\n`, + ); + expect(customStderrOutput).not.toContain("machine-wide"); + expect(customStderrOutput).not.toContain("TUI"); + expect(customStderrOutput).not.toContain("/yolo"); + expect(disposeCalls).toBe(2); + expect(getActiveDisposeHost()).toBeNull(); }, ); },