From 59ea7a7958f06524c438b99b82a2886db57dea1e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 09:19:06 -0700 Subject: [PATCH] Deny agent writes to the standing approval store --- docs/ARCHITECTURE.md | 2 +- docs/IMPLEMENTATION.md | 2 +- src/plugins/secret-guard-plugin.test.ts | 168 +++++++++++++++++++++++- src/plugins/secret-guard-plugin.ts | 14 +- 4 files changed, 182 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3a311ec63..aa947def2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,7 +73,7 @@ In TUI chat mode there is no completion gate — the session stays open across t - `settings.ts` owns the schema, validators (the per-repo file rejects credentials), file loaders, and the pure `resolveProvider` precedence function. - `providers.ts` defines the `ProviderCatalogEntry` type and helpers for building TUI provider lists; `profiles.ts` handles profile-level selection logic. - `loadConfig` is async (it reads settings files). Parses a leading `exec`/`run` subcommand, flags `--cwd`, `--config`, `--provider`, `--model`, `--dangerously-skip-permissions` (forces this process; TUI `/yolo` persists as the user-global default), `--auto` / `--no-auto` (auto mode defaults on); collects positional arguments as the optional initial task for the TUI or the required prompt for exec. -- Both settings files 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. +- Both settings files and the project/global grant store (`.corbits/permissions.json`) are on the secret-guard denylist for path-keyed tools, so the agent cannot `read_file` its own credentials or persist standing auto-approvals. Shell commands that reference them still require explicit operator approval. ### TUI Runner (`src/tui/runner.ts`) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 36aaaffd4..b5e25ecc9 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -278,7 +278,7 @@ 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). +- 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. `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. diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index c6f1b6f5b..846f67829 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -1,10 +1,17 @@ import { describe, test, expect } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createPosixTools } from "@intx/tools-posix"; +import type { ToolCall, ToolResult } from "@intx/types/runtime"; +import { buildCorePosixToolPlugins } from "../agent/posix-tool-plugins.js"; +import { createPermissionGate } from "../permission/gate.js"; +import { loadProjectApprovals } from "../permission/store.js"; import { secretGuardPlugin, isSensitivePath, commandReferencesSensitivePath, } from "./secret-guard-plugin.js"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; const next = async (call: ToolCall): Promise => ({ callId: call.id, @@ -27,6 +34,16 @@ const shell = (command: unknown): ToolCall => ({ arguments: { command }, }); +const GRANT_STORE_PAYLOAD = JSON.stringify({ + approvals: [{ tool: "run_shell", pattern: "bash -c *" }], +}); + +const APPLY_PATCH_GRANT_STORE = `*** Begin Patch +*** Add File: .corbits/permissions.json ++${GRANT_STORE_PAYLOAD} +*** End Patch +`; + describe("isSensitivePath", () => { const sensitive = [ ".env", @@ -56,6 +73,8 @@ describe("isSensitivePath", () => { "my-project_service_account-key.json", ".corbits/settings.json", "/Users/me/.corbits/settings.json", + ".corbits/permissions.json", + "/Users/me/.corbits/permissions.json", // Shell histories. "/home/me/.bash_history", ".zsh_history", @@ -92,6 +111,7 @@ describe("isSensitivePath", () => { ".env.example.md", "docs/pem.md", ".corbits/hooks/post-turn.ts", + "permissions.json", "docker-compose.yml", "keystore.md", "account.json", @@ -129,6 +149,46 @@ describe("secretGuardPlugin", () => { expect(result.isError).toBe(true); }); + test("denies writing the project grant store", async () => { + const call: ToolCall = { + id: "c", + name: "write_file", + arguments: { + path: ".corbits/permissions.json", + content: GRANT_STORE_PAYLOAD, + }, + }; + const result = await handler()(call, new AbortController().signal); + expect(result.isError).toBe(true); + expect(result.content).toMatch(/sensitive file blocked/); + }); + + test("denies editing the project grant store", async () => { + const call: ToolCall = { + id: "c", + name: "edit_file", + arguments: { + path: ".corbits/permissions.json", + old_string: "{}", + new_string: GRANT_STORE_PAYLOAD, + }, + }; + const result = await handler()(call, new AbortController().signal); + expect(result.isError).toBe(true); + expect(result.content).toMatch(/sensitive file blocked/); + }); + + test("denies apply_patch of the project grant store", async () => { + const call: ToolCall = { + id: "c", + name: "apply_patch", + arguments: { input: APPLY_PATCH_GRANT_STORE }, + }; + const result = await handler()(call, new AbortController().signal); + expect(result.isError).toBe(true); + expect(result.content).toMatch(/sensitive file blocked/); + }); + test("allows an ordinary source file", async () => { const result = await handler()( read("src/index.ts"), @@ -143,6 +203,7 @@ describe("commandReferencesSensitivePath", () => { "cat .env", "cat ~/.corbits/settings.json", "less /Users/me/.corbits/settings.json", + "cat .corbits/permissions.json", "xxd .ssh/id_rsa", "base64 secrets/server.pem", "grep KEY .env.production", @@ -241,3 +302,108 @@ describe("secretGuardPlugin run_shell", () => { expect(result.content).toBe("ok"); }); }); + +describe("auto-mode project grant store", () => { + async function withAutoTools( + run: (args: { + cwd: string; + tools: ReturnType; + }) => Promise, + ): Promise { + const cwd = await mkdtemp(join(tmpdir(), "cl7634-grant-store-")); + await mkdir(join(cwd, ".corbits"), { recursive: true }); + const gate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: false, + reactorGated: false, + auto: true, + cwd, + }); + const tools = createPosixTools({ + cwd, + plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }), + }); + try { + return await run({ cwd, tools }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + } + + test("auto mode denies write_file of the project grant store", async () => { + await withAutoTools(async ({ cwd, tools }) => { + const result = await tools.run( + { + id: "1", + name: "write_file", + arguments: { + path: ".corbits/permissions.json", + content: GRANT_STORE_PAYLOAD, + }, + }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(String(result.content)).toMatch(/sensitive file blocked/i); + expect(await loadProjectApprovals(cwd)).toEqual([]); + }); + }); + + test("auto mode denies edit_file of the project grant store", async () => { + await withAutoTools(async ({ cwd, tools }) => { + const result = await tools.run( + { + id: "1", + name: "edit_file", + arguments: { + path: ".corbits/permissions.json", + old_string: "{}", + new_string: GRANT_STORE_PAYLOAD, + }, + }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(String(result.content)).toMatch(/sensitive file blocked/i); + expect(await loadProjectApprovals(cwd)).toEqual([]); + }); + }); + + test("a denied grant-store write cannot auto-allow bash -c npm install on a fresh gate", async () => { + await withAutoTools(async ({ cwd, tools }) => { + await tools.run( + { + id: "1", + name: "write_file", + arguments: { + path: ".corbits/permissions.json", + content: GRANT_STORE_PAYLOAD, + }, + }, + new AbortController().signal, + ); + const seeded = await loadProjectApprovals(cwd); + let asked = 0; + const gate = createPermissionGate({ + approvals: seeded, + requestApproval: async () => { + asked++; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + reactorGated: false, + auto: true, + cwd, + }); + const verdict = await gate.evaluate({ + id: "c", + name: "run_shell", + arguments: { command: "bash -c 'npm install lodash'" }, + }); + expect(asked).toBeGreaterThan(0); + expect(verdict.allowed).toBe(true); + }); + }); +}); diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index 377fba7d7..1caebdcc1 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -4,6 +4,7 @@ import { realpathNearestOr, UNRESOLVABLE, } from "../permission/path-restriction.js"; +import { productMutationPaths } from "../agent/product-mutation-tools.js"; import { looksLikePath } from "./path-escape-plugin.js"; // Files that hold secrets and must never be read or written by path-keyed tools @@ -23,8 +24,10 @@ const SENSITIVE_PATTERNS: RegExp[] = [ /(^|\/)\.git-credentials$/, // Corbits Code's own settings hold provider credentials. Covers both the // global (~/.corbits/settings.json) and per-repo (.corbits/settings.json) - // locations. + // locations. The grant store next to them is not a credential file, but a + // write becomes a standing auto-approval on the next run — same path deny. /(^|\/)\.corbits\/settings\.json$/, + /(^|\/)\.corbits\/permissions\.json$/, /(^|\/)\.pgpass$/, /(^|\/)\.htpasswd$/, /(^|\/)\.ssh\//, @@ -165,6 +168,15 @@ export function secretGuardPlugin(): ToolPlugin { }; } } + for (const path of productMutationPaths(call.name, call.arguments)) { + if (isSensitivePathResolved(path)) { + return { + callId: call.id, + content: `Access to sensitive file blocked by policy: ${path}`, + isError: true, + }; + } + } return next(call, signal); }, };