Skip to content

Commit c63783c

Browse files
Merge pull request #1470 from corbitsdev/cl-10749-accept-a-permissions-file-on-corbits-exec-that-replaces-the
feat(permissions): replace global grants with a process file
2 parents 1b7026b + 6692a8c commit c63783c

12 files changed

Lines changed: 854 additions & 70 deletions

File tree

‎docs/IMPLEMENTATION.md‎

Lines changed: 24 additions & 21 deletions
Large diffs are not rendered by default.

‎src/config.test.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,8 @@ describe("loadConfig", () => {
970970
[["--cwd", "-h"]],
971971
[["--config", "--help"]],
972972
[["--config", "-h"]],
973+
[["--permissions", "--help"]],
974+
[["--permissions", "-h"]],
973975
[["--profile", "--help"]],
974976
[["--profile", "-h"]],
975977
])(
@@ -1007,6 +1009,8 @@ describe("loadConfig", () => {
10071009
[["--cwd", "-V"]],
10081010
[["--config", "--version"]],
10091011
[["--config", "-V"]],
1012+
[["--permissions", "--version"]],
1013+
[["--permissions", "-V"]],
10101014
[["--profile", "--version"]],
10111015
[["--profile", "-V"]],
10121016
])(
@@ -1041,6 +1045,8 @@ describe("loadConfig", () => {
10411045
[["--model"], "--model requires a value"],
10421046
[["--cwd"], "--cwd requires a value"],
10431047
[["--config"], "--config requires a value"],
1048+
[["--permissions"], "--permissions requires a value"],
1049+
[["--permissions", "--auto"], "--permissions requires a value"],
10441050
[["--profile"], "--profile requires a value"],
10451051
] as const)("%j rejects with %s", async (argv, message) => {
10461052
await expect(
@@ -1200,6 +1206,85 @@ describe("loadConfig", () => {
12001206
).rejects.toThrow(/not found or empty/);
12011207
});
12021208

1209+
test("CLI_HELP_TEXT documents --permissions next to --config", () => {
1210+
const configAt = CLI_HELP_TEXT.indexOf("--config <path>");
1211+
const permissionsAt = CLI_HELP_TEXT.indexOf("--permissions <path>");
1212+
expect(configAt).toBeGreaterThanOrEqual(0);
1213+
expect(permissionsAt).toBeGreaterThan(configAt);
1214+
expect(CLI_HELP_TEXT.indexOf("--provider <name>")).toBeGreaterThan(
1215+
permissionsAt,
1216+
);
1217+
});
1218+
1219+
test("--permissions resolves onto config without adding an approvals settings field", async () => {
1220+
const cwd = await emptyCwd();
1221+
const permissionsPath = join(cwd, "grants.json");
1222+
await writeFile(
1223+
permissionsPath,
1224+
JSON.stringify({
1225+
approvals: [
1226+
{
1227+
tool: "run_shell",
1228+
pattern: "bun install*",
1229+
providerModel: "openai:gpt-5",
1230+
},
1231+
],
1232+
}),
1233+
);
1234+
const config = await loadFor(cwd, [
1235+
"exec",
1236+
"--permissions",
1237+
permissionsPath,
1238+
"task",
1239+
]);
1240+
expect(config.command).toBe("exec");
1241+
expect(config.permissionsPath).toBe(resolve(permissionsPath));
1242+
expect(config.settings).not.toHaveProperty("approvals");
1243+
expect(config.dangerouslySkipPermissions).toBe(false);
1244+
});
1245+
1246+
test("--permissions pointing at a missing file fails at start", async () => {
1247+
const cwd = await emptyCwd();
1248+
await expect(
1249+
loadFor(cwd, [
1250+
"exec",
1251+
"--permissions",
1252+
join(cwd, "missing.json"),
1253+
"task",
1254+
]),
1255+
).rejects.toThrow(/--permissions file not found/);
1256+
});
1257+
1258+
test("--permissions invalid JSON fails at start", async () => {
1259+
const cwd = await emptyCwd();
1260+
const permissionsPath = join(cwd, "bad.json");
1261+
await writeFile(permissionsPath, "{");
1262+
await expect(
1263+
loadFor(cwd, ["exec", "--permissions", permissionsPath, "task"]),
1264+
).rejects.toThrow(/invalid JSON/);
1265+
});
1266+
1267+
test("--permissions unreadable path fails at start", async () => {
1268+
const cwd = await emptyCwd();
1269+
await expect(
1270+
loadFor(cwd, ["exec", "--permissions", cwd, "task"]),
1271+
).rejects.toThrow(/unreadable/);
1272+
});
1273+
1274+
test("--permissions wildcard-only pattern fails at start", async () => {
1275+
const cwd = await emptyCwd();
1276+
const permissionsPath = join(cwd, "wild.json");
1277+
await writeFile(
1278+
permissionsPath,
1279+
JSON.stringify({
1280+
approvals: [{ tool: "run_shell", pattern: "*" }],
1281+
}),
1282+
);
1283+
await expect(
1284+
loadFor(cwd, ["exec", "--permissions", permissionsPath, "task"]),
1285+
).rejects.toThrow(/literal floor/);
1286+
});
1287+
12031288
test("--profile flag surfaces profile name and model from project profile.json", async () => {
12041289
const cwd = await emptyCwd();
12051290
await mkdir(join(cwd, ".corbits"), { recursive: true });

‎src/config/index.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import {
103103
type ResolvedMCPServerConfig,
104104
} from "../mcp/exa.js";
105105
import { resolveProfile } from "./profiles.js";
106+
import { loadPermissionsFile } from "../permission/store.js";
106107

107108
// Per-call token ceiling for inference sources, shared by agent creation
108109
// (runner.ts) and live provider switching (the /agent modal) so a live switch
@@ -618,6 +619,12 @@ export interface Config {
618619
command: "tui" | "exec";
619620
/** Active settings source, including an explicit --config path. */
620621
globalSettingsPath: string;
622+
/**
623+
* Absolute path from `--permissions`. When set, that file's approvals
624+
* replace the user-global and provider-model grant lists for this process
625+
* only. Not a settings key.
626+
*/
627+
permissionsPath?: string;
621628
globalDefaultProvider?: string;
622629
// Every provider available to switch to at runtime. From the settings file
623630
// when present; env-only mode has just the single resolved provider.
@@ -691,6 +698,11 @@ export interface UnconfiguredConfig {
691698
globalSettingsPath: string;
692699
/** Original CLI path, present only when --config selected the write target. */
693700
cliConfigPath?: string;
701+
/**
702+
* Absolute path from `--permissions`, replayed after onboarding so the
703+
* process-only grant file is not dropped on the reload.
704+
*/
705+
permissionsPath?: string;
694706
/** Whether the caller requested an OAuth-isolated programmatic settings load. */
695707
programmaticSettingsPath: boolean;
696708
// 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):
721733
Flags:
722734
--cwd <dir> working directory (default: process.cwd())
723735
--config <path> settings file (default: ~/.corbits/settings.json)
736+
--permissions <path> grant file replacing ~/.corbits/permissions.json for this process
724737
--provider <name> configured provider name
725738
--model <id> model for the active provider
726739
--profile <name> settings profile
@@ -875,6 +888,7 @@ export async function loadConfig(
875888
let auto = true;
876889
let director: DirectorId | undefined;
877890
let configPath: string | undefined;
891+
let permissionsPath: string | undefined;
878892
let provider: string | undefined;
879893
let model: string | undefined;
880894
let profileFlag: string | undefined;
@@ -901,6 +915,10 @@ export async function loadConfig(
901915
configPath = resolve(requireValue("--config", args[++i]));
902916
continue;
903917
}
918+
if (arg === "--permissions") {
919+
permissionsPath = resolve(requireValue("--permissions", args[++i]));
920+
continue;
921+
}
904922
if (arg === "--provider") {
905923
provider = requireValue("--provider", args[++i]);
906924
continue;
@@ -979,6 +997,14 @@ export async function loadConfig(
979997
positional.push(arg);
980998
}
981999

1000+
if (permissionsPath !== undefined) {
1001+
try {
1002+
await loadPermissionsFile(permissionsPath);
1003+
} catch (err) {
1004+
throw new CliUserError(err instanceof Error ? err.message : String(err));
1005+
}
1006+
}
1007+
9821008
const pricingCachePath = defaultPricingCachePath();
9831009

9841010
await bootstrapPricingMetadata({
@@ -1103,6 +1129,7 @@ export async function loadConfig(
11031129
: {}),
11041130
globalSettingsPath: effectiveSettingsPath,
11051131
...(configPath !== undefined ? { cliConfigPath: configPath } : {}),
1132+
...(permissionsPath !== undefined ? { permissionsPath } : {}),
11061133
programmaticSettingsPath: options.globalSettingsPath !== undefined,
11071134
providerError: err instanceof Error ? err.message : String(err),
11081135
// Keep diagnostics when provider setup fails early so junk local files
@@ -1197,6 +1224,7 @@ export async function loadConfig(
11971224
command,
11981225
...(director !== undefined ? { director } : {}),
11991226
globalSettingsPath: effectiveSettingsPath,
1227+
...(permissionsPath !== undefined ? { permissionsPath } : {}),
12001228
sessionId,
12011229
...(resumeMode !== undefined
12021230
? {

‎src/exec/runner.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
722722
// Main session: gating rides the reactor's approval-suspend seam. In
723723
// headless exec an ask resolves to a deny effect, so the hook blocks.
724724
reactorGated: true,
725+
permissionsPath: config.permissionsPath,
725726
});
726727

727728
const liveSubAgentProvider: { current: SubAgentProvider } = {

‎src/permission/admin.test.ts‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
2+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { SETTINGS_DIR_NAME } from "../branding.js";
6+
import { generateSessionId, initSessionDir } from "../session/index.js";
7+
import { loadSeededApprovals } from "../session/runtime-assembly.js";
8+
import { createPermissionsAdmin } from "./admin.js";
9+
import { createPermissionGate } from "./gate.js";
10+
import * as permissionStore from "./store.js";
11+
12+
describe("createPermissionsAdmin --permissions reseed", () => {
13+
let cwd = "";
14+
let home = "";
15+
16+
afterEach(async () => {
17+
mock.restore();
18+
if (cwd !== "") await rm(cwd, { recursive: true, force: true });
19+
if (home !== "") await rm(home, { recursive: true, force: true });
20+
cwd = "";
21+
home = "";
22+
});
23+
24+
test("revoking a project grant keeps the file grant and does not restore or edit user grants", async () => {
25+
cwd = await mkdtemp(join(tmpdir(), "perm-admin-"));
26+
home = await mkdtemp(join(tmpdir(), "perm-admin-home-"));
27+
const sessionId = generateSessionId();
28+
await initSessionDir(cwd, sessionId, home);
29+
30+
await permissionStore.saveGlobalApproval(
31+
{ tool: "run_shell", pattern: "global-only git *" },
32+
home,
33+
);
34+
await permissionStore.saveProviderModelApproval(
35+
"openai:gpt-5",
36+
{ tool: "run_shell", pattern: "provider-only npm *" },
37+
home,
38+
);
39+
await permissionStore.saveProjectApproval(
40+
cwd,
41+
{ tool: "run_shell", pattern: "project keep *" },
42+
home,
43+
);
44+
await permissionStore.saveProjectApproval(
45+
cwd,
46+
{ tool: "run_shell", pattern: "project drop *" },
47+
home,
48+
);
49+
const permissionsPath = join(cwd, "grants.json");
50+
await writeFile(
51+
permissionsPath,
52+
JSON.stringify({
53+
approvals: [
54+
{ tool: "run_shell", pattern: "bun install*" },
55+
{
56+
tool: "run_shell",
57+
pattern: "scoped cargo *",
58+
providerModel: "openai:gpt-5",
59+
},
60+
],
61+
}),
62+
);
63+
const userStorePath = join(home, SETTINGS_DIR_NAME, "permissions.json");
64+
const userStoreBefore = await readFile(userStorePath, "utf-8");
65+
66+
const seeded = await loadSeededApprovals(cwd, sessionId, home, {
67+
permissionsPath,
68+
});
69+
const gate = createPermissionGate({
70+
approvals: seeded,
71+
cwd,
72+
interactive: false,
73+
skipPermissions: false,
74+
reactorGated: false,
75+
});
76+
77+
// homedir() is cached, so a no-arg reload of the user store would not see
78+
// this temp home — and a no-arg remove would edit the real user file.
79+
// Surface either call as the sentinel grant / a throw instead.
80+
spyOn(permissionStore, "loadGlobalApprovals").mockResolvedValue([
81+
{ tool: "run_shell", pattern: "global-only git *" },
82+
]);
83+
spyOn(permissionStore, "loadProviderModelApprovals").mockResolvedValue([
84+
{
85+
tool: "run_shell",
86+
pattern: "provider-only npm *",
87+
providerModel: "openai:gpt-5",
88+
},
89+
]);
90+
const removeGlobal = spyOn(
91+
permissionStore,
92+
"removeGlobalApproval",
93+
).mockImplementation(() => {
94+
throw new Error("user global store must not be mutated");
95+
});
96+
const removeProvider = spyOn(
97+
permissionStore,
98+
"removeProviderModelApproval",
99+
).mockImplementation(() => {
100+
throw new Error("user provider-model store must not be mutated");
101+
});
102+
103+
const admin = createPermissionsAdmin(gate, cwd, { permissionsPath, home });
104+
await admin.revoke({
105+
scope: "project",
106+
tool: "run_shell",
107+
pattern: "project drop *",
108+
});
109+
110+
const patterns = gate.getApprovals().map((approval) => approval.pattern);
111+
expect(patterns).toContain("bun install*");
112+
expect(patterns).toContain("scoped cargo *");
113+
expect(patterns).toContain("project keep *");
114+
expect(patterns).not.toContain("project drop *");
115+
expect(patterns).not.toContain("global-only git *");
116+
expect(patterns).not.toContain("provider-only npm *");
117+
expect(removeGlobal).not.toHaveBeenCalled();
118+
expect(removeProvider).not.toHaveBeenCalled();
119+
expect(await readFile(userStorePath, "utf-8")).toBe(userStoreBefore);
120+
121+
const listed = await admin.list();
122+
const fileRow = listed.find((entry) => entry.pattern === "bun install*");
123+
expect(fileRow).toEqual({
124+
scope: "global",
125+
tool: "run_shell",
126+
pattern: "bun install*",
127+
});
128+
await admin.revoke({
129+
scope: "global",
130+
tool: "run_shell",
131+
pattern: "bun install*",
132+
});
133+
await admin.revoke({
134+
scope: "provider-model",
135+
tool: "run_shell",
136+
pattern: "scoped cargo *",
137+
providerModel: "openai:gpt-5",
138+
});
139+
140+
const afterFileRevoke = gate
141+
.getApprovals()
142+
.map((approval) => approval.pattern);
143+
expect(afterFileRevoke).not.toContain("bun install*");
144+
expect(afterFileRevoke).not.toContain("scoped cargo *");
145+
expect(afterFileRevoke).toContain("project keep *");
146+
expect(afterFileRevoke).not.toContain("global-only git *");
147+
expect(afterFileRevoke).not.toContain("provider-only npm *");
148+
expect(removeGlobal).not.toHaveBeenCalled();
149+
expect(removeProvider).not.toHaveBeenCalled();
150+
expect(await readFile(userStorePath, "utf-8")).toBe(userStoreBefore);
151+
152+
const fileAfter = JSON.parse(await readFile(permissionsPath, "utf-8")) as {
153+
approvals: { tool: string; pattern: string }[];
154+
providerModels?: unknown;
155+
};
156+
expect(fileAfter.approvals).toEqual([]);
157+
expect(fileAfter.providerModels).toBeUndefined();
158+
159+
const reseeded = await loadSeededApprovals(cwd, sessionId, home, {
160+
permissionsPath,
161+
});
162+
const reseededPatterns = reseeded.map((approval) => approval.pattern);
163+
expect(reseededPatterns).not.toContain("bun install*");
164+
expect(reseededPatterns).not.toContain("scoped cargo *");
165+
expect(reseededPatterns).toContain("project keep *");
166+
expect(reseededPatterns).not.toContain("global-only git *");
167+
});
168+
});

0 commit comments

Comments
 (0)