From 6692a8ca9ff2d3f46345e946e544ead8df48b9a9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 16:38:08 -0700 Subject: [PATCH] feat(permissions): replace global grants with a process file A headless caller cannot confirm a project grant file and must not inherit the user global list. A separate file replaces that list for one process. --- docs/IMPLEMENTATION.md | 45 ++-- src/config.test.ts | 85 ++++++++ src/config/index.ts | 28 +++ src/exec/runner.ts | 1 + src/permission/admin.test.ts | 168 +++++++++++++++ src/permission/admin.ts | 85 ++++++-- src/permission/store.ts | 117 ++++++++++- src/session/assemble-runtime.ts | 14 +- src/session/runtime-assembly.test.ts | 304 ++++++++++++++++++++++++++- src/session/runtime-assembly.ts | 70 ++++-- src/tui/onboarding.ts | 2 + src/tui/runner/session.ts | 5 +- 12 files changed, 854 insertions(+), 70 deletions(-) create mode 100644 src/permission/admin.test.ts diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 1eaf83f42..731f0981d 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -319,7 +319,9 @@ Provider and model configuration lives in JSON settings files. The global file h Optional `sessionMode` is **deprecated**. Legacy values (`single` | `orchestrator`) may still appear on disk and load without error; resolve always returns **orchestrator**. There is no first-run mode picker and no Settings row. Both the interactive TUI (`runTUI`) and the non-TUI product path (`runExec` / `corbits exec`) are orchestrator-only. Exec bootstrap consumes the same session assembly as the TUI (intentional deltas documented under Architecture → Exec Runner). -- Per-repo: `.corbits/settings.json` — **selection only**, e.g. `{ "provider": "firepass", "model": "fp-small" }`. Any other key (notably `apiKey` or `baseURL`) is rejected by the loader, and the file is gitignored. It is also on the secret-guard denylist for path-keyed tools, as is the global file, so the agent cannot `read_file` its own credentials (shell references still require explicit operator approval). The project grant store (`.corbits/permissions.json`) is on the same denylist so the agent cannot write a standing auto-approval. +- Per-repo: `.corbits/settings.json` — **selection only**, e.g. `{ "provider": "firepass", "model": "fp-small" }`. Any other key (notably `apiKey` or `baseURL`) is rejected by the loader, and the file is gitignored. It is also on the secret-guard denylist for path-keyed tools, as is the global file, so the agent cannot `read_file` its own credentials (shell references still require explicit operator approval). The project grant store (`.corbits/permissions.json`) is on the same denylist so the agent cannot write a standing auto-approval. An unconfirmed project file still contributes no grants. + + `corbits exec --permissions ` replaces the user-global and provider-model grants for that process only. The file is `{ "approvals": [ { "tool": "run_shell", "pattern": "bun install*" } ] }` (`providerModel` optional). It is not merged with `~/.corbits/permissions.json` and is not a settings key. A missing `approvals` key is an empty replacement; a `providerModels` map in that file is not applied. Session grants stay in memory and project grants still use the project permissions file. While the flag is set, global and provider-model Allow Always append to that file's `approvals` array, and `/permissions` revoke removes the row from it. Neither path reads or writes `~/.corbits/permissions.json`. A missing, unreadable, or invalid file, or a wildcard-only pattern, fails at start. `baseURL` is editable provider metadata, but it still belongs in the global provider definition rather than the per-repo selection file. `apiKey` is secret and must never be projected into TUI display-only provider lists. @@ -423,26 +425,27 @@ This release changes no settings, OAuth-store, or session persistence format and 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` | 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`) | -| `--version`, `-V` | — | Print `corbits v` plus the running `package.json` version (exit 0 via `CliVersionError`) | +| 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 | +| `--permissions ` | — | Process-only grant file. Its `approvals` replace user-global and provider-model grants for this process and are not merged with `~/.corbits/permissions.json`. Missing, unreadable, invalid JSON, or a wildcard-only pattern fails at start. | +| `--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`) | +| `--version`, `-V` | — | Print `corbits v` plus the running `package.json` version (exit 0 via `CliVersionError`) | 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/src/config.test.ts b/src/config.test.ts index 72a92d167..32d488de0 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -970,6 +970,8 @@ describe("loadConfig", () => { [["--cwd", "-h"]], [["--config", "--help"]], [["--config", "-h"]], + [["--permissions", "--help"]], + [["--permissions", "-h"]], [["--profile", "--help"]], [["--profile", "-h"]], ])( @@ -1007,6 +1009,8 @@ describe("loadConfig", () => { [["--cwd", "-V"]], [["--config", "--version"]], [["--config", "-V"]], + [["--permissions", "--version"]], + [["--permissions", "-V"]], [["--profile", "--version"]], [["--profile", "-V"]], ])( @@ -1041,6 +1045,8 @@ describe("loadConfig", () => { [["--model"], "--model requires a value"], [["--cwd"], "--cwd requires a value"], [["--config"], "--config requires a value"], + [["--permissions"], "--permissions requires a value"], + [["--permissions", "--auto"], "--permissions requires a value"], [["--profile"], "--profile requires a value"], ] as const)("%j rejects with %s", async (argv, message) => { await expect( @@ -1200,6 +1206,85 @@ describe("loadConfig", () => { ).rejects.toThrow(/not found or empty/); }); + test("CLI_HELP_TEXT documents --permissions next to --config", () => { + const configAt = CLI_HELP_TEXT.indexOf("--config "); + const permissionsAt = CLI_HELP_TEXT.indexOf("--permissions "); + expect(configAt).toBeGreaterThanOrEqual(0); + expect(permissionsAt).toBeGreaterThan(configAt); + expect(CLI_HELP_TEXT.indexOf("--provider ")).toBeGreaterThan( + permissionsAt, + ); + }); + + test("--permissions resolves onto config without adding an approvals settings field", async () => { + const cwd = await emptyCwd(); + const permissionsPath = join(cwd, "grants.json"); + await writeFile( + permissionsPath, + JSON.stringify({ + approvals: [ + { + tool: "run_shell", + pattern: "bun install*", + providerModel: "openai:gpt-5", + }, + ], + }), + ); + const config = await loadFor(cwd, [ + "exec", + "--permissions", + permissionsPath, + "task", + ]); + expect(config.command).toBe("exec"); + expect(config.permissionsPath).toBe(resolve(permissionsPath)); + expect(config.settings).not.toHaveProperty("approvals"); + expect(config.dangerouslySkipPermissions).toBe(false); + }); + + test("--permissions pointing at a missing file fails at start", async () => { + const cwd = await emptyCwd(); + await expect( + loadFor(cwd, [ + "exec", + "--permissions", + join(cwd, "missing.json"), + "task", + ]), + ).rejects.toThrow(/--permissions file not found/); + }); + + test("--permissions invalid JSON fails at start", async () => { + const cwd = await emptyCwd(); + const permissionsPath = join(cwd, "bad.json"); + await writeFile(permissionsPath, "{"); + await expect( + loadFor(cwd, ["exec", "--permissions", permissionsPath, "task"]), + ).rejects.toThrow(/invalid JSON/); + }); + + test("--permissions unreadable path fails at start", async () => { + const cwd = await emptyCwd(); + await expect( + loadFor(cwd, ["exec", "--permissions", cwd, "task"]), + ).rejects.toThrow(/unreadable/); + }); + + test("--permissions wildcard-only pattern fails at start", async () => { + const cwd = await emptyCwd(); + const permissionsPath = join(cwd, "wild.json"); + await writeFile( + permissionsPath, + JSON.stringify({ + approvals: [{ tool: "run_shell", pattern: "*" }], + }), + ); + await expect( + loadFor(cwd, ["exec", "--permissions", permissionsPath, "task"]), + ).rejects.toThrow(/literal floor/); + }); + test("--profile flag surfaces profile name and model from project profile.json", async () => { const cwd = await emptyCwd(); await mkdir(join(cwd, ".corbits"), { recursive: true }); diff --git a/src/config/index.ts b/src/config/index.ts index c5ecd5f44..8c49a0183 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -103,6 +103,7 @@ import { type ResolvedMCPServerConfig, } from "../mcp/exa.js"; import { resolveProfile } from "./profiles.js"; +import { loadPermissionsFile } from "../permission/store.js"; // Per-call token ceiling for inference sources, shared by agent creation // (runner.ts) and live provider switching (the /agent modal) so a live switch @@ -618,6 +619,12 @@ export interface Config { command: "tui" | "exec"; /** Active settings source, including an explicit --config path. */ globalSettingsPath: string; + /** + * Absolute path from `--permissions`. When set, that file's approvals + * replace the user-global and provider-model grant lists for this process + * only. Not a settings key. + */ + permissionsPath?: string; globalDefaultProvider?: string; // Every provider available to switch to at runtime. From the settings file // when present; env-only mode has just the single resolved provider. @@ -691,6 +698,11 @@ export interface UnconfiguredConfig { globalSettingsPath: string; /** Original CLI path, present only when --config selected the write target. */ cliConfigPath?: string; + /** + * Absolute path from `--permissions`, replayed after onboarding so the + * process-only grant file is not dropped on the reload. + */ + permissionsPath?: string; /** Whether the caller requested an OAuth-isolated programmatic settings load. */ programmaticSettingsPath: boolean; // The original error message, used for non-TUI (exec) error output. @@ -721,6 +733,7 @@ Continue verbs (project-keyed to this checkout's git toplevel): Flags: --cwd working directory (default: process.cwd()) --config settings file (default: ~/.corbits/settings.json) + --permissions grant file replacing ~/.corbits/permissions.json for this process --provider configured provider name --model model for the active provider --profile settings profile @@ -875,6 +888,7 @@ export async function loadConfig( let auto = true; let director: DirectorId | undefined; let configPath: string | undefined; + let permissionsPath: string | undefined; let provider: string | undefined; let model: string | undefined; let profileFlag: string | undefined; @@ -901,6 +915,10 @@ export async function loadConfig( configPath = resolve(requireValue("--config", args[++i])); continue; } + if (arg === "--permissions") { + permissionsPath = resolve(requireValue("--permissions", args[++i])); + continue; + } if (arg === "--provider") { provider = requireValue("--provider", args[++i]); continue; @@ -979,6 +997,14 @@ export async function loadConfig( positional.push(arg); } + if (permissionsPath !== undefined) { + try { + await loadPermissionsFile(permissionsPath); + } catch (err) { + throw new CliUserError(err instanceof Error ? err.message : String(err)); + } + } + const pricingCachePath = defaultPricingCachePath(); await bootstrapPricingMetadata({ @@ -1103,6 +1129,7 @@ export async function loadConfig( : {}), globalSettingsPath: effectiveSettingsPath, ...(configPath !== undefined ? { cliConfigPath: configPath } : {}), + ...(permissionsPath !== undefined ? { permissionsPath } : {}), programmaticSettingsPath: options.globalSettingsPath !== undefined, providerError: err instanceof Error ? err.message : String(err), // Keep diagnostics when provider setup fails early so junk local files @@ -1197,6 +1224,7 @@ export async function loadConfig( command, ...(director !== undefined ? { director } : {}), globalSettingsPath: effectiveSettingsPath, + ...(permissionsPath !== undefined ? { permissionsPath } : {}), sessionId, ...(resumeMode !== undefined ? { diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 3fd4199a5..274f6d847 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -722,6 +722,7 @@ export async function runExec(config: Config): Promise { // Main session: gating rides the reactor's approval-suspend seam. In // headless exec an ask resolves to a deny effect, so the hook blocks. reactorGated: true, + permissionsPath: config.permissionsPath, }); const liveSubAgentProvider: { current: SubAgentProvider } = { diff --git a/src/permission/admin.test.ts b/src/permission/admin.test.ts new file mode 100644 index 000000000..879f84917 --- /dev/null +++ b/src/permission/admin.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SETTINGS_DIR_NAME } from "../branding.js"; +import { generateSessionId, initSessionDir } from "../session/index.js"; +import { loadSeededApprovals } from "../session/runtime-assembly.js"; +import { createPermissionsAdmin } from "./admin.js"; +import { createPermissionGate } from "./gate.js"; +import * as permissionStore from "./store.js"; + +describe("createPermissionsAdmin --permissions reseed", () => { + let cwd = ""; + let home = ""; + + afterEach(async () => { + mock.restore(); + if (cwd !== "") await rm(cwd, { recursive: true, force: true }); + if (home !== "") await rm(home, { recursive: true, force: true }); + cwd = ""; + home = ""; + }); + + test("revoking a project grant keeps the file grant and does not restore or edit user grants", async () => { + cwd = await mkdtemp(join(tmpdir(), "perm-admin-")); + home = await mkdtemp(join(tmpdir(), "perm-admin-home-")); + const sessionId = generateSessionId(); + await initSessionDir(cwd, sessionId, home); + + await permissionStore.saveGlobalApproval( + { tool: "run_shell", pattern: "global-only git *" }, + home, + ); + await permissionStore.saveProviderModelApproval( + "openai:gpt-5", + { tool: "run_shell", pattern: "provider-only npm *" }, + home, + ); + await permissionStore.saveProjectApproval( + cwd, + { tool: "run_shell", pattern: "project keep *" }, + home, + ); + await permissionStore.saveProjectApproval( + cwd, + { tool: "run_shell", pattern: "project drop *" }, + home, + ); + const permissionsPath = join(cwd, "grants.json"); + await writeFile( + permissionsPath, + JSON.stringify({ + approvals: [ + { tool: "run_shell", pattern: "bun install*" }, + { + tool: "run_shell", + pattern: "scoped cargo *", + providerModel: "openai:gpt-5", + }, + ], + }), + ); + const userStorePath = join(home, SETTINGS_DIR_NAME, "permissions.json"); + const userStoreBefore = await readFile(userStorePath, "utf-8"); + + const seeded = await loadSeededApprovals(cwd, sessionId, home, { + permissionsPath, + }); + const gate = createPermissionGate({ + approvals: seeded, + cwd, + interactive: false, + skipPermissions: false, + reactorGated: false, + }); + + // homedir() is cached, so a no-arg reload of the user store would not see + // this temp home — and a no-arg remove would edit the real user file. + // Surface either call as the sentinel grant / a throw instead. + spyOn(permissionStore, "loadGlobalApprovals").mockResolvedValue([ + { tool: "run_shell", pattern: "global-only git *" }, + ]); + spyOn(permissionStore, "loadProviderModelApprovals").mockResolvedValue([ + { + tool: "run_shell", + pattern: "provider-only npm *", + providerModel: "openai:gpt-5", + }, + ]); + const removeGlobal = spyOn( + permissionStore, + "removeGlobalApproval", + ).mockImplementation(() => { + throw new Error("user global store must not be mutated"); + }); + const removeProvider = spyOn( + permissionStore, + "removeProviderModelApproval", + ).mockImplementation(() => { + throw new Error("user provider-model store must not be mutated"); + }); + + const admin = createPermissionsAdmin(gate, cwd, { permissionsPath, home }); + await admin.revoke({ + scope: "project", + tool: "run_shell", + pattern: "project drop *", + }); + + const patterns = gate.getApprovals().map((approval) => approval.pattern); + expect(patterns).toContain("bun install*"); + expect(patterns).toContain("scoped cargo *"); + expect(patterns).toContain("project keep *"); + expect(patterns).not.toContain("project drop *"); + expect(patterns).not.toContain("global-only git *"); + expect(patterns).not.toContain("provider-only npm *"); + expect(removeGlobal).not.toHaveBeenCalled(); + expect(removeProvider).not.toHaveBeenCalled(); + expect(await readFile(userStorePath, "utf-8")).toBe(userStoreBefore); + + const listed = await admin.list(); + const fileRow = listed.find((entry) => entry.pattern === "bun install*"); + expect(fileRow).toEqual({ + scope: "global", + tool: "run_shell", + pattern: "bun install*", + }); + await admin.revoke({ + scope: "global", + tool: "run_shell", + pattern: "bun install*", + }); + await admin.revoke({ + scope: "provider-model", + tool: "run_shell", + pattern: "scoped cargo *", + providerModel: "openai:gpt-5", + }); + + const afterFileRevoke = gate + .getApprovals() + .map((approval) => approval.pattern); + expect(afterFileRevoke).not.toContain("bun install*"); + expect(afterFileRevoke).not.toContain("scoped cargo *"); + expect(afterFileRevoke).toContain("project keep *"); + expect(afterFileRevoke).not.toContain("global-only git *"); + expect(afterFileRevoke).not.toContain("provider-only npm *"); + expect(removeGlobal).not.toHaveBeenCalled(); + expect(removeProvider).not.toHaveBeenCalled(); + expect(await readFile(userStorePath, "utf-8")).toBe(userStoreBefore); + + const fileAfter = JSON.parse(await readFile(permissionsPath, "utf-8")) as { + approvals: { tool: string; pattern: string }[]; + providerModels?: unknown; + }; + expect(fileAfter.approvals).toEqual([]); + expect(fileAfter.providerModels).toBeUndefined(); + + const reseeded = await loadSeededApprovals(cwd, sessionId, home, { + permissionsPath, + }); + const reseededPatterns = reseeded.map((approval) => approval.pattern); + expect(reseededPatterns).not.toContain("bun install*"); + expect(reseededPatterns).not.toContain("scoped cargo *"); + expect(reseededPatterns).toContain("project keep *"); + expect(reseededPatterns).not.toContain("global-only git *"); + }); +}); diff --git a/src/permission/admin.ts b/src/permission/admin.ts index 0dd45cc12..cacbd1a69 100644 --- a/src/permission/admin.ts +++ b/src/permission/admin.ts @@ -1,9 +1,10 @@ import type { Approval, GrantScope } from "./types.js"; import type { PermissionGate } from "./gate.js"; import { + flattenProcessGrantLayers, + loadProcessGrantLayers, loadProjectApprovals, - loadGlobalApprovals, - loadProviderModelApprovals, + removePermissionsFileApproval, removeProjectApproval, removeGlobalApproval, removeProviderModelApproval, @@ -21,6 +22,16 @@ export interface PermissionsAdmin { revoke: (entry: ScopedApproval) => Promise; } +export interface PermissionsAdminOptions { + /** + * When set, global and provider-model rows come from this file. Reseed + * reloads it and does not read or write the user grant stores. + */ + permissionsPath?: string | undefined; + /** Temp home for tests. Production omits it and uses the real user home. */ + home?: string | undefined; +} + function toApproval(entry: ScopedApproval): Approval { return entry.providerModel !== undefined ? { @@ -38,31 +49,59 @@ function tag( return approvals.map((a) => ({ scope, ...a })); } +// File rows carry providerModel only when the grant itself is scoped. Tagging +// by that field matches the user-store split without reordering the file. +function tagFileApprovals(approvals: readonly Approval[]): ScopedApproval[] { + return approvals.map((approval) => ({ + scope: + approval.providerModel !== undefined + ? ("provider-model" as const) + : ("global" as const), + ...approval, + })); +} + // Read/write both the persistent stores and the live gate so /permissions -// changes take effect without a restart. +// changes take effect without a restart. A --permissions file is the whole +// global/provider-model list for this process: reseed reloads it, and revoke +// edits that file rather than ~/.corbits/permissions.json. export function createPermissionsAdmin( gate: PermissionGate, cwd: string, + opts?: PermissionsAdminOptions, ): PermissionsAdmin { - const loadPersisted = (): Promise<[Approval[], Approval[], Approval[]]> => - Promise.all([ - loadProjectApprovals(cwd), - loadGlobalApprovals(), - loadProviderModelApprovals(), + const permissionsPath = opts?.permissionsPath; + const home = opts?.home; + + const loadPersisted = async (): Promise<{ + project: Approval[]; + layers: Awaited>; + }> => { + const [project, layers] = await Promise.all([ + loadProjectApprovals(cwd, home), + loadProcessGrantLayers(permissionsPath, home), ]); + return { project, layers }; + }; const reseed = async (): Promise => { - const [project, global, providerModel] = await loadPersisted(); - gate.setSeededApprovals([...project, ...global, ...providerModel]); + const { project, layers } = await loadPersisted(); + gate.setSeededApprovals([...project, ...flattenProcessGrantLayers(layers)]); }; const list = async (): Promise => { - const [project, global, providerModel] = await loadPersisted(); + const { project, layers } = await loadPersisted(); + const replaced = + layers.source === "file" + ? tagFileApprovals(layers.approvals) + : [ + ...tag("global", layers.global), + ...tag("provider-model", layers.providerModel), + ]; return [ ...tag("session", gate.getSessionApprovals()), ...tag("project", project), - ...tag("global", global), - ...tag("provider-model", providerModel), + ...replaced, ]; }; @@ -73,14 +112,28 @@ export function createPermissionsAdmin( return; } if (entry.scope === "project") { - await removeProjectApproval(cwd, approval); + await removeProjectApproval(cwd, approval, home); + } else if (permissionsPath !== undefined) { + // The file is the active global/provider-model store. Removing the row + // there is what makes the following reseed keep it gone. + if ( + entry.scope === "global" || + (entry.scope === "provider-model" && entry.providerModel !== undefined) + ) { + await removePermissionsFileApproval(permissionsPath, approval); + } } else if (entry.scope === "global") { - await removeGlobalApproval(approval); + if (home === undefined) await removeGlobalApproval(approval); + else await removeGlobalApproval(approval, home); } else if ( entry.scope === "provider-model" && entry.providerModel !== undefined ) { - await removeProviderModelApproval(entry.providerModel, approval); + if (home === undefined) { + await removeProviderModelApproval(entry.providerModel, approval); + } else { + await removeProviderModelApproval(entry.providerModel, approval, home); + } } await reseed(); }; diff --git a/src/permission/store.ts b/src/permission/store.ts index 4d68b0440..ac4c3aa5f 100644 --- a/src/permission/store.ts +++ b/src/permission/store.ts @@ -64,12 +64,30 @@ const ApprovalSchema = type({ ctx.mustBe("a pattern with a literal floor (not only wildcards)"), ); -function parseApprovalList(raw: unknown): Approval[] { - if (!Array.isArray(raw)) return []; +function parseApprovalList(raw: unknown, strictPath?: string): Approval[] { + if (!Array.isArray(raw)) { + if (strictPath !== undefined) { + throw new Error( + `--permissions file must contain an approvals array: ${strictPath}`, + ); + } + return []; + } const out: Approval[] = []; - for (const entry of raw) { - const result = ApprovalSchema(entry); - if (!(result instanceof type.errors)) out.push(result); + for (let index = 0; index < raw.length; index++) { + const result = ApprovalSchema(raw[index]); + if (result instanceof type.errors) { + // Persisted stores drop a poisoned entry. A --permissions file is the + // whole grant list for this process, so dropping one would silently + // change what the caller asked to allow. + if (strictPath !== undefined) { + throw new Error( + `--permissions file ${strictPath} approvals[${index}] is invalid: ${result.summary}`, + ); + } + continue; + } + out.push(result); } return out; } @@ -278,6 +296,95 @@ export async function loadProviderModelApprovals( return out; } +// A --permissions file replaces the user grant list for one process. Missing, +// unreadable, and invalid files must fail closed: the persisted-store readers +// treat ENOENT as an empty list and drop bad entries, which would silently +// change the caller's grant set. +export async function loadPermissionsFile(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, "utf-8"); + } catch (err) { + if (isEnoent(err)) { + throw new Error(`--permissions file not found: ${path}`); + } + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`--permissions file is unreadable: ${path} (${detail})`); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`--permissions file is invalid JSON: ${path} (${detail})`); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`--permissions file must be a JSON object: ${path}`); + } + const approvals = (parsed as Record).approvals; + if (approvals === undefined) return []; + return parseApprovalList(approvals, path); +} + +// The process grant list is either the --permissions file or the user stores. +// Callers that reseed after /permissions must use this and not reach around it: +// reloading ~/.corbits/permissions.json puts the user's grants back, and +// removing a listed row from those stores deletes them on disk. +export type ProcessGrantLayers = + | { source: "file"; approvals: Approval[] } + | { source: "user"; global: Approval[]; providerModel: Approval[] }; + +export async function loadProcessGrantLayers( + permissionsPath: string | undefined, + home?: string, +): Promise { + if (permissionsPath !== undefined) { + return { + source: "file", + approvals: await loadPermissionsFile(permissionsPath), + }; + } + const [global, providerModel] = await Promise.all([ + home === undefined ? loadGlobalApprovals() : loadGlobalApprovals(home), + home === undefined + ? loadProviderModelApprovals() + : loadProviderModelApprovals(home), + ]); + return { source: "user", global, providerModel }; +} + +export function flattenProcessGrantLayers( + layers: ProcessGrantLayers, +): Approval[] { + return layers.source === "file" + ? layers.approvals + : [...layers.global, ...layers.providerModel]; +} + +// --permissions is one approvals array. Provider-model scope is a field on +// the entry, not the user store's providerModels map. +export async function appendPermissionsFileApproval( + path: string, + approval: Approval, +): Promise { + return chainObjectWrite(path, (current) => ({ + ...current, + approvals: [...parseApprovalList(current.approvals), approval], + })); +} + +export async function removePermissionsFileApproval( + path: string, + target: Approval, +): Promise { + return chainObjectWrite(path, (current) => ({ + ...current, + approvals: parseApprovalList(current.approvals).filter( + (entry) => !sameApproval(entry, target), + ), + })); +} + export async function saveGlobalApproval( approval: Approval, home: string = homedir(), diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index d4e0065ba..4edfa320b 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -270,6 +270,11 @@ export interface SessionGateArgs { /** Route this gate's decisions through the reactor authz seam (main session). */ reactorGated: boolean; onGrant?: PermissionGateOptions["onGrant"] | undefined; + /** + * `--permissions` file. When set, its approvals replace global and + * provider-model grants for this process. + */ + permissionsPath?: string | undefined; } export interface SessionGate { @@ -278,7 +283,8 @@ export interface SessionGate { } /** - * Seed approvals (session → project → global → provider-model) and build the + * Seed approvals (session → project → global → provider-model, or the + * --permissions file in place of those last two layers) and build the * permission gate over roots, persist, and log wiring. Runners differ only * in the operator channel (modal vs stdin); approval arrives as a callback. */ @@ -289,7 +295,10 @@ export async function assembleSessionGate( args.cwd, args.sessionId, undefined, - { onPendingProjectGrants: args.onPendingProjectGrants }, + { + onPendingProjectGrants: args.onPendingProjectGrants, + permissionsPath: args.permissionsPath, + }, ); const gate = createPermissionGate({ approvals: seededApprovals, @@ -304,6 +313,7 @@ export async function assembleSessionGate( args.cwd, args.getActiveProviderModel, args.onPersistNotice, + args.permissionsPath, ), approvalLog: createApprovalLog(sessionDir(args.cwd, args.sessionId)), interactive: args.interactive, diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index 7106ede80..f7ed2abe8 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -7,13 +7,13 @@ import { spyOn, test, } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getLogger } from "@intx/log"; import type { ConversationTurn, ToolCall } from "@intx/types/runtime"; -import { LOG_NAMESPACE_ROOT } from "../branding.js"; +import { LOG_NAMESPACE_ROOT, SETTINGS_DIR_NAME } from "../branding.js"; import * as permissionStore from "../permission/store.js"; import { createPermissionGate } from "../permission/gate.js"; import type { GrantScope } from "../permission/types.js"; @@ -229,6 +229,188 @@ describe("loadSeededApprovals merge order", () => { }); }); +describe("loadSeededApprovals --permissions replacement", () => { + let cwd = ""; + let home = ""; + let sessionId = ""; + + afterEach(async () => { + if (cwd !== "") await rm(cwd, { recursive: true, force: true }); + if (home !== "") await rm(home, { recursive: true, force: true }); + cwd = ""; + home = ""; + sessionId = ""; + }); + + async function setup(): Promise { + cwd = await mkdtemp(join(tmpdir(), "perm-flag-")); + home = await mkdtemp(join(tmpdir(), "perm-flag-home-")); + sessionId = generateSessionId(); + await initSessionDir(cwd, sessionId, home); + } + + test("seeds the file grant and ignores a grant that exists only in the user global file", async () => { + await setup(); + await permissionStore.saveGlobalApproval( + { tool: "run_shell", pattern: "global-only git *" }, + home, + ); + await permissionStore.saveProviderModelApproval( + "openai:gpt-5", + { tool: "run_shell", pattern: "provider-only npm *" }, + home, + ); + await mkdir(sessionDir(cwd, sessionId, home), { recursive: true }); + await writeFile( + join(sessionDir(cwd, sessionId, home), "permissions.json"), + JSON.stringify({ + approvals: [{ tool: "run_shell", pattern: "session keep *" }], + }), + ); + await permissionStore.saveProjectApproval( + cwd, + { tool: "run_shell", pattern: "project keep *" }, + home, + ); + const projectPath = join(cwd, ".corbits", "permissions.json"); + const projectFile = JSON.parse(await readFile(projectPath, "utf-8")) as { + approvals: { tool: string; pattern: string }[]; + }; + projectFile.approvals.push({ + tool: "run_shell", + pattern: "project unconfirmed *", + }); + await writeFile(projectPath, JSON.stringify(projectFile)); + + const permissionsPath = join(cwd, "grants.json"); + await writeFile( + permissionsPath, + JSON.stringify({ + approvals: [ + { tool: "run_shell", pattern: "bun install*" }, + { + tool: "run_shell", + pattern: "scoped cargo *", + providerModel: "openai:gpt-5", + }, + ], + }), + ); + + const without = await loadSeededApprovals(cwd, sessionId, home); + expect(without).toEqual( + expect.arrayContaining([ + { tool: "run_shell", pattern: "global-only git *" }, + { + tool: "run_shell", + pattern: "provider-only npm *", + providerModel: "openai:gpt-5", + }, + ]), + ); + + const seeded = await loadSeededApprovals(cwd, sessionId, home, { + permissionsPath, + }); + expect(seeded).toEqual([ + { tool: "run_shell", pattern: "session keep *" }, + { tool: "run_shell", pattern: "project keep *" }, + { tool: "run_shell", pattern: "bun install*" }, + { + tool: "run_shell", + pattern: "scoped cargo *", + providerModel: "openai:gpt-5", + }, + ]); + expect( + seeded.some((approval) => approval.pattern === "global-only git *"), + ).toBe(false); + expect( + seeded.some((approval) => approval.pattern === "provider-only npm *"), + ).toBe(false); + expect( + seeded.some((approval) => approval.pattern === "project unconfirmed *"), + ).toBe(false); + }); + + test("an empty approvals list replaces global grants instead of merging them", async () => { + await setup(); + await permissionStore.saveGlobalApproval( + { tool: "run_shell", pattern: "global-only git *" }, + home, + ); + const permissionsPath = join(cwd, "empty.json"); + await writeFile(permissionsPath, JSON.stringify({ approvals: [] })); + + const seeded = await loadSeededApprovals(cwd, sessionId, home, { + permissionsPath, + }); + + expect(seeded).toEqual([]); + }); + + test("a missing approvals key is an empty replacement and ignores providerModels", async () => { + await setup(); + await permissionStore.saveGlobalApproval( + { tool: "run_shell", pattern: "global-only git *" }, + home, + ); + const permissionsPath = join(cwd, "no-approvals.json"); + await writeFile( + permissionsPath, + JSON.stringify({ + providerModels: { + "openai:gpt-5": [{ tool: "run_shell", pattern: "hidden npm *" }], + }, + }), + ); + + const seeded = await loadSeededApprovals(cwd, sessionId, home, { + permissionsPath, + }); + expect(seeded).toEqual([]); + + await writeFile( + permissionsPath, + JSON.stringify({ + approvals: [{ tool: "run_shell", pattern: "bun install*" }], + providerModels: { + "openai:gpt-5": [{ tool: "run_shell", pattern: "hidden npm *" }], + }, + }), + ); + const withApprovals = await loadSeededApprovals(cwd, sessionId, home, { + permissionsPath, + }); + expect(withApprovals).toEqual([ + { tool: "run_shell", pattern: "bun install*" }, + ]); + }); + + test("a missing permissions file fails", async () => { + await setup(); + await expect( + loadSeededApprovals(cwd, sessionId, home, { + permissionsPath: join(cwd, "missing.json"), + }), + ).rejects.toThrow(/not found/); + }); + + test("a wildcard-only pattern fails", async () => { + await setup(); + const permissionsPath = join(cwd, "wild.json"); + await writeFile( + permissionsPath, + JSON.stringify({ + approvals: [{ tool: "run_shell", pattern: "*" }], + }), + ); + await expect( + loadSeededApprovals(cwd, sessionId, home, { permissionsPath }), + ).rejects.toThrow(/literal floor/); + }); +}); + describe("createApprovalPersist", () => { const persistLogger = getLogger([LOG_NAMESPACE_ROOT, "session", "approvals"]); @@ -292,6 +474,124 @@ describe("createApprovalPersist", () => { ); }); + test("Allow Always with --permissions appends to the file and does not touch the user store", async () => { + const cwd = await mkdtemp(join(tmpdir(), "perm-persist-")); + const home = await mkdtemp(join(tmpdir(), "perm-persist-home-")); + try { + await permissionStore.saveGlobalApproval( + { tool: "run_shell", pattern: "global-only git *" }, + home, + ); + await permissionStore.saveProviderModelApproval( + "openai:gpt-5", + { tool: "run_shell", pattern: "provider-only npm *" }, + home, + ); + const permissionsPath = join(cwd, "grants.json"); + await writeFile( + permissionsPath, + JSON.stringify({ + approvals: [{ tool: "run_shell", pattern: "bun install*" }], + }), + ); + const userStorePath = join(home, SETTINGS_DIR_NAME, "permissions.json"); + const userStoreBefore = await readFile(userStorePath, "utf-8"); + + const userWrites: Promise[] = []; + const originalAppend = permissionStore.appendPermissionsFileApproval; + const originalProject = permissionStore.saveProjectApproval; + const appends: Promise[] = []; + const projects: Promise[] = []; + spyOn(permissionStore, "saveGlobalApproval").mockImplementation(() => { + const write = Promise.reject( + new Error("user global store must not be mutated"), + ); + userWrites.push(write); + return write; + }); + spyOn(permissionStore, "saveProviderModelApproval").mockImplementation( + () => { + const write = Promise.reject( + new Error("user provider-model store must not be mutated"), + ); + userWrites.push(write); + return write; + }, + ); + spyOn( + permissionStore, + "appendPermissionsFileApproval", + ).mockImplementation((path, approval) => { + const write = originalAppend(path, approval); + appends.push(write); + return write; + }); + spyOn(permissionStore, "saveProjectApproval").mockImplementation( + (projectCwd, approval, projectHome) => { + const write = originalProject(projectCwd, approval, projectHome); + projects.push(write); + return write; + }, + ); + + const persist = createApprovalPersist( + cwd, + () => "openai:gpt-5", + undefined, + permissionsPath, + ); + persist( + { + tool: "run_shell", + pattern: "bun test*", + providerModel: "stale:model", + }, + "global", + ); + persist( + { tool: "run_shell", pattern: "npm *", providerModel: "stale:model" }, + "provider-model", + ); + persist({ tool: "write_file", pattern: "src/*" }, "project"); + persist({ tool: "run_shell", pattern: "session only *" }, "session"); + + await Promise.all([...appends, ...projects]); + await Promise.allSettled(userWrites); + + const file = JSON.parse(await readFile(permissionsPath, "utf-8")) as { + approvals: { + tool: string; + pattern: string; + providerModel?: string; + }[]; + providerModels?: unknown; + }; + expect(file.providerModels).toBeUndefined(); + expect(file.approvals).toEqual([ + { tool: "run_shell", pattern: "bun install*" }, + { tool: "run_shell", pattern: "bun test*" }, + { + tool: "run_shell", + pattern: "npm *", + providerModel: "openai:gpt-5", + }, + ]); + expect(await readFile(userStorePath, "utf-8")).toBe(userStoreBefore); + const projectFile = JSON.parse( + await readFile( + join(cwd, SETTINGS_DIR_NAME, "permissions.json"), + "utf-8", + ), + ) as { approvals: { tool: string; pattern: string }[] }; + expect(projectFile.approvals).toEqual([ + expect.objectContaining({ tool: "write_file", pattern: "src/*" }), + ]); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + } + }); + const persistedScopes: { scope: Exclude; reject: (message: string) => void; diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index 97d1762f0..6804c9efe 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -39,11 +39,12 @@ import { isPluginModuleEnabled } from "../plugins/register.js"; import { migratePersistedApprovalStores } from "../permission/approval-store-migration.js"; import { formatPendingProjectApprovals, + flattenProcessGrantLayers, + appendPermissionsFileApproval, loadApprovals, - loadGlobalApprovals, loadPendingProjectApprovals, + loadProcessGrantLayers, loadProjectApprovals, - loadProviderModelApprovals, saveGlobalApproval, saveProjectApproval, saveProviderModelApproval, @@ -137,12 +138,19 @@ export function createLiveSubAgentSources( // 2. Permission approvals + persist callback // --------------------------------------------------------------------------- -/** Session → project → global → provider-model merge order (first match wins in gate). */ +/** Session → project → global → provider-model (first match wins in the gate). */ export async function loadSeededApprovals( cwd: string, sessionId: string, home?: string, - opts?: { onPendingProjectGrants?: ((text: string) => void) | undefined }, + opts?: { + onPendingProjectGrants?: ((text: string) => void) | undefined; + /** + * Process-only grant file. When set, its approvals replace the global + * and provider-model layers and are not merged with the user file. + */ + permissionsPath?: string | undefined; + }, ): Promise { // One-time migration: purge persisted update_plan keys (dropped at load but // never rewritten) so removing the normalizer later cannot resurrect them. @@ -152,17 +160,13 @@ export async function loadSeededApprovals( await migratePersistedApprovalStores(cwd, sessionId, home); } catch {} const sessionApprovals = await loadApprovals(cwd, sessionId, home); - const [ - projectApprovals, - globalApprovals, - providerModelApprovals, - pendingProjectApprovals, - ] = await Promise.all([ - loadProjectApprovals(cwd, home), - loadGlobalApprovals(), - loadProviderModelApprovals(), - loadPendingProjectApprovals(cwd, home), - ]); + const permissionsPath = opts?.permissionsPath; + const [projectApprovals, grantLayers, pendingProjectApprovals] = + await Promise.all([ + loadProjectApprovals(cwd, home), + loadProcessGrantLayers(permissionsPath, home), + loadPendingProjectApprovals(cwd, home), + ]); // First encounter with an unconfirmed project approvals file: show what it // would grant. The entries stay gated; the notice is the only trace. const pendingNotice = formatPendingProjectApprovals(pendingProjectApprovals); @@ -177,8 +181,7 @@ export async function loadSeededApprovals( return [ ...sessionApprovals, ...projectApprovals, - ...globalApprovals, - ...providerModelApprovals, + ...flattenProcessGrantLayers(grantLayers), ]; } @@ -212,29 +215,50 @@ function persistBestEffort( * Route a gate-persisted grant to the store its scope selects. Session * grants never reach here — the gate keeps those in memory only. * `getActiveProviderModel` is read at persist time so a live model switch - * stores under the pair in use. + * stores under the pair in use. When `permissionsPath` is set, global and + * provider-model grants append to that file instead of the user store. */ export function createApprovalPersist( cwd: string, getActiveProviderModel: () => string, onPersistFailure?: ((text: string) => void) | undefined, + permissionsPath?: string, ): (approval: Approval, scope: GrantScope) => void { return (approval: Approval, scope: GrantScope) => { - if (scope === "project") + if (scope === "project") { persistBestEffort( scope, saveProjectApproval(cwd, approval), onPersistFailure, ); - else if (scope === "global") - persistBestEffort(scope, saveGlobalApproval(approval), onPersistFailure); - else if (scope === "provider-model") { + return; + } + if (scope !== "global" && scope !== "provider-model") return; + if (permissionsPath !== undefined) { + const stored: Approval = + scope === "provider-model" + ? { + tool: approval.tool, + pattern: approval.pattern, + providerModel: getActiveProviderModel(), + } + : { tool: approval.tool, pattern: approval.pattern }; persistBestEffort( scope, - saveProviderModelApproval(getActiveProviderModel(), approval), + appendPermissionsFileApproval(permissionsPath, stored), onPersistFailure, ); + return; } + if (scope === "global") { + persistBestEffort(scope, saveGlobalApproval(approval), onPersistFailure); + return; + } + persistBestEffort( + scope, + saveProviderModelApproval(getActiveProviderModel(), approval), + onPersistFailure, + ); }; } diff --git a/src/tui/onboarding.ts b/src/tui/onboarding.ts index 1f67d2c36..60d51189a 100644 --- a/src/tui/onboarding.ts +++ b/src/tui/onboarding.ts @@ -72,6 +72,8 @@ export async function runOnboarding( const argv: string[] = ["--cwd", config.cwd]; if (config.cliConfigPath !== undefined) argv.push("--config", config.cliConfigPath); + if (config.permissionsPath !== undefined) + argv.push("--permissions", config.permissionsPath); if (config.dangerouslySkipPermissions) argv.push("--dangerously-skip-permissions"); if (config.task.length > 0) argv.push(config.task); diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 7d5c039b0..0fb0fc368 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -225,11 +225,14 @@ export async function assembleTUISession( auto: config.auto, // Main session: gating rides the reactor's approval-suspend seam. reactorGated: true, + permissionsPath: config.permissionsPath, onGrant: (approval, covers) => emitter.emit("permission.grant", { approval, covers }), }); - const permissionsAdmin = createPermissionsAdmin(permissionGate, config.cwd); + const permissionsAdmin = createPermissionsAdmin(permissionGate, config.cwd, { + permissionsPath: config.permissionsPath, + }); // Derive the subagent provider from the live config binding on every spawn // so a /agent switch (provider, model, reasoning effort) reaches subagents