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
45 changes: 24 additions & 21 deletions docs/IMPLEMENTATION.md

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,8 @@ describe("loadConfig", () => {
[["--cwd", "-h"]],
[["--config", "--help"]],
[["--config", "-h"]],
[["--permissions", "--help"]],
[["--permissions", "-h"]],
[["--profile", "--help"]],
[["--profile", "-h"]],
])(
Expand Down Expand Up @@ -1007,6 +1009,8 @@ describe("loadConfig", () => {
[["--cwd", "-V"]],
[["--config", "--version"]],
[["--config", "-V"]],
[["--permissions", "--version"]],
[["--permissions", "-V"]],
[["--profile", "--version"]],
[["--profile", "-V"]],
])(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 <path>");
const permissionsAt = CLI_HELP_TEXT.indexOf("--permissions <path>");
expect(configAt).toBeGreaterThanOrEqual(0);
expect(permissionsAt).toBeGreaterThan(configAt);
expect(CLI_HELP_TEXT.indexOf("--provider <name>")).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 });
Expand Down
28 changes: 28 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -721,6 +733,7 @@ Continue verbs (project-keyed to this checkout's git toplevel):
Flags:
--cwd <dir> working directory (default: process.cwd())
--config <path> settings file (default: ~/.corbits/settings.json)
--permissions <path> grant file replacing ~/.corbits/permissions.json for this process
--provider <name> configured provider name
--model <id> model for the active provider
--profile <name> settings profile
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1197,6 +1224,7 @@ export async function loadConfig(
command,
...(director !== undefined ? { director } : {}),
globalSettingsPath: effectiveSettingsPath,
...(permissionsPath !== undefined ? { permissionsPath } : {}),
sessionId,
...(resumeMode !== undefined
? {
Expand Down
1 change: 1 addition & 0 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
// 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 } = {
Expand Down
168 changes: 168 additions & 0 deletions src/permission/admin.test.ts
Original file line number Diff line number Diff line change
@@ -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 *");
});
});
Loading
Loading