From 887ae48e6f2cd15ef62926c6c6fb83e4f52e4772 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 11:00:47 -0700 Subject: [PATCH 1/3] Persist yolo as the user-global skip-permissions default --- README.md | 2 +- docs/ARCHITECTURE.md | 4 +- docs/IMPLEMENTATION.md | 4 +- docs/PRODUCT.md | 6 +-- src/config.test.ts | 84 ++++++++++++++++++++++++++++++++++++ src/config/index.ts | 3 ++ src/config/settings.ts | 6 +++ src/settings.test.ts | 17 ++++++++ src/tui/commands/built-in.ts | 4 +- src/tui/runner.ts | 54 +++++++++++++++-------- tests/unit/config.test.ts | 1 + 11 files changed, 158 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 307263b8c..0f2503a01 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` (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). +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` still forces this process; `/yolo` persists as the user-global default. Both bypass 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 0eef84dc7..af47d4d86 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` (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. +- `loadConfig` is async (it reads settings files). Parses a leading `exec`/`run` subcommand, flags `--cwd`, `--config`, `--provider`, `--model`, `--force`, `--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 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`) @@ -368,7 +368,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` (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. +- **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` (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. - **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 a093aae5b..ce68d283f 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -166,7 +166,7 @@ Intent defaults: `intent=implement` → director `build`; explore/plan → same- ### 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. 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. +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. Secret-guard and authz still apply. 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: @@ -315,7 +315,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). Mid-session TUI twin: `/yolo [on\|off\|toggle]` via `setSkipPermissions` | +| `--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`) | diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 7af4ed6ec..011511049 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -67,7 +67,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). In the TUI, `/yolo` is the mid-session twin of that flag. `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` persists as the user-global default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply. `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`. @@ -97,7 +97,7 @@ is the direct, explicit resume path. ## 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`, `/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. +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` (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. 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`. Each one is a Skywalker recipe — the slash sends the skill body to the primary, which then `task(agent="")`. `/scribe` → shakespeare; `/implement` spawns build / greybeard / critique as the recipe specifies; `/plan` → plan director (eng change plan: files, AC, non-goals, risks, ordered steps; does not implement); `/review` is a code-review action. `/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`). Dispatch is not a default slash — it stays `use_skill` only, along with git-rebase, linear-issue-workflow, style, philosophy, typescript, and opsh (`user-invocable: false`). Draper and emil are not slashes; they remain closed directors via `task(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `use_skill`. Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone. @@ -124,7 +124,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, re-run with `--dangerously-skip-permissions`, or use `/yolo` mid-session in the TUI. +**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). ### Resume after interruption diff --git a/src/config.test.ts b/src/config.test.ts index fec1d0ca4..68387fb73 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -510,6 +510,90 @@ describe("loadConfig", () => { } }); + test("seeds dangerouslySkipPermissions from global settings without the CLI flag", async () => { + const cwd = await emptyCwd(); + try { + const globalPath = join(cwd, "global.json"); + await writeFile( + globalPath, + JSON.stringify({ + defaultProvider: "fireworks", + providers: { + fireworks: { + baseURL: "https://api.fireworks.ai/inference", + apiKey: "test-key", + models: ["accounts/fireworks/routers/kimi-k2p6-turbo"], + }, + }, + dangerouslySkipPermissions: true, + }), + ); + const config = await loadConfig(["--cwd", cwd, "do something"], { + globalSettingsPath: globalPath, + }); + expect(config.dangerouslySkipPermissions).toBe(true); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("CLI --dangerously-skip-permissions still wins over settings false", async () => { + const cwd = await emptyCwd(); + try { + const globalPath = join(cwd, "global.json"); + await writeFile( + globalPath, + JSON.stringify({ + defaultProvider: "fireworks", + providers: { + fireworks: { + baseURL: "https://api.fireworks.ai/inference", + apiKey: "test-key", + models: ["accounts/fireworks/routers/kimi-k2p6-turbo"], + }, + }, + dangerouslySkipPermissions: false, + }), + ); + const config = await loadConfig( + ["--cwd", cwd, "--dangerously-skip-permissions", "do something"], + { globalSettingsPath: globalPath }, + ); + expect(config.dangerouslySkipPermissions).toBe(true); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("exec inherits persisted skip-permissions without the CLI flag", async () => { + const cwd = await emptyCwd(); + try { + const globalPath = join(cwd, "global.json"); + await writeFile( + globalPath, + JSON.stringify({ + defaultProvider: "fireworks", + providers: { + fireworks: { + baseURL: "https://api.fireworks.ai/inference", + apiKey: "test-key", + models: ["accounts/fireworks/routers/kimi-k2p6-turbo"], + }, + }, + dangerouslySkipPermissions: true, + }), + ); + const config = await loadConfig(["exec", "--cwd", cwd, "ship it"], { + globalSettingsPath: globalPath, + }); + assertConfigured(config); + expect(config.command).toBe("exec"); + expect(config.dangerouslySkipPermissions).toBe(true); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("reads provider and model from a --config settings file", async () => { const cwd = await emptyCwd(); try { diff --git a/src/config/index.ts b/src/config/index.ts index fc4fffe76..642d59d93 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -589,6 +589,9 @@ export async function loadConfig( }) : await loadSettings(options.globalSettingsPath ?? globalSettingsPath()); + dangerouslySkipPermissions = + dangerouslySkipPermissions || settings?.dangerouslySkipPermissions === true; + // OAuth profiles live in home-level auth stores, not in settings files. They // are merged in only for the real default settings path: an explicit --config // or test override selects a controlled provider set that should not pull in diff --git a/src/config/settings.ts b/src/config/settings.ts index 856a9b01d..6191e9332 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -165,6 +165,8 @@ export type Settings = { // breakdown on demand, so the border only needs to opt in to the running // total. showPromptCost?: boolean; + // User-global YOLO default; `/yolo` writes it. + dangerouslySkipPermissions?: boolean; }; function modelRefKey(ref: ModelRef): string { @@ -477,6 +479,7 @@ const SettingsSchema = type({ "recentModels?": ModelRefSchema.array(), "favoriteModels?": ModelRefSchema.array(), "showPromptCost?": "boolean", + "dangerouslySkipPermissions?": "boolean", }); // Per-entry MCP shape without the name key. The "exactly one transport" rule is @@ -653,6 +656,7 @@ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [ "otel", "recentModels", "favoriteModels", + "dangerouslySkipPermissions", ] as const satisfies readonly (keyof OptionalSettingsFields)[]; /** Optional local settings keys the load path is required to consider. */ @@ -772,6 +776,8 @@ export async function loadSettings(path: string): Promise { recentModels: s.recentModels as Settings["recentModels"] | undefined, favoriteModels: s.favoriteModels as Settings["favoriteModels"] | undefined, showPromptCost: s.showPromptCost !== undefined ? Boolean(s.showPromptCost) : undefined, + dangerouslySkipPermissions: + s.dangerouslySkipPermissions !== undefined ? Boolean(s.dangerouslySkipPermissions) : undefined, }; const settings: Settings = { providers: s.providers as Settings["providers"], diff --git a/src/settings.test.ts b/src/settings.test.ts index 2be4d543a..a9f2a379b 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -259,6 +259,12 @@ describe("validators", () => { expect(isSettings({ providers: firepass.providers, showPromptCost: true })).toBe(true); }); + test("isSettings accepts dangerouslySkipPermissions", () => { + expect(isSettings({ providers: firepass.providers, dangerouslySkipPermissions: true })).toBe( + true, + ); + }); + test("isLocalSettings rejects credentials", () => { expect(isLocalSettings({ provider: "a", apiKey: "leak" })).toBe(false); }); @@ -779,6 +785,17 @@ test("loadSettings round-trips showPromptCost", async () => { } }); +test("loadSettings round-trips dangerouslySkipPermissions", async () => { + const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); + try { + const path = join(dir, ".corbits", "settings.json"); + await saveGlobalSettings(path, { ...firepass, dangerouslySkipPermissions: true }); + expect(await loadSettings(path)).toEqual({ ...firepass, dangerouslySkipPermissions: true }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("loadSettings tolerates a legacy maxConcurrentSubAgents key", async () => { const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); try { diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 7c35ed152..96043c231 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -188,10 +188,10 @@ export function registerBuiltInCommands(): void { }, }); - // Mid-session twin of --dangerously-skip-permissions. + // Persist as user-global default, not session-only. registerCommand({ name: "yolo", - description: "Skip permission prompts for this session", + description: "Skip permission prompts (persists as the default)", argumentHint: "[on|off|toggle]", subcommands: [ { name: "on", description: "Enable skip-permissions" }, diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 047ea1d32..d480d1cb6 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -1846,11 +1846,48 @@ export async function runTUI(initialConfig: Config): Promise { }); } + // Absent file → fresh base; unreadable/invalid → skip the write rather than + // clobber a corrupt settings file with a minimal shell. Returns false when + // the write is skipped so `/yolo` can notice that the live flip did not persist. + const persistGlobalSettings = async ( + what: string, + apply: (base: Settings) => Settings, + ): Promise => { + const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath); + if (base === null) { + tuiLogger.warn("Skipping {what} write: unreadable global settings at {path}", { + what, + path: config.globalSettingsPath, + }); + return false; + } + await saveGlobalSettings(config.globalSettingsPath, apply(base)); + return true; + }; + const commandContext: CommandContext = { signalClear: newSession, getSkipPermissions: () => permissionGate.getSkipPermissions(), setSkipPermissions: (value: boolean) => { permissionGate.setSkipPermissions(value); + config.dangerouslySkipPermissions = value; + void (async () => { + try { + const written = await persistGlobalSettings("skip-permissions default", (base) => ({ + ...base, + dangerouslySkipPermissions: value, + })); + if (!written) { + systemNotice( + "Yolo flipped for this session, but the default did not stick.", + ); + } + } catch { + systemNotice( + "Yolo flipped for this session, but the default did not stick.", + ); + } + })(); }, getCostSummary: (): CostSummary => { const usage = runSink.getTokenUsage(); @@ -1931,23 +1968,6 @@ export async function runTUI(initialConfig: Config): Promise { // listing, so revoke resolves against the same snapshot the operator saw. let listedGrants: readonly ScopedApproval[] = []; - // Absent file → fresh base; unreadable/invalid → skip the write rather than - // clobber a corrupt settings file with a minimal shell. - const persistGlobalSettings = async ( - what: string, - apply: (base: Settings) => Settings, - ): Promise => { - const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath); - if (base === null) { - tuiLogger.warn("Skipping {what} write: unreadable global settings at {path}", { - what, - path: config.globalSettingsPath, - }); - return; - } - await saveGlobalSettings(config.globalSettingsPath, apply(base)); - }; - const localSettingsFile = localSettingsPath(config.cwd); // Same fail-open shape as persistGlobalSettings, against the per-repo file. const persistLocalSettings = async ( diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 22b06516b..ab1e7d535 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -147,6 +147,7 @@ test("loadSettings cannot silently drop a known optional key", async () => { otel: { endpoint: "http://localhost:4318", serviceName: "corbits-test" }, recentModels: [{ provider: "p", model: "m" }], favoriteModels: [{ provider: "p", model: "m" }], + dangerouslySkipPermissions: true, }; await writeFile(globalPath, JSON.stringify(fixture)); const loaded = await loadSettings(globalPath); From 3f652cede3f2bd6cd348912148ede26a1069570b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 11:58:13 -0700 Subject: [PATCH 2/3] Cover persisted yolo default edge cases --- CHANGELOG.md | 8 +++++ docs/IMPLEMENTATION.md | 2 +- docs/PRODUCT.md | 2 +- src/config.test.ts | 27 +++++++++++++++ src/config/settings.ts | 12 +++++++ src/settings.test.ts | 55 ++++++++++++++++++++++++++++++- src/tui/commands/built-in.test.ts | 10 +++--- src/tui/commands/built-in.ts | 7 ++-- src/tui/commands/registry.ts | 2 +- src/tui/runner.ts | 54 +++++++++++++++++------------- 10 files changed, 146 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da465ba8..0e787505f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain parallel copies under `docs/` or `scripts/notes/`. At cut time: rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script. +## [Unreleased] + +### Changed + +- **`/yolo` persists as the user-global skip-permissions default.** Exec + inherits it; `--dangerously-skip-permissions` still forces the current + process. Secret-guard and authz still apply. + ## [0.2.100] - 2026-08-22 ### Plugins diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index ce68d283f..f3b9f0387 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -166,7 +166,7 @@ Intent defaults: `intent=implement` → director `build`; explore/plan → same- ### 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. Secret-guard and authz still apply. +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. 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: diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 011511049..9c7a93307 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -67,7 +67,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). In the TUI, `/yolo` persists as the user-global default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply. `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, 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` 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`. diff --git a/src/config.test.ts b/src/config.test.ts index 68387fb73..b078ad673 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -537,6 +537,33 @@ describe("loadConfig", () => { } }); + test("settings dangerouslySkipPermissions false without the CLI flag stays false", async () => { + const cwd = await emptyCwd(); + try { + const globalPath = join(cwd, "global.json"); + await writeFile( + globalPath, + JSON.stringify({ + defaultProvider: "fireworks", + providers: { + fireworks: { + baseURL: "https://api.fireworks.ai/inference", + apiKey: "test-key", + models: ["accounts/fireworks/routers/kimi-k2p6-turbo"], + }, + }, + dangerouslySkipPermissions: false, + }), + ); + const config = await loadConfig(["--cwd", cwd, "do something"], { + globalSettingsPath: globalPath, + }); + expect(config.dangerouslySkipPermissions).toBe(false); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("CLI --dangerously-skip-permissions still wins over settings false", async () => { const cwd = await emptyCwd(); try { diff --git a/src/config/settings.ts b/src/config/settings.ts index 6191e9332..1c5c516ba 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -1016,6 +1016,18 @@ export async function saveGlobalSettings(path: string, settings: Settings): Prom await rename(tmp, path); } +// Silent helper: callers log or notice. Skips when the write base is null so a +// corrupt settings file is never replaced with a one-key rewrite. +export async function persistSkipPermissionsDefault( + path: string, + value: boolean, +): Promise<"ok" | "skipped"> { + const base = await loadGlobalSettingsWriteBase(path); + if (base === null) return "skipped"; + await saveGlobalSettings(path, { ...base, dangerouslySkipPermissions: value }); + return "ok"; +} + // Stamp the global `onboarded` flag. Reads the on-disk global settings fresh // (never an in-memory Settings that may carry injected OAuth provider entries // with short-lived access tokens) and re-saves with onboarded set. When the diff --git a/src/settings.test.ts b/src/settings.test.ts index a9f2a379b..f3a5e4230 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import { chmod, mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { chmod, mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; @@ -24,6 +24,7 @@ import { validateTaskMaxTurns, toolWatchdogFromSettings, loadGlobalSettingsWriteBase, + persistSkipPermissionsDefault, markLastChangelogVersion, pushRecentModel, toggleFavoriteModel, @@ -739,6 +740,56 @@ describe("loaders", () => { }); }); +describe("persistSkipPermissionsDefault", () => { + test("writes true", async () => { + const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); + try { + const path = join(dir, "settings.json"); + await saveGlobalSettings(path, firepass); + expect(await persistSkipPermissionsDefault(path, true)).toBe("ok"); + expect(await loadSettings(path)).toEqual({ + ...firepass, + dangerouslySkipPermissions: true, + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("writes false", async () => { + const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); + try { + const path = join(dir, "settings.json"); + await saveGlobalSettings(path, { ...firepass, dangerouslySkipPermissions: true }); + expect(await persistSkipPermissionsDefault(path, false)).toBe("ok"); + expect(await loadSettings(path)).toEqual({ + ...firepass, + dangerouslySkipPermissions: false, + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("skips invalid or unreadable settings and leaves the file unchanged", async () => { + const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); + try { + const path = join(dir, "settings.json"); + const garbage = "{ not json"; + await writeFile(path, garbage); + expect(await persistSkipPermissionsDefault(path, true)).toBe("skipped"); + expect(await readFile(path, "utf8")).toBe(garbage); + + const wrongShape = JSON.stringify({ providers: "wrong-shape" }); + await writeFile(path, wrongShape); + expect(await persistSkipPermissionsDefault(path, true)).toBe("skipped"); + expect(await readFile(path, "utf8")).toBe(wrongShape); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + describe("sessionMode", () => { test("loadSettings drops legacy single sessionMode", async () => { const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); @@ -791,6 +842,8 @@ test("loadSettings round-trips dangerouslySkipPermissions", async () => { const path = join(dir, ".corbits", "settings.json"); await saveGlobalSettings(path, { ...firepass, dangerouslySkipPermissions: true }); expect(await loadSettings(path)).toEqual({ ...firepass, dangerouslySkipPermissions: true }); + await saveGlobalSettings(path, { ...firepass, dangerouslySkipPermissions: false }); + expect(await loadSettings(path)).toEqual({ ...firepass, dangerouslySkipPermissions: false }); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 67115fb0c..09af1422a 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -83,17 +83,17 @@ describe("/yolo command", () => { }; expect(getCommand("yolo")!.handler("", ctx)).toEqual({ type: "message", - text: "Yolo mode on — permission prompts skipped.", + text: "Yolo mode on — permission prompts skipped. Saved as the default.", }); expect(skip).toBe(true); expect(getCommand("yolo")!.handler("", ctx)).toEqual({ type: "message", - text: "Yolo mode off — permission prompts restored.", + text: "Yolo mode off — permission prompts restored. Saved as the default.", }); expect(skip).toBe(false); expect(getCommand("yolo")!.handler("toggle", ctx)).toEqual({ type: "message", - text: "Yolo mode on — permission prompts skipped.", + text: "Yolo mode on — permission prompts skipped. Saved as the default.", }); expect(skip).toBe(true); }); @@ -109,12 +109,12 @@ describe("/yolo command", () => { }; expect(getCommand("yolo")!.handler("on", ctx)).toEqual({ type: "message", - text: "Yolo mode on — permission prompts skipped.", + text: "Yolo mode on — permission prompts skipped. Saved as the default.", }); expect(skip).toBe(true); expect(getCommand("yolo")!.handler("off", ctx)).toEqual({ type: "message", - text: "Yolo mode off — permission prompts restored.", + text: "Yolo mode off — permission prompts restored. Saved as the default.", }); expect(skip).toBe(false); }); diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 96043c231..879248209 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -217,10 +217,13 @@ export function registerBuiltInCommands(): void { if (next) { return { type: "message", - text: "Yolo mode on — permission prompts skipped.", + text: "Yolo mode on — permission prompts skipped. Saved as the default.", }; } - return { type: "message", text: "Yolo mode off — permission prompts restored." }; + return { + type: "message", + text: "Yolo mode off — permission prompts restored. Saved as the default.", + }; }, }); diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index 5f601cf78..24504a504 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -25,7 +25,7 @@ export type CommandContext = { beginFeedbackCapture?: () => void; /** Whether skip-permissions (yolo) is active for this session. */ getSkipPermissions?: () => boolean; - /** Toggle skip-permissions for the rest of the session (`/yolo`). */ + /** Live-flip skip-permissions and persist `/yolo` as the user-global default. */ setSkipPermissions?: (value: boolean) => void; }; diff --git a/src/tui/runner.ts b/src/tui/runner.ts index d480d1cb6..25053910e 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -28,6 +28,7 @@ import { loadSettings, localSettingsPath, markTelemetryNoticeShown, + persistSkipPermissionsDefault, pushRecentModel, saveGlobalSettings, saveLocalSettings, @@ -1846,24 +1847,33 @@ export async function runTUI(initialConfig: Config): Promise { }); } + // One tail for every RMW of config.globalSettingsPath from this runner so + // /yolo and /settings toggles cannot stale-RMW each other. + let persistTail = Promise.resolve(); + const enqueueGlobalPersist = (job: () => Promise): Promise => { + const run = persistTail.then(job); + persistTail = run.then(() => undefined, () => undefined); + return run; + }; + // Absent file → fresh base; unreadable/invalid → skip the write rather than - // clobber a corrupt settings file with a minimal shell. Returns false when - // the write is skipped so `/yolo` can notice that the live flip did not persist. - const persistGlobalSettings = async ( + // clobber a corrupt settings file with a minimal shell. + const persistGlobalSettings = ( what: string, apply: (base: Settings) => Settings, - ): Promise => { - const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath); - if (base === null) { - tuiLogger.warn("Skipping {what} write: unreadable global settings at {path}", { - what, - path: config.globalSettingsPath, - }); - return false; - } - await saveGlobalSettings(config.globalSettingsPath, apply(base)); - return true; - }; + ): Promise => + enqueueGlobalPersist(async () => { + const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath); + if (base === null) { + tuiLogger.warn("Skipping {what} write: unreadable global settings at {path}", { + what, + path: config.globalSettingsPath, + }); + return false; + } + await saveGlobalSettings(config.globalSettingsPath, apply(base)); + return true; + }); const commandContext: CommandContext = { signalClear: newSession, @@ -1871,13 +1881,13 @@ export async function runTUI(initialConfig: Config): Promise { setSkipPermissions: (value: boolean) => { permissionGate.setSkipPermissions(value); config.dangerouslySkipPermissions = value; - void (async () => { + void enqueueGlobalPersist(async () => { try { - const written = await persistGlobalSettings("skip-permissions default", (base) => ({ - ...base, - dangerouslySkipPermissions: value, - })); - if (!written) { + const result = await persistSkipPermissionsDefault( + config.globalSettingsPath, + value, + ); + if (result === "skipped") { systemNotice( "Yolo flipped for this session, but the default did not stick.", ); @@ -1887,7 +1897,7 @@ export async function runTUI(initialConfig: Config): Promise { "Yolo flipped for this session, but the default did not stick.", ); } - })(); + }); }, getCostSummary: (): CostSummary => { const usage = runSink.getTokenUsage(); From bf3925f0f17b87fad28058b3b82150dbae8281b8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 13:15:06 -0700 Subject: [PATCH 3/3] Surface persisted skip-permissions default at startup --- docs/IMPLEMENTATION.md | 2 +- docs/PRODUCT.md | 2 +- src/config.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++++ src/config/index.ts | 15 ++++++++++++++ src/exec/runner.ts | 6 ++++++ src/tui/runner.ts | 10 ++++++++++ 6 files changed, 77 insertions(+), 2 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index f3b9f0387..bf6e9e863 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -166,7 +166,7 @@ Intent defaults: `intent=implement` → director `build`; explore/plan → same- ### 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. +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. 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: diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 9c7a93307..f56545620 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -97,7 +97,7 @@ is the direct, explicit resume path. ## 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`, `/mcp`, 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. 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` (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. **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`. Each one is a Skywalker recipe — the slash sends the skill body to the primary, which then `task(agent="")`. `/scribe` → shakespeare; `/implement` spawns build / greybeard / critique as the recipe specifies; `/plan` → plan director (eng change plan: files, AC, non-goals, risks, ordered steps; does not implement); `/review` is a code-review action. `/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`). Dispatch is not a default slash — it stays `use_skill` only, along with git-rebase, linear-issue-workflow, style, philosophy, typescript, and opsh (`user-invocable: false`). Draper and emil are not slashes; they remain closed directors via `task(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `use_skill`. Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone. diff --git a/src/config.test.ts b/src/config.test.ts index b078ad673..3b9d7b35b 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -491,6 +491,7 @@ describe("loadConfig", () => { globalSettingsPath: globalPath, }); expect(config.dangerouslySkipPermissions).toBe(false); + expect(config.skipPermissionsFromSettings).toBe(false); } finally { await rm(cwd, { recursive: true, force: true }); } @@ -505,6 +506,8 @@ describe("loadConfig", () => { { globalSettingsPath: globalPath }, ); expect(config.dangerouslySkipPermissions).toBe(true); + // Came from the CLI flag, not the persisted default — no startup notice. + expect(config.skipPermissionsFromSettings).toBe(false); } finally { await rm(cwd, { recursive: true, force: true }); } @@ -532,6 +535,9 @@ describe("loadConfig", () => { globalSettingsPath: globalPath, }); expect(config.dangerouslySkipPermissions).toBe(true); + // Origin is the persisted default, not this invocation's flag — the + // startup notice should fire. + expect(config.skipPermissionsFromSettings).toBe(true); } finally { await rm(cwd, { recursive: true, force: true }); } @@ -559,6 +565,7 @@ describe("loadConfig", () => { globalSettingsPath: globalPath, }); expect(config.dangerouslySkipPermissions).toBe(false); + expect(config.skipPermissionsFromSettings).toBe(false); } finally { await rm(cwd, { recursive: true, force: true }); } @@ -587,6 +594,8 @@ describe("loadConfig", () => { { globalSettingsPath: globalPath }, ); expect(config.dangerouslySkipPermissions).toBe(true); + // CLI flag wins over settings — no notice is warranted here. + expect(config.skipPermissionsFromSettings).toBe(false); } finally { await rm(cwd, { recursive: true, force: true }); } @@ -616,11 +625,46 @@ describe("loadConfig", () => { assertConfigured(config); expect(config.command).toBe("exec"); expect(config.dangerouslySkipPermissions).toBe(true); + expect(config.skipPermissionsFromSettings).toBe(true); } finally { await rm(cwd, { recursive: true, force: true }); } }); + test("persisted skip-permissions default applies regardless of cwd (machine-wide scope)", async () => { + // The global settings file is machine-wide: a session opened against a + // completely different cwd still inherits the same default. This is the + // exact silent-everywhere behavior the startup notice exists to surface. + const globalCwd = await emptyCwd(); + const otherCwd = await emptyCwd(); + try { + const globalPath = join(globalCwd, "global.json"); + await writeFile( + globalPath, + JSON.stringify({ + defaultProvider: "fireworks", + providers: { + fireworks: { + baseURL: "https://api.fireworks.ai/inference", + apiKey: "test-key", + models: ["accounts/fireworks/routers/kimi-k2p6-turbo"], + }, + }, + dangerouslySkipPermissions: true, + }), + ); + const config = await loadConfig(["--cwd", otherCwd, "do something"], { + globalSettingsPath: globalPath, + }); + expect(config.cwd).toBe(otherCwd); + expect(config.dangerouslySkipPermissions).toBe(true); + expect(config.skipPermissionsFromSettings).toBe(true); + } finally { + await rm(globalCwd, { recursive: true, force: true }); + await rm(otherCwd, { recursive: true, force: true }); + } + }); + test("reads provider and model from a --config settings file", async () => { const cwd = await emptyCwd(); try { diff --git a/src/config/index.ts b/src/config/index.ts index 642d59d93..eb98b4f26 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -279,6 +279,10 @@ export type Config = { task: string; force: boolean; dangerouslySkipPermissions: 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. + skipPermissionsFromSettings: boolean; auto: boolean; /** * Exec-only chosen primary director. Omitted = Skywalker (product default). @@ -348,6 +352,7 @@ export type UnconfiguredConfig = { task: string; force: boolean; dangerouslySkipPermissions: boolean; + skipPermissionsFromSettings: boolean; auto: boolean; command: "tui" | "exec"; /** Exec-only chosen primary. Omitted on the unconfigured path too. */ @@ -388,6 +393,9 @@ Flags: --force override an existing run state --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 --auto / --no-auto auto mode on/off --help, -h show this help `; @@ -589,6 +597,11 @@ export async function loadConfig( }) : await loadSettings(options.globalSettingsPath ?? globalSettingsPath()); + // Track whether the effective value came from the persisted global default + // 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 = + !dangerouslySkipPermissions && settings?.dangerouslySkipPermissions === true; dangerouslySkipPermissions = dangerouslySkipPermissions || settings?.dangerouslySkipPermissions === true; @@ -656,6 +669,7 @@ export async function loadConfig( task, force, dangerouslySkipPermissions, + skipPermissionsFromSettings, auto, command, ...(director !== undefined ? { director } : {}), @@ -708,6 +722,7 @@ export async function loadConfig( task: resumeTask, force, dangerouslySkipPermissions, + skipPermissionsFromSettings, auto, command, ...(director !== undefined ? { director } : {}), diff --git a/src/exec/runner.ts b/src/exec/runner.ts index b4251a87a..e6fb4fec7 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -358,6 +358,12 @@ export async function runExec(config: Config): Promise { const interactive = input.isTTY === true && output.isTTY === true; + if (config.skipPermissionsFromSettings) { + stderr.write( + "Warning: permission prompts are disabled by your saved default (/yolo off to re-enable).\n", + ); + } + const permissionGate = createPermissionGate({ approvals: seededApprovals, telemetry: liveTelemetry, diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 25053910e..f994d39da 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2585,6 +2585,16 @@ export async function runTUI(initialConfig: Config): Promise { paintPluginAttention = (needs) => setPluginNeedsAttention(host.shell, needs); paintPluginAttention(standingPluginWarnings.length > 0); + // 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 (config.skipPermissionsFromSettings) { + surfaceSystemNotice( + host.shell, + "Permission prompts are disabled by your saved default (/yolo off to re-enable).", + ); + } + // Soft upgrade check: never blocks startup; offline / rate-limit is a quiet skip. // surfaceSystemNotice keeps the landing hero up and flushes into the transcript // once a session row ends the landing (same path as MCP startup chatter).