feat(perms): OPR.0.4.8.2 rip out Claude config-file policy writes C1b/C1c/C2 (RED-first)

Founder-locked agnostic permission rip-out, seam 1. Rip-list = ASSESSMENT sha 5d450fdd. Base f81018fb.

OpenRig goes agnostic on permissions: it bakes NO config-file permission policy beyond the
usability floor. Two-surface rule — launch flags (stable) may be deterministic code; config-file
writes (chaotic) are removed.

- C1b/C1c: claude-settings.fragment.json stripped to ONLY permissions.defaultMode=acceptEdits
  (+ enabledMcpjsonServers). The 13-entry allow-list and the rig up/down ask gates are gone.
- C2: provisionRigPermissions (the global Bash(rig:*) allow + _openrig_provenance writer into
  ~/.claude/settings.json) is DELETED — call, method, CONVENIENCE_BASELINE, and the now-dead
  readStringArray. No retro-scrub: the new code never touches settings.json, so pre-existing
  provenance-marked user files stay byte-identical. Trust/onboarding (C3/C4) plumbing kept.
- Floor kept byte-identical: fragment defaultMode acceptEdits + the --permission-mode acceptEdits
  launch flag (unchanged).

Tests RED-first: agnostic-rip-out.test.ts (fragment has only defaultMode; fresh startup authors no
settings.json; no-retro-scrub byte-identical; launch-floor flag pin). Flipped the pins that
asserted the ripped rows (slice16-hygiene item 1, spec-library-starters, and removed the 7-test
provisionRigPermissions section with a rip-pointer). 201/201 across six suites; daemon tsc 0.
Guard-reviewed. VM-only, source-only (no live-host mutation); never pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-05 02:53:51 +00:00
1 parent 2f71ca7520
commit 1c4e2db135
6 files changed
+142 -236

No files matched your search

@@ -1,25 +1,6 @@
{
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Bash(rig:*)",
"Bash(git:*)",
"Bash(npm:*)",
"Bash(npx:*)",
"Bash(pnpm:*)",
"Bash(yarn:*)",
"Bash(node:*)",
"Bash(tsc:*)",
"Bash(vitest:*)",
"Bash(jest:*)",
"Read",
"Edit",
"Write"
],
"ask": [
"Bash(rig up:*)",
"Bash(rig down:*)"
]
"defaultMode": "acceptEdits"
},
"enabledMcpjsonServers": [
"exa",
@@ -583,58 +583,17 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
}
private provisionManagedBootstrap(binding: { cwd?: string | null; tmuxSession?: string | null }): void {
this.provisionRigPermissions();
// OPR.0.4.8.2 agnostic rip-out: provisionRigPermissions (C2) removed — OpenRig no longer
// authors any config-file permission policy. Trust/onboarding (C3/C4) are neutral plumbing, kept.
this.provisionWorkspaceTrust(binding.cwd ?? null);
this.provisionOnboardingState();
}
/** Managed rig command baseline.
* Additive only; never removes existing entries. Uses `Bash(cmd:*)`
* colon-form per Claude Code convention.
*
* NOTE: this is not OpenRig's permission system. Harness-native
* permissions should remain the primary control surface. */
static readonly CONVENIENCE_BASELINE: readonly string[] = [
"Bash(rig:*)",
];
private provisionRigPermissions(): void {
const home = this.fs.homedir ?? (typeof process !== "undefined" ? process.env.HOME : undefined);
if (!home) return;
const settingsPath = nodePath.join(home, ".claude", "settings.json");
this.fs.mkdirp(nodePath.dirname(settingsPath));
const settings = this.readJsonObject(settingsPath);
const permissions = this.readJsonObjectField(settings, "permissions");
const allow = new Set(this.readStringArray(permissions["allow"]));
// Bash convenience baseline — additive only; never removes existing entries.
// Track whether any new patterns were actually added to avoid redundant writes
// (preserves provenance timestamp + file content idempotency on re-runs).
let added = 0;
for (const pattern of ClaudeCodeAdapter.CONVENIENCE_BASELINE) {
if (!allow.has(pattern)) {
allow.add(pattern);
added++;
}
}
if (added === 0) return; // All patterns already present; skip write
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: "convenience",
patterns_added: added,
ts: new Date().toISOString(),
};
this.fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
}
// OPR.0.4.8.2 agnostic rip-out: the CONVENIENCE_BASELINE (global `Bash(rig:*)` allow) and its
// provisionRigPermissions writer (assessment row C2 — wrote into ~/.claude/settings.json with an
// `_openrig_provenance` marker) are DELETED. OpenRig no longer authors any config-file permission
// policy; the harness-native permission surface is the control surface. Existing provenance-marked
// user files are NOT retro-scrubbed — the new code simply never touches settings.json.
private provisionWorkspaceTrust(cwd: string | null): void {
if (!cwd) return;
@@ -702,9 +661,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
: {};
}
private readStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
// OPR.0.4.8.2 rip-out: readStringArray removed — its only caller was provisionRigPermissions (C2).
/**
* Best-effort: provision the OpenRig context collector for managed Claude sessions.
@@ -0,0 +1,116 @@
import { describe, it, expect, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ClaudeCodeAdapter, type ClaudeAdapterFsOps } from "../src/adapters/claude-code-adapter.js";
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
// OPR.0.4.8.2 — agnostic permission RIP-OUT (founder-locked; rip-list = ASSESSMENT sha 5d450fdd).
// Strips the three OpenRig-baked CONFIG-FILE policy writes beyond the floor: C1b (fragment
// permissions.allow), C1c (fragment permissions.ask), C2 (provisionRigPermissions global allow).
// KEEPS the usability floor byte-identical (fragment defaultMode=acceptEdits + the
// --permission-mode acceptEdits launch flag). Two-surface rule: config-file writes go, launch-flag
// floor stays. RED-first: the rip assertions FAIL on f81018fb (which ships allow/ask + rig allow).
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FRAGMENT_PATH = path.join(__dirname, "../specs/agents/shared/runtime/claude-settings.fragment.json");
function mockTmux(): TmuxAdapter {
return {
sendText: vi.fn(async () => ({ ok: true as const })),
hasSession: vi.fn(async () => true),
getPaneCommand: vi.fn(async () => "claude"),
capturePaneContent: vi.fn(async () => ""),
createSession: vi.fn(async () => ({ ok: true as const })),
killSession: vi.fn(async () => ({ ok: true as const })),
listSessions: vi.fn(async () => []),
listWindows: vi.fn(async () => []),
listPanes: vi.fn(async () => []),
sendKeys: vi.fn(async () => ({ ok: true as const })),
} as unknown as TmuxAdapter;
}
function mockFs(files?: Record<string, string>): ClaudeAdapterFsOps & { _store: Record<string, string> } {
const store: Record<string, string> = { ...files };
return {
readFile: (p: string) => {
if (p in store) return store[p]!;
throw new Error(`Not found: ${p}`);
},
writeFile: (p: string, c: string) => {
store[p] = c;
},
exists: (p: string) => p in store,
mkdirp: () => {},
copyFile: () => {},
listFiles: (dir: string) => Object.keys(store).filter((k) => k.startsWith(dir + "/")).map((k) => k.slice(dir.length + 1)),
_store: store,
} as ClaudeAdapterFsOps & { _store: Record<string, string> };
}
function makeBinding(cwd = "/project"): NodeBinding {
return {
id: "b1",
nodeId: "n1",
tmuxSession: "r01-impl",
tmuxWindow: null,
tmuxPane: null,
cmuxWorkspace: null,
cmuxSurface: null,
updatedAt: "",
cwd,
};
}
describe("OPR.0.4.8.2 agnostic rip-out — config-file policy writes stripped, launch-flag floor kept", () => {
it("C1b/C1c: the shipped fragment carries NO permissions.allow / ask / deny", () => {
const frag = JSON.parse(fs.readFileSync(FRAGMENT_PATH, "utf8"));
expect(frag.permissions.allow).toBeUndefined();
expect(frag.permissions.ask).toBeUndefined();
expect(frag.permissions.deny).toBeUndefined();
});
it("floor KEPT: fragment permissions.defaultMode is exactly acceptEdits (+ mcp servers untouched)", () => {
const frag = JSON.parse(fs.readFileSync(FRAGMENT_PATH, "utf8"));
expect(Object.keys(frag.permissions)).toEqual(["defaultMode"]); // ONLY the floor key remains
expect(frag.permissions.defaultMode).toBe("acceptEdits");
expect(frag.enabledMcpjsonServers).toEqual(["exa", "context7"]);
});
it("C2: a fresh startup authors NO ~/.claude/settings.json for permissions", async () => {
const fsm = mockFs({});
const adapter = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: { ...fsm, homedir: "/home/test" } });
await adapter.deliverStartup([], makeBinding());
// Post-rip: nothing authored solely for permissions. (Trust/onboarding write ~/.claude.json,
// not settings.json.)
expect(fsm._store["/home/test/.claude/settings.json"]).toBeUndefined();
});
it("NO retro-scrub: a pre-existing provenance-marked settings.json is left BYTE-IDENTICAL", async () => {
// NOTE: no Bash(rig:*) here — so the pre-rip code WOULD add it and rewrite (that is the RED).
const existing = JSON.stringify(
{ permissions: { allow: ["Bash(npm:*)"] }, _openrig_provenance: { author: "openrig-at-spawn", baseline: "convenience" } },
null,
2,
);
const fsm = mockFs({ "/home/test/.claude/settings.json": existing });
const adapter = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: { ...fsm, homedir: "/home/test" } });
await adapter.deliverStartup([], makeBinding());
expect(fsm._store["/home/test/.claude/settings.json"]).toBe(existing); // byte-identical, untouched
});
it("floor launch flag KEPT byte-for-byte: launch command still contains --permission-mode acceptEdits", async () => {
const tmux = mockTmux();
const adapter = new ClaudeCodeAdapter({
tmux,
fsOps: mockFs(),
sessionIdFactory: () => "11111111-1111-4111-8111-111111111111",
});
const result = await adapter.launchHarness(makeBinding(), { name: "dev-impl@test-rig" });
expect(result.ok).toBe(true);
const sendText = tmux.sendText as ReturnType<typeof vi.fn>;
const cmd = sendText.mock.calls[0]?.[1] as string;
expect(cmd).toContain("--permission-mode acceptEdits");
});
});
@@ -590,139 +590,11 @@ describe("Claude Code runtime adapter", () => {
logSpy.mockRestore();
});
// --- Permission-config-at-spawn: Bash convenience baseline provisioning ---
it("provisionRigPermissions only adds the managed rig command on fresh settings", 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"] ?? "{}");
const allow: string[] = settings.permissions?.allow ?? [];
expect(allow).toContain("Bash(rig:*)");
expect(allow).toEqual(["Bash(rig:*)"]);
expect(allow).not.toContain("Bash(ls:*)");
expect(allow).not.toContain("Bash(cat:*)");
expect(allow).not.toContain("Bash(tail:*)");
expect(allow).not.toContain("Bash(head:*)");
expect(allow).not.toContain("Bash(wc:*)");
expect(allow).not.toContain("Bash(grep:*)");
expect(allow).not.toContain("Bash(rg:*)");
expect(allow).not.toContain("Bash(pwd)");
expect(allow).not.toContain("Bash(which:*)");
expect(allow).not.toContain("Bash(echo:*)");
});
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:*)");
// Managed rig command added
expect(allow).toContain("Bash(rig:*)");
expect(allow).not.toContain("Bash(ls:*)");
});
it("provisionRigPermissions is idempotent — no duplicates on re-run", async () => {
const fs = mockFs({});
const adapter = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: { ...fs, homedir: "/home/test" } });
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 ?? [];
const rigCount = allow.filter((p: string) => p === "Bash(rig:*)").length;
expect(rigCount).toBe(1);
expect(allow.filter((p: string) => p === "Bash(ls:*)")).toHaveLength(0);
});
it("provisionRigPermissions is file-idempotent — identical settings.json on consecutive runs", 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 firstWrite = store["/home/test/.claude/settings.json"];
await adapter.deliverStartup([], makeBinding());
const secondWrite = store["/home/test/.claude/settings.json"];
// Second run should not rewrite (all patterns already present);
// file content stays byte-identical including provenance timestamp
expect(secondWrite).toBe(firstWrite);
});
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("convenience");
});
it("provisionRigPermissions preserves existing deny/ask entries", async () => {
const fs = mockFs({
"/home/test/.claude/settings.json": JSON.stringify({
permissions: {
deny: ["Bash(git push*)", "Bash(gh pr *)"],
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(git push*)", "Bash(gh pr *)"]);
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: {} });
});
// OPR.0.4.8.2 agnostic rip-out: the whole "Permission-config-at-spawn: Bash convenience
// baseline provisioning" section (7 provisionRigPermissions tests) is REMOVED — the writer
// (assessment C2) is deleted. Replacement coverage: agnostic-rip-out.test.ts asserts (a) a
// fresh startup authors no ~/.claude/settings.json for permissions; (b) a pre-existing
// provenance-marked settings file is left byte-identical (no retro-scrub).
// Pre-rip 'provisions project-local Claude hooks without clobbering
// existing local settings or persisting the hook token' test removed in
@@ -11,19 +11,14 @@ import { fileURLToPath } from "node:url";
const specs = fileURLToPath(new URL("../specs/", import.meta.url));
describe("Slice 16 — starter bootstrap hygiene", () => {
it("item 1: shipped claude-settings fragment allows the dev toolchain; rig up/down stays gated", () => {
it("item 1 (OPR.0.4.8.2 agnostic rip-out): fragment keeps ONLY the acceptEdits floor — no allow/ask/deny", () => {
const frag = JSON.parse(readFileSync(specs + "agents/shared/runtime/claude-settings.fragment.json", "utf8"));
const allow: string[] = frag.permissions.allow;
// acceptEdits + the toolchain a TDD factory needs (git / npm test / tsc / node) + edit tools
// The 13-entry allow-list (assessment C1b) and the rig up/down ask gates (C1c) are RIPPED —
// OpenRig no longer bakes any config-file permission policy. The floor (acceptEdits) stays.
expect(frag.permissions.defaultMode).toBe("acceptEdits");
for (const perm of ["Bash(rig:*)", "Bash(git:*)", "Bash(npm:*)", "Bash(node:*)", "Bash(tsc:*)", "Read", "Edit", "Write"]) {
expect(allow).toContain(perm);
}
// rig up/down remain behind the ask gate — NOT auto-allowed
expect(frag.permissions.ask).toContain("Bash(rig up:*)");
expect(frag.permissions.ask).toContain("Bash(rig down:*)");
expect(allow).not.toContain("Bash(rig up:*)");
expect(allow).not.toContain("Bash(rig down:*)");
expect(frag.permissions.allow).toBeUndefined();
expect(frag.permissions.ask).toBeUndefined();
expect(frag.permissions.deny).toBeUndefined();
});
it("item 3: the QA agent carries the test-driven-development skill it enforces", () => {
@@ -425,28 +425,13 @@ describe("Starter specs", () => {
}
const claudeSettingsResource = sharedRuntimeResources.find((entry) => entry.id === "claude-default-settings");
const claudeSettings = JSON.parse(readFileSync(join(SPECS_ROOT, "agents/shared", claudeSettingsResource!.path), "utf-8"));
// Slice 16 (item 1): the shipped fragment carries the dev toolchain a TDD
// factory needs (git / npm / node / tsc + edit tools) so fresh product-team
// seats don't stall on permission prompts. This assertion is the deliberate
// security GATE — any FURTHER widening beyond this authorized set must update
// it consciously. `rig up`/`rig down` stay behind the ask gate.
expect(claudeSettings.permissions.allow).toEqual([
"Bash(rig:*)",
"Bash(git:*)",
"Bash(npm:*)",
"Bash(npx:*)",
"Bash(pnpm:*)",
"Bash(yarn:*)",
"Bash(node:*)",
"Bash(tsc:*)",
"Bash(vitest:*)",
"Bash(jest:*)",
"Read",
"Edit",
"Write",
]);
expect(claudeSettings.permissions.ask).toEqual(["Bash(rig up:*)", "Bash(rig down:*)"]);
// OPR.0.4.8.2 agnostic rip-out: the fragment's 13-entry allow-list (assessment C1b) and the
// rig up/down ask gates (C1c) are RIPPED. OpenRig bakes NO config-file permission policy
// beyond the acceptEdits floor — post-rip the fragment carries only defaultMode.
expect(claudeSettings.permissions.allow).toBeUndefined();
expect(claudeSettings.permissions.ask).toBeUndefined();
expect(claudeSettings.permissions.deny).toBeUndefined();
expect(claudeSettings.permissions.defaultMode).toBe("acceptEdits");
// Slice 29: claude-compact-in-place skill DELETED (was mis-imported as a
// skill; its content remains in skill-spec docs not under skills/).