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:
mvschwarzandClaude Opus 4.6 committed 2026-04-22 20:06:21 -07:00
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: {} });
});
});