mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
feat(claude-adapter): standard-safe permission baseline at spawn
Extend provisionRigPermissions() to inject the standard-safe Bash baseline alongside the existing Bash(rig:*) on every rig up / restore / launch. Addresses B1 momentum-killer where Claude Code sessions reset to ask-all on benign read-only ops (ls, cat, tail, head, wc, grep, rg, pwd, echo, which). 11 patterns in STANDARD_SAFE_BASELINE using Bash(cmd:*) colon-form. Additive only — never removes existing allow entries. Provenance marker (_openrig_provenance) written so operators can distinguish rig-injected from human-authored entries. Path C partial fix per PM spec: durable across OpenRig lifecycle events (re-provisions on every touch); unknown durability across arbitrary Claude updates (spike never concluded). Honest position documented in architect brief. v0 scope: no rig.yaml schema changes, no new CLI commands, no new routes, no teardown cleanup. Same 11 patterns for every Claude Code member (per-rig customization deferred to v0.5). Tests added (6): baseline applied on fresh settings, additive with existing entries, idempotent on re-run, provenance marker present, deny/ask preserved, non-permissions keys preserved. Gates: claude-adapter 26/26; full daemon 1798/1798; tsc + build clean. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
1 parent
09f8cc8682
commit
3d8d7e83c8
2 files changed
+151
-1
No files matched your search
@@ -376,6 +376,23 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
this.provisionOnboardingState();
|
||||
}
|
||||
|
||||
/** Standard-safe baseline patterns — the highest-friction Bash prompts in
|
||||
* daily operation. Additive only; never removes existing entries. Uses
|
||||
* `Bash(cmd:*)` colon-form per Claude Code convention. */
|
||||
static readonly STANDARD_SAFE_BASELINE: readonly string[] = [
|
||||
"Bash(ls:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(tail:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(pwd)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(which:*)",
|
||||
"Bash(rig:*)",
|
||||
];
|
||||
|
||||
private provisionRigPermissions(): void {
|
||||
const home = this.fs.homedir ?? (typeof process !== "undefined" ? process.env.HOME : undefined);
|
||||
if (!home) return;
|
||||
@@ -386,11 +403,23 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
const settings = this.readJsonObject(settingsPath);
|
||||
const permissions = this.readJsonObjectField(settings, "permissions");
|
||||
const allow = new Set(this.readStringArray(permissions["allow"]));
|
||||
allow.add("Bash(rig:*)");
|
||||
|
||||
// Standard-safe baseline — additive only; never removes existing entries
|
||||
for (const pattern of ClaudeCodeAdapter.STANDARD_SAFE_BASELINE) {
|
||||
allow.add(pattern);
|
||||
}
|
||||
|
||||
permissions["allow"] = Array.from(allow);
|
||||
settings["permissions"] = permissions;
|
||||
|
||||
// Provenance marker so operators can distinguish rig-injected from human-authored
|
||||
settings["_openrig_provenance"] = {
|
||||
author: "openrig-at-spawn",
|
||||
baseline: "standard-safe",
|
||||
patterns_added: ClaudeCodeAdapter.STANDARD_SAFE_BASELINE.length,
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -429,4 +429,125 @@ describe("Claude Code runtime adapter", () => {
|
||||
);
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
// --- Permission-config-at-spawn: standard-safe baseline provisioning ---
|
||||
|
||||
it("provisionRigPermissions applies standard-safe baseline on fresh settings", async () => {
|
||||
const fs = mockFs({});
|
||||
const adapter = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: { ...fs, homedir: "/home/test" } });
|
||||
|
||||
// Trigger provisioning via deliverStartup (which calls provisionManagedBootstrap)
|
||||
await adapter.deliverStartup([], makeBinding());
|
||||
|
||||
const store = (fs as unknown as { _store: Record<string, string> })._store;
|
||||
const settings = JSON.parse(store["/home/test/.claude/settings.json"] ?? "{}");
|
||||
const allow: string[] = settings.permissions?.allow ?? [];
|
||||
|
||||
expect(allow).toContain("Bash(rig:*)");
|
||||
expect(allow).toContain("Bash(ls:*)");
|
||||
expect(allow).toContain("Bash(cat:*)");
|
||||
expect(allow).toContain("Bash(tail:*)");
|
||||
expect(allow).toContain("Bash(head:*)");
|
||||
expect(allow).toContain("Bash(wc:*)");
|
||||
expect(allow).toContain("Bash(grep:*)");
|
||||
expect(allow).toContain("Bash(rg:*)");
|
||||
expect(allow).toContain("Bash(pwd)");
|
||||
expect(allow).toContain("Bash(echo:*)");
|
||||
expect(allow).toContain("Bash(which:*)");
|
||||
});
|
||||
|
||||
it("provisionRigPermissions is additive — preserves existing allow entries", async () => {
|
||||
const fs = mockFs({
|
||||
"/home/test/.claude/settings.json": JSON.stringify({
|
||||
permissions: { allow: ["Bash(npm:*)", "Bash(git:*)"] },
|
||||
}),
|
||||
});
|
||||
const adapter = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: { ...fs, homedir: "/home/test" } });
|
||||
|
||||
await adapter.deliverStartup([], makeBinding());
|
||||
|
||||
const store = (fs as unknown as { _store: Record<string, string> })._store;
|
||||
const settings = JSON.parse(store["/home/test/.claude/settings.json"] ?? "{}");
|
||||
const allow: string[] = settings.permissions?.allow ?? [];
|
||||
|
||||
// Pre-existing entries preserved
|
||||
expect(allow).toContain("Bash(npm:*)");
|
||||
expect(allow).toContain("Bash(git:*)");
|
||||
// Baseline entries added
|
||||
expect(allow).toContain("Bash(ls:*)");
|
||||
expect(allow).toContain("Bash(rig:*)");
|
||||
});
|
||||
|
||||
it("provisionRigPermissions is idempotent — no duplicates on re-run", async () => {
|
||||
const fs = mockFs({});
|
||||
const adapter = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: { ...fs, homedir: "/home/test" } });
|
||||
|
||||
// Run twice
|
||||
await adapter.deliverStartup([], makeBinding());
|
||||
await adapter.deliverStartup([], makeBinding());
|
||||
|
||||
const store = (fs as unknown as { _store: Record<string, string> })._store;
|
||||
const settings = JSON.parse(store["/home/test/.claude/settings.json"] ?? "{}");
|
||||
const allow: string[] = settings.permissions?.allow ?? [];
|
||||
|
||||
// Each pattern appears exactly once
|
||||
const rigCount = allow.filter((p: string) => p === "Bash(rig:*)").length;
|
||||
expect(rigCount).toBe(1);
|
||||
const lsCount = allow.filter((p: string) => p === "Bash(ls:*)").length;
|
||||
expect(lsCount).toBe(1);
|
||||
});
|
||||
|
||||
it("provisionRigPermissions writes provenance marker", async () => {
|
||||
const fs = mockFs({});
|
||||
const adapter = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: { ...fs, homedir: "/home/test" } });
|
||||
|
||||
await adapter.deliverStartup([], makeBinding());
|
||||
|
||||
const store = (fs as unknown as { _store: Record<string, string> })._store;
|
||||
const settings = JSON.parse(store["/home/test/.claude/settings.json"] ?? "{}");
|
||||
|
||||
expect(settings._openrig_provenance).toBeDefined();
|
||||
expect(settings._openrig_provenance.author).toBe("openrig-at-spawn");
|
||||
expect(settings._openrig_provenance.baseline).toBe("standard-safe");
|
||||
});
|
||||
|
||||
it("provisionRigPermissions preserves existing deny/ask entries", async () => {
|
||||
const fs = mockFs({
|
||||
"/home/test/.claude/settings.json": JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Bash(rm:*)"],
|
||||
ask: ["Bash(git push:*)"],
|
||||
allow: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const adapter = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: { ...fs, homedir: "/home/test" } });
|
||||
|
||||
await adapter.deliverStartup([], makeBinding());
|
||||
|
||||
const store = (fs as unknown as { _store: Record<string, string> })._store;
|
||||
const settings = JSON.parse(store["/home/test/.claude/settings.json"] ?? "{}");
|
||||
|
||||
expect(settings.permissions.deny).toEqual(["Bash(rm:*)"]);
|
||||
expect(settings.permissions.ask).toEqual(["Bash(git push:*)"]);
|
||||
});
|
||||
|
||||
it("provisionRigPermissions preserves non-permissions keys", async () => {
|
||||
const fs = mockFs({
|
||||
"/home/test/.claude/settings.json": JSON.stringify({
|
||||
theme: "dark",
|
||||
mcpServers: { context7: {} },
|
||||
permissions: { allow: [] },
|
||||
}),
|
||||
});
|
||||
const adapter = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: { ...fs, homedir: "/home/test" } });
|
||||
|
||||
await adapter.deliverStartup([], makeBinding());
|
||||
|
||||
const store = (fs as unknown as { _store: Record<string, string> })._store;
|
||||
const settings = JSON.parse(store["/home/test/.claude/settings.json"] ?? "{}");
|
||||
|
||||
expect(settings.theme).toBe("dark");
|
||||
expect(settings.mcpServers).toEqual({ context7: {} });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user