Skip to content

Commit b125da5

Browse files
committed
docs(permissions): clarify project-layer scope and log canonical grant-file path
1 parent c63783c commit b125da5

4 files changed

Lines changed: 131 additions & 7 deletions

File tree

‎docs/IMPLEMENTATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ Provider and model configuration lives in JSON settings files. The global file h
321321

322322
- 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.
323323

324-
`corbits exec --permissions <path>` 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.
324+
`corbits exec --permissions <path>` 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. Every load (startup validation, seed, `/permissions` reseed) logs the canonical realpath target and approval count.
325325

326326
`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.
327327

‎src/config/index.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,8 @@ export interface Config {
622622
/**
623623
* Absolute path from `--permissions`. When set, that file's approvals
624624
* replace the user-global and provider-model grant lists for this process
625-
* only. Not a settings key.
625+
* only. Session and project layers still apply, and project-file entries
626+
* still need trust confirmation. Not a settings key.
626627
*/
627628
permissionsPath?: string;
628629
globalDefaultProvider?: string;
@@ -733,7 +734,9 @@ Continue verbs (project-keyed to this checkout's git toplevel):
733734
Flags:
734735
--cwd <dir> working directory (default: process.cwd())
735736
--config <path> settings file (default: ~/.corbits/settings.json)
736-
--permissions <path> grant file replacing ~/.corbits/permissions.json for this process
737+
--permissions <path> grant file replacing global + provider-model grants
738+
for this process only (session + project layers still
739+
apply; project entries still need trust confirmation)
737740
--provider <name> configured provider name
738741
--model <id> model for the active provider
739742
--profile <name> settings profile
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {
2+
afterEach,
3+
beforeEach,
4+
describe,
5+
expect,
6+
mock,
7+
spyOn,
8+
test,
9+
} from "bun:test";
10+
import { mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
11+
import { tmpdir } from "node:os";
12+
import { join } from "node:path";
13+
import { getLogger } from "@intx/log";
14+
15+
import { LOG_NAMESPACE_ROOT } from "../branding.js";
16+
import { canonicalGrantFilePath, loadPermissionsFile } from "./store.js";
17+
18+
const grantsFileLog = getLogger([
19+
LOG_NAMESPACE_ROOT,
20+
"permission",
21+
"grants-file",
22+
]);
23+
24+
describe("grant-file canonical logging", () => {
25+
let dir = "";
26+
27+
beforeEach(() => {
28+
spyOn(grantsFileLog, "info");
29+
});
30+
31+
afterEach(async () => {
32+
mock.restore();
33+
if (dir !== "") await rm(dir, { recursive: true, force: true });
34+
dir = "";
35+
});
36+
37+
test("loading through a symlink logs the canonical realpath target", async () => {
38+
dir = await mkdtemp(join(tmpdir(), "grants-file-"));
39+
const target = join(dir, "grants.json");
40+
await writeFile(
41+
target,
42+
JSON.stringify({
43+
approvals: [
44+
{ tool: "run_shell", pattern: "bun install*" },
45+
{
46+
tool: "run_shell",
47+
pattern: "scoped cargo *",
48+
providerModel: "openai:gpt-5",
49+
},
50+
],
51+
}),
52+
);
53+
const link = join(dir, "link.json");
54+
await symlink(target, link);
55+
56+
const approvals = await loadPermissionsFile(link);
57+
58+
expect(approvals).toHaveLength(2);
59+
expect(grantsFileLog.info).toHaveBeenCalledWith(
60+
"Grant file loaded: {path} ({count} approvals)",
61+
{ path: await realpath(link), count: 2 },
62+
);
63+
});
64+
65+
test("a file without an approvals key logs an empty load", async () => {
66+
dir = await mkdtemp(join(tmpdir(), "grants-file-"));
67+
const path = join(dir, "grants.json");
68+
await writeFile(path, JSON.stringify({}));
69+
70+
const approvals = await loadPermissionsFile(path);
71+
72+
expect(approvals).toEqual([]);
73+
expect(grantsFileLog.info).toHaveBeenCalledWith(
74+
"Grant file loaded: {path} ({count} approvals)",
75+
{ path: await realpath(path), count: 0 },
76+
);
77+
});
78+
79+
test("a missing file fails closed without a load log", async () => {
80+
dir = await mkdtemp(join(tmpdir(), "grants-file-"));
81+
82+
await expect(
83+
loadPermissionsFile(join(dir, "missing.json")),
84+
).rejects.toThrow(/not found/);
85+
expect(grantsFileLog.info).not.toHaveBeenCalled();
86+
});
87+
88+
test("canonicalGrantFilePath falls back to the given path when unreadable", async () => {
89+
dir = await mkdtemp(join(tmpdir(), "grants-file-"));
90+
const missing = join(dir, "missing.json");
91+
92+
await expect(canonicalGrantFilePath(missing)).resolves.toBe(missing);
93+
});
94+
});

‎src/permission/store.ts‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { homedir } from "node:os";
22
import { join } from "node:path";
3-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3+
import { mkdir, readFile, realpath, rename, writeFile } from "node:fs/promises";
44
import { dirname } from "node:path";
55

66
import { type } from "arktype";
7+
import { getLogger } from "@intx/log";
78

89
import type { Approval } from "./types.js";
910
import { sessionDir } from "../session/index.js";
10-
import { SETTINGS_DIR_NAME } from "../branding.js";
11+
import { LOG_NAMESPACE_ROOT, SETTINGS_DIR_NAME } from "../branding.js";
1112
import {
1213
isProjectGrantTrusted,
1314
reconcileProjectGrants,
@@ -300,6 +301,24 @@ export async function loadProviderModelApprovals(
300301
// unreadable, and invalid files must fail closed: the persisted-store readers
301302
// treat ENOENT as an empty list and drop bad entries, which would silently
302303
// change the caller's grant set.
304+
const grantsFileLog = getLogger([
305+
LOG_NAMESPACE_ROOT,
306+
"permission",
307+
"grants-file",
308+
]);
309+
310+
// The canonical grant-file target for logs: a symlinked --permissions path
311+
// and its target must not read as two different grant sources. Falls back to
312+
// the given path when the file is missing or unreadable — the load below
313+
// still fails closed with its own error.
314+
export async function canonicalGrantFilePath(path: string): Promise<string> {
315+
try {
316+
return await realpath(path);
317+
} catch {
318+
return path;
319+
}
320+
}
321+
303322
export async function loadPermissionsFile(path: string): Promise<Approval[]> {
304323
let raw: string;
305324
try {
@@ -322,8 +341,16 @@ export async function loadPermissionsFile(path: string): Promise<Approval[]> {
322341
throw new Error(`--permissions file must be a JSON object: ${path}`);
323342
}
324343
const approvals = (parsed as Record<string, unknown>).approvals;
325-
if (approvals === undefined) return [];
326-
return parseApprovalList(approvals, path);
344+
// Startup, seed, and every /permissions reseed funnel through here, so one
345+
// log line names the active grant source for the whole process lifetime.
346+
const canonicalPath = await canonicalGrantFilePath(path);
347+
const list =
348+
approvals === undefined ? [] : parseApprovalList(approvals, path);
349+
grantsFileLog.info("Grant file loaded: {path} ({count} approvals)", {
350+
path: canonicalPath,
351+
count: list.length,
352+
});
353+
return list;
327354
}
328355

329356
// The process grant list is either the --permissions file or the user stores.

0 commit comments

Comments
 (0)