Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
168 changes: 167 additions & 1 deletion src/plugins/secret-guard-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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<ToolResult> => ({
callId: call.id,
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"),
Expand All @@ -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",
Expand Down Expand Up @@ -241,3 +302,108 @@ describe("secretGuardPlugin run_shell", () => {
expect(result.content).toBe("ok");
});
});

describe("auto-mode project grant store", () => {
async function withAutoTools<T>(
run: (args: {
cwd: string;
tools: ReturnType<typeof createPosixTools>;
}) => Promise<T>,
): Promise<T> {
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);
});
});
});
14 changes: 13 additions & 1 deletion src/plugins/secret-guard-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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\//,
Expand Down Expand Up @@ -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);
},
};
Expand Down
Loading