mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
fix(perms): route ALL managed launch paths through one launch-posture decision (guard finding)
Guard NOT-CLEAR on 7f6b28f6: the "every seat" contract missed three managed paths the fresh-only
YOLO wiring did not exercise. Centralize the decision in yolo-mode.ts (claudePostureFlag /
codexPostureArg / piTrust) and route every fresh/resume/fork path through it:
- Claude restore (claude-resume.ts): was `claude --resume <token>` — now carries the posture flag
(the unconditional acceptEdits floor when OFF; --dangerously-skip-permissions when YOLO ON).
- Codex native fork (codex-runtime-adapter.ts fork branch): now codexPostureArg(profileArg) — OFF
keeps the profile/floor, ON forces --dangerously-bypass-approvals-and-sandbox. Fresh + resume
also routed through the same helper (behavior unchanged when OFF).
- Pi restore (pi-resume.ts) + launch: piTrust(configured) — ON forces `approve`, OFF the configured
posture. Pi's --approve/--no-approve govern RESOURCE TRUST, NOT a permission policy — wording
corrected in source comments/helper doc/test descriptions accordingly (PM/Leg-B framing). The
zero-permission-config-write property is scoped to Claude/Codex permission policy.
Test-first: yolo-mode.test.ts adds ON/OFF pins for Claude restore, Codex fork, and the
codexPostureArg / piTrust decisions; flipped the 3 claude-resume assertions (floor now present).
Daemon bounded suite 303/303, cli setup 24/24; daemon+cli tsc 0. VM/source-only; never pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
1 parent
7f6b28f664
commit
6581dbaf34
9 files changed
+129
-41
No files matched your search
@@ -2,7 +2,7 @@ import nodePath from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { TmuxAdapter } from "./tmux.js";
|
||||
import { yoloEnabled } from "./yolo-mode.js";
|
||||
import { claudePostureFlag } from "./yolo-mode.js";
|
||||
import type {
|
||||
RuntimeAdapter, NodeBinding, ResolvedStartupFile,
|
||||
InstalledResource, ProjectionResult, StartupDeliveryResult, ReadinessResult,
|
||||
@@ -170,9 +170,9 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
return { ok: false, error: "resumeToken and forkSource are mutually exclusive — pick one" };
|
||||
}
|
||||
|
||||
// OPR.0.4.8.2: the acceptEdits floor by default; YOLO mode (opt-in) swaps in the full-bypass
|
||||
// launch flag. Launch-flag surface only — no config-file writes on either path.
|
||||
const permissionMode = yoloEnabled() ? "--dangerously-skip-permissions" : "--permission-mode acceptEdits";
|
||||
// OPR.0.4.8.2: the acceptEdits floor by default; YOLO (opt-in) swaps in the full-bypass flag.
|
||||
// The SAME decision (claudePostureFlag) is used on the restore path (claude-resume.ts).
|
||||
const permissionMode = claudePostureFlag();
|
||||
|
||||
// Fork branch: build `claude --resume <parent> --fork-session --name <seat>`
|
||||
// and capture the NEW post-fork session id. The parent token is NEVER
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import type { TmuxAdapter } from "./tmux.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { claudePostureFlag } from "./yolo-mode.js";
|
||||
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
|
||||
|
||||
export type ResumeResult =
|
||||
@@ -42,7 +43,9 @@ export class ClaudeResumeAdapter {
|
||||
return { ok: false, code: "no_resume", message: "Claude resume not available" };
|
||||
}
|
||||
|
||||
const cmd = `claude --resume ${shellQuote(resumeToken!)}`;
|
||||
// OPR.0.4.8.2: the RESTORE path uses the SAME launch-posture decision as fresh launch (the
|
||||
// unconditional acceptEdits floor when OFF; the full bypass when YOLO is ON) — every seat.
|
||||
const cmd = `claude ${claudePostureFlag()} --resume ${shellQuote(resumeToken!)}`;
|
||||
|
||||
const textResult = await this.tmux.sendText(tmuxSessionName, cmd);
|
||||
if (!textResult.ok) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import Database from "better-sqlite3";
|
||||
import type { TmuxAdapter } from "./tmux.js";
|
||||
import { yoloEnabled } from "./yolo-mode.js";
|
||||
import { codexPostureArg } from "./yolo-mode.js";
|
||||
import type {
|
||||
RuntimeAdapter, NodeBinding, ResolvedStartupFile,
|
||||
InstalledResource, ProjectionResult, StartupDeliveryResult, ReadinessResult,
|
||||
@@ -337,7 +337,9 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
if (!parentId) {
|
||||
return { ok: false, error: "codex fork: forkSource.value is required (parent native_id)" };
|
||||
}
|
||||
const cmd = `codex${profileArg} fork${queueStateDirArg} ${shellQuote(parentId)}`;
|
||||
// OPR.0.4.8.2: the FORK path uses the SAME posture decision (codexPostureArg) — YOLO forces
|
||||
// full-bypass on every seat; otherwise the named profile (or the harness-default floor).
|
||||
const cmd = `codex${codexPostureArg(profileArg)} fork${queueStateDirArg} ${shellQuote(parentId)}`;
|
||||
const textResult = await this.tmux.sendText(binding.tmuxSession, cmd);
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
|
||||
@@ -357,16 +359,13 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id" };
|
||||
}
|
||||
|
||||
// OPR.0.4.8.2: one posture decision (codexPostureArg) for the fresh launch too — YOLO forces
|
||||
// full-bypass (overrides even a named profile); otherwise the named profile, or the harness-
|
||||
// default floor (no forced -a/-s; Codex's no-flags default is restricted-fs + OnRequest, the
|
||||
// workspace-only floor confirmed via `codex doctor`).
|
||||
const cmd = opts.resumeToken
|
||||
? buildCodexResumeCore(opts.resumeToken, profile, false, queueStateDirArg.trim() || undefined)
|
||||
: yoloEnabled()
|
||||
? // OPR.0.4.8.2 YOLO (opt-in): full-bypass on every seat (overrides even a named profile).
|
||||
`codex -C ${shellQuote(binding.cwd)}${gitDirArg}${queueStateDirArg}${modelArg} --dangerously-bypass-approvals-and-sandbox`
|
||||
: profile
|
||||
? `codex${profileArg} -C ${shellQuote(binding.cwd)}${gitDirArg}${queueStateDirArg}${modelArg}`
|
||||
: // OPR.0.4.8.2 agnostic rip-out (Codex floor): no forced -a/-s. Codex's no-flags default
|
||||
// is restricted-fs + OnRequest (the workspace-only floor, confirmed via `codex doctor`).
|
||||
`codex -C ${shellQuote(binding.cwd)}${gitDirArg}${queueStateDirArg}${modelArg}`;
|
||||
: `codex${codexPostureArg(profileArg)} -C ${shellQuote(binding.cwd)}${gitDirArg}${queueStateDirArg}${modelArg}`;
|
||||
|
||||
const textResult = await this.tmux.sendText(binding.tmuxSession, cmd);
|
||||
if (!textResult.ok) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { TmuxAdapter } from "./tmux.js";
|
||||
import type { ResumeResult } from "./claude-resume.js";
|
||||
import { piTrust } from "./yolo-mode.js";
|
||||
import {
|
||||
piSeatPaths, parsePiRunnerState, buildPiRunnerCommand, buildPendingRunnerState,
|
||||
} from "./pi-runner-protocol.js";
|
||||
@@ -87,7 +88,9 @@ export class PiResumeAdapter {
|
||||
// The model declaration must survive resume: the runner's provider-key
|
||||
// allowlist keys off the declared provider (VM leg finding).
|
||||
model: model ?? undefined,
|
||||
trust: this.options.trustPosture ?? "no-approve",
|
||||
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). Same decision as the launch path:
|
||||
// YOLO forces `approve` on every restored seat; otherwise the configured posture.
|
||||
trust: piTrust(this.options.trustPosture),
|
||||
sessionFile,
|
||||
launchId,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import nodePath from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { TmuxAdapter } from "./tmux.js";
|
||||
import { yoloEnabled } from "./yolo-mode.js";
|
||||
import { piTrust } from "./yolo-mode.js";
|
||||
import type {
|
||||
RuntimeAdapter, NodeBinding, ResolvedStartupFile,
|
||||
InstalledResource, ProjectionResult, StartupDeliveryResult, ReadinessResult,
|
||||
@@ -239,8 +239,9 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
stateRoot: this.stateRoot,
|
||||
cwd: binding.cwd,
|
||||
model: binding.model,
|
||||
// OPR.0.4.8.2 YOLO (opt-in): forces --approve on every seat; otherwise the conservative floor.
|
||||
trust: yoloEnabled() ? "approve" : this.trustPosture,
|
||||
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). YOLO forces `approve` on every
|
||||
// seat; otherwise the configured posture. Same decision used on the restore path (pi-resume).
|
||||
trust: piTrust(this.trustPosture),
|
||||
sessionFile: opts.resumeToken,
|
||||
forkRef,
|
||||
launchId,
|
||||
|
||||
@@ -2,14 +2,42 @@
|
||||
//
|
||||
// A simple deterministic setting that rides the STABLE launch-flag surface only (per the founder's
|
||||
// two-surface rule: launch flags may be deterministic code; config-file policy may NOT). When ON,
|
||||
// every managed seat boots with its harness full-bypass LAUNCH FLAG:
|
||||
// - Claude: --dangerously-skip-permissions
|
||||
// - Codex: --dangerously-bypass-approvals-and-sandbox
|
||||
// - Pi: --approve
|
||||
// every managed seat boots at its harness's maximally-permissive LAUNCH FLAG:
|
||||
// - Claude: --dangerously-skip-permissions (permission bypass)
|
||||
// - Codex: --dangerously-bypass-approvals-and-sandbox (permission + sandbox bypass)
|
||||
// - Pi: --approve (full RESOURCE TRUST — Pi's
|
||||
// --approve/--no-approve govern RESOURCE TRUST, not a permission policy)
|
||||
// When OFF (the default), seats boot with the usability floor, unchanged. The YOLO path writes ZERO
|
||||
// config files — it only selects a launch flag. Opt-in via the OPENRIG_YOLO env setting.
|
||||
// config files — it only selects a launch flag. Opt-in via the OPENRIG_YOLO env setting. (The
|
||||
// zero-permission-config-write property concerns Claude/Codex permission policy; Pi is resource trust.)
|
||||
|
||||
export function yoloEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const v = env.OPENRIG_YOLO;
|
||||
return v === "1" || v === "true";
|
||||
}
|
||||
|
||||
// ── The single launch-posture decision per harness — used on EVERY managed launch path (fresh,
|
||||
// resume, fork) so the floor (OFF) and the full-bypass (ON) are uniform, never path-dependent. ──
|
||||
|
||||
/** Claude launch posture flag: floor `--permission-mode acceptEdits`, or the YOLO full bypass. */
|
||||
export function claudePostureFlag(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return yoloEnabled(env) ? "--dangerously-skip-permissions" : "--permission-mode acceptEdits";
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex launch posture segment (leading space included). YOLO forces the full-bypass flag on EVERY
|
||||
* seat, overriding even a named `-p` profile; otherwise pass the caller's profile arg (or "" for the
|
||||
* harness-default floor). `profileArg` is the already-formatted ` -p <profile>` string or "".
|
||||
*/
|
||||
export function codexPostureArg(profileArg: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||
return yoloEnabled(env) ? " --dangerously-bypass-approvals-and-sandbox" : profileArg;
|
||||
}
|
||||
|
||||
/** Pi RESOURCE TRUST (Pi's --approve/--no-approve govern resource trust, NOT a permission policy):
|
||||
* YOLO forces `approve`; otherwise the configured posture (default `no-approve`). */
|
||||
export function piTrust(
|
||||
configured: "approve" | "no-approve" | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): "approve" | "no-approve" {
|
||||
return yoloEnabled(env) ? "approve" : configured ?? "no-approve";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import { yoloEnabled } from "../adapters/yolo-mode.js";
|
||||
import { codexPostureArg } from "../adapters/yolo-mode.js";
|
||||
|
||||
// L3 adds `attention_required` for the Claude resume-selection prompt proxy.
|
||||
// Distinct from `inconclusive` (we don't know yet) and `failed` (terminal
|
||||
@@ -48,18 +48,12 @@ export function buildCodexResumeCore(
|
||||
useLast?: boolean,
|
||||
extraArgs?: string,
|
||||
): string {
|
||||
// OPR.0.4.8.2 agnostic rip-out (Codex floor): the no-profile branch NO LONGER forces
|
||||
// `-s danger-full-access` / `-a on-request`. Codex's own no-flags default is restricted-fs +
|
||||
// OnRequest approval (the workspace-only floor the founder selected — confirmed via
|
||||
// `codex doctor`), so "setting nothing" IS the floor. A named config profile still governs itself.
|
||||
// OPR.0.4.8.2: YOLO (opt-in) forces the full-bypass launch flag on EVERY seat (overriding even a
|
||||
// named profile); otherwise a named profile governs itself and the no-profile case is the harness
|
||||
// default (no forced flags). Launch-flag surface only.
|
||||
const profileOrPosture = yoloEnabled()
|
||||
? " --dangerously-bypass-approvals-and-sandbox"
|
||||
: codexConfigProfile
|
||||
? ` -p ${shellQuote(codexConfigProfile)}`
|
||||
: "";
|
||||
// OPR.0.4.8.2: the RESUME path uses the SAME posture decision (codexPostureArg) as fresh/fork.
|
||||
// YOLO forces full-bypass (overriding even a named profile); otherwise a named profile governs
|
||||
// itself and the no-profile case is the harness-default floor (no forced -a/-s; Codex's no-flags
|
||||
// default is restricted-fs + OnRequest, the workspace-only floor confirmed via `codex doctor`).
|
||||
const profileArg = codexConfigProfile ? ` -p ${shellQuote(codexConfigProfile)}` : "";
|
||||
const profileOrPosture = codexPostureArg(profileArg);
|
||||
const middle = extraArgs ? `${extraArgs} ` : "";
|
||||
const tokenArg = useLast ? "--last" : shellQuote(resumeToken);
|
||||
return `codex${profileOrPosture} resume ${middle}${tokenArg}`;
|
||||
|
||||
@@ -61,7 +61,8 @@ describe("ClaudeResumeAdapter", () => {
|
||||
|
||||
expect(sendText).toHaveBeenCalledOnce();
|
||||
expect(sendText.mock.calls[0]![0]).toBe("r99-demo1-lead");
|
||||
expect(sendText.mock.calls[0]![1]).toBe("claude --resume 'my-session'");
|
||||
// OPR.0.4.8.2: restore now carries the launch-posture floor (acceptEdits), same as fresh.
|
||||
expect(sendText.mock.calls[0]![1]).toBe("claude --permission-mode acceptEdits --resume 'my-session'");
|
||||
expect(sendKeys).toHaveBeenCalledOnce();
|
||||
expect(sendKeys.mock.calls[0]![0]).toBe("r99-demo1-lead");
|
||||
expect(sendKeys.mock.calls[0]![1]).toEqual(["Enter"]);
|
||||
@@ -104,7 +105,7 @@ describe("ClaudeResumeAdapter", () => {
|
||||
|
||||
await adapter.resume("r99-demo1-lead", "claude_name", "tok; rm -rf /", "/repo");
|
||||
|
||||
expect(sendText.mock.calls[0]![1]).toBe("claude --resume 'tok; rm -rf /'");
|
||||
expect(sendText.mock.calls[0]![1]).toBe("claude --permission-mode acceptEdits --resume 'tok; rm -rf /'");
|
||||
});
|
||||
|
||||
it("sendKeys(Enter) fails after sendText -> C-c sent to clear buffer", async () => {
|
||||
@@ -237,7 +238,7 @@ describe("ClaudeResumeAdapter", () => {
|
||||
}
|
||||
// Explicitly: only the launch command + Enter were sent.
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendText.mock.calls[0]![1]).toContain("claude --resume");
|
||||
expect(sendText.mock.calls[0]![1]).toContain("claude --permission-mode acceptEdits --resume");
|
||||
expect(sendKeys).toHaveBeenCalledTimes(1);
|
||||
expect(sendKeys.mock.calls[0]![1]).toEqual(["Enter"]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { yoloEnabled } from "../src/adapters/yolo-mode.js";
|
||||
import { yoloEnabled, codexPostureArg, piTrust } from "../src/adapters/yolo-mode.js";
|
||||
import { buildCodexResumeCore } from "../src/domain/native-resume-probe.js";
|
||||
import { ClaudeCodeAdapter, type ClaudeAdapterFsOps } from "../src/adapters/claude-code-adapter.js";
|
||||
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
|
||||
import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
@@ -89,4 +91,61 @@ describe("OPR.0.4.8.2 YOLO mode — opt-in, default OFF, launch-flag surface onl
|
||||
expect(on).toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
expect(on).not.toContain("-p 'my-profile'");
|
||||
});
|
||||
|
||||
// ── The three managed launch paths the fresh-only wiring missed (guard finding) ──
|
||||
|
||||
it("Claude RESTORE (ClaudeResumeAdapter) carries the posture flag: OFF floor / ON bypass", async () => {
|
||||
delete process.env.OPENRIG_YOLO;
|
||||
const tmuxOff = mockTmux();
|
||||
await new ClaudeResumeAdapter(tmuxOff).resume("r01-impl", "claude_name", "my-session", "/repo");
|
||||
const off = (tmuxOff.sendText as ReturnType<typeof vi.fn>).mock.calls[0]?.[1] as string;
|
||||
expect(off).toContain("--permission-mode acceptEdits");
|
||||
expect(off).toContain("--resume 'my-session'");
|
||||
|
||||
process.env.OPENRIG_YOLO = "1";
|
||||
const tmuxOn = mockTmux();
|
||||
await new ClaudeResumeAdapter(tmuxOn).resume("r01-impl", "claude_name", "my-session", "/repo");
|
||||
const on = (tmuxOn.sendText as ReturnType<typeof vi.fn>).mock.calls[0]?.[1] as string;
|
||||
expect(on).toContain("--dangerously-skip-permissions");
|
||||
expect(on).not.toContain("--permission-mode acceptEdits");
|
||||
});
|
||||
|
||||
it("Codex native FORK carries the posture: OFF no bypass / ON --dangerously-bypass-approvals-and-sandbox", async () => {
|
||||
const codexFs = {
|
||||
readFile: () => { throw new Error("nf"); },
|
||||
writeFile: () => {},
|
||||
exists: () => false,
|
||||
mkdirp: () => {},
|
||||
copyFile: () => {},
|
||||
listFiles: () => [],
|
||||
} as unknown as ConstructorParameters<typeof CodexRuntimeAdapter>[0]["fsOps"];
|
||||
const forkOpts = { name: "dev-impl@test-rig", forkSource: { kind: "native_id" as const, value: "parent-123" } };
|
||||
|
||||
delete process.env.OPENRIG_YOLO;
|
||||
const tmuxOff = mockTmux();
|
||||
await new CodexRuntimeAdapter({ tmux: tmuxOff, fsOps: codexFs }).launchHarness(makeBinding(), forkOpts);
|
||||
const off = (tmuxOff.sendText as ReturnType<typeof vi.fn>).mock.calls[0]?.[1] as string;
|
||||
expect(off).toContain("fork");
|
||||
expect(off).not.toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
|
||||
process.env.OPENRIG_YOLO = "1";
|
||||
const tmuxOn = mockTmux();
|
||||
await new CodexRuntimeAdapter({ tmux: tmuxOn, fsOps: codexFs }).launchHarness(makeBinding(), forkOpts);
|
||||
const on = (tmuxOn.sendText as ReturnType<typeof vi.fn>).mock.calls[0]?.[1] as string;
|
||||
expect(on).toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
expect(on).toContain("fork");
|
||||
});
|
||||
|
||||
it("codexPostureArg: OFF passes the profile arg through; ON forces the bypass flag", () => {
|
||||
expect(codexPostureArg(" -p 'x'", {} as NodeJS.ProcessEnv)).toBe(" -p 'x'");
|
||||
expect(codexPostureArg("", {} as NodeJS.ProcessEnv)).toBe("");
|
||||
expect(codexPostureArg(" -p 'x'", { OPENRIG_YOLO: "1" } as NodeJS.ProcessEnv)).toBe(" --dangerously-bypass-approvals-and-sandbox");
|
||||
});
|
||||
|
||||
it("piTrust (RESOURCE TRUST, not permission policy): OFF keeps configured/no-approve; ON forces approve", () => {
|
||||
expect(piTrust("no-approve", {} as NodeJS.ProcessEnv)).toBe("no-approve");
|
||||
expect(piTrust(undefined, {} as NodeJS.ProcessEnv)).toBe("no-approve");
|
||||
expect(piTrust("approve", {} as NodeJS.ProcessEnv)).toBe("approve");
|
||||
expect(piTrust("no-approve", { OPENRIG_YOLO: "1" } as NodeJS.ProcessEnv)).toBe("approve");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user