From cd74cbbdd4d197484ea96d5696fd0744ee315d8a Mon Sep 17 00:00:00 2001 From: flint Date: Fri, 2 Oct 2026 19:33:56 +0000 Subject: [PATCH 1/7] fix(cli): route the runtime launches through the attested launch (cli#363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent start --runtime claude-code|codex|gemini` branched in the CLI before `runAgent({action:"start"})`, spawned the runtime directly, and so never reached the attested launch — it was not confined by nono and `--sandbox-required` asserted an isolation that path could not deliver. Route those runtimes through the same attested launch as every other agent start: the runtime is carried into the nono re-exec and runs inside the launcher's session, so a `--sandbox-required` launch is confined (or refused before anything is spawned when confinement is unavailable). Retire the slice-A refusal (rule 2b and `attestationExemptRuntime`). --- ...ed-363-runtime-sandbox-required-refuses.md | 10 - .../fixed-363-runtimes-attested-launch.md | 8 + packages/cli/bin/tps.ts | 26 +- packages/cli/src/commands/agent.ts | 14 + packages/cli/src/utils/launch-attestation.ts | 19 +- packages/cli/src/utils/nono.ts | 53 +-- .../cli/test/runtime-attested-launch.test.ts | 319 ++++++++++++++++++ .../runtime-sandbox-required-refuses.test.ts | 146 -------- 8 files changed, 377 insertions(+), 218 deletions(-) delete mode 100644 .changelog/unreleased/fixed-363-runtime-sandbox-required-refuses.md create mode 100644 .changelog/unreleased/fixed-363-runtimes-attested-launch.md create mode 100644 packages/cli/test/runtime-attested-launch.test.ts delete mode 100644 packages/cli/test/runtime-sandbox-required-refuses.test.ts diff --git a/.changelog/unreleased/fixed-363-runtime-sandbox-required-refuses.md b/.changelog/unreleased/fixed-363-runtime-sandbox-required-refuses.md deleted file mode 100644 index f3b4d2a7..00000000 --- a/.changelog/unreleased/fixed-363-runtime-sandbox-required-refuses.md +++ /dev/null @@ -1,10 +0,0 @@ -- **`agent start --runtime claude-code|codex|gemini` with `--sandbox-required` is refused before dispatch; that path does not run attested (Refs #363).** - - Those three runtimes branch in the CLI before the attested launch and spawn - the runtime directly, so they are not confined by nono. The launch gate keys - "an agent launch must assert `--sandbox-required`" on the command name, so the - flag passed there and the process then ran unconfined — an isolation asserted - that the path could not deliver. The gate now refuses such an invocation before - anything is spawned, with a runtime-specific message unless an earlier launch - control has already refused it. Routing those runtimes through the attested launch - is slice B of the same issue. diff --git a/.changelog/unreleased/fixed-363-runtimes-attested-launch.md b/.changelog/unreleased/fixed-363-runtimes-attested-launch.md new file mode 100644 index 00000000..75f6986f --- /dev/null +++ b/.changelog/unreleased/fixed-363-runtimes-attested-launch.md @@ -0,0 +1,8 @@ +- **`agent start --runtime claude-code|codex|gemini` now runs through the attested launch and is confined by nono (Closes #363).** + + Those three runtimes branched in the CLI before the attested launch and + spawned the runtime directly, so they ran outside nono and a + `--sandbox-required` launch asserted an isolation the path could not deliver. + They now reach the same attested launch as every other agent start: a launch + that asserts `--sandbox-required` is confined, or refused before anything is + spawned when confinement is unavailable. diff --git a/packages/cli/bin/tps.ts b/packages/cli/bin/tps.ts index cbad688d..8f8c20f4 100755 --- a/packages/cli/bin/tps.ts +++ b/packages/cli/bin/tps.ts @@ -465,7 +465,19 @@ async function main() { await runAgent({ action: "run", config: configPath, id: agentId, message }); } else if (action === "start") { const runtimeArg = process.argv.includes("--runtime") ? process.argv[process.argv.indexOf("--runtime") + 1] : undefined; - if (runtimeArg === "claude-code" || runtimeArg === "codex" || runtimeArg === "gemini") { + const attestedRuntime = runtimeArg === "claude-code" || runtimeArg === "codex" || runtimeArg === "gemini"; + const sandboxed = process.argv.includes("--sandboxed"); + const noSandbox = process.argv.includes("--no-sandbox"); + // cli#363 slice B: the three runtime runners are reached only on the + // EXECUTION side — inside the launcher's nono session (`--sandboxed`), + // or under an interactive human `--no-sandbox` opt-out. Every other + // invocation routes through `runAgent({action:"start"})` carrying + // `--runtime`, so the runtime reaches the SAME attested launch as every + // other agent start and is confined like it. There is no unconfined + // runtime spawn path: `--sandboxed` is only honoured with the + // launcher's release (attested), and `--no-sandbox` is the documented + // interactive escape hatch the launch gate already governs. + if (attestedRuntime && (sandboxed || noSandbox)) { // Claude Code CLI runtime — OAuth, no TPS proxy needed const { join } = await import("node:path"); const { homedir } = await import("node:os"); @@ -587,7 +599,17 @@ async function main() { if (stopResult.changed) console.log(`[${agentId}] worktree removed: ${stopResult.reason}`); } } else { - await runAgent({ action: "start", config: configPath, id: agentId, sandbox: !process.argv.includes("--no-sandbox"), sandboxed: process.argv.includes("--sandboxed"), sandboxRequired: process.argv.includes("--sandbox-required") }); + await runAgent({ + action: "start", + config: configPath, + id: agentId, + sandbox: !noSandbox, + sandboxed, + sandboxRequired: process.argv.includes("--sandbox-required"), + // Carry the runtime into the re-exec so the sandboxed child runs the + // runtime runner (cli#363 slice B); undefined for the default path. + runtime: attestedRuntime ? runtimeArg : undefined, + }); } } else { await runAgent({ action: "health", config: configPath, id: agentId }); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index c8cf425c..efd89804 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -61,6 +61,12 @@ export interface AgentArgs { sandboxed?: boolean; /** The launch unit asserted --sandbox-required; carry it into the re-exec. */ sandboxRequired?: boolean; + /** + * The selected agent runtime (`--runtime claude-code|codex|gemini`). Carried + * into the attested re-exec so the sandboxed child runs the runtime runner + * (cli#363 slice B); undefined for the default AgentRuntime path. + */ + runtime?: string; lines?: number; follow?: boolean; ackScopeExpansion?: boolean; @@ -814,6 +820,10 @@ export async function runAgent(args: AgentArgs): Promise { const sandbox = (args as any).sandbox ?? true; // default ON — nono is the required isolation layer const sandboxed = (args as any).sandboxed ?? false; const sandboxRequired = (args as any).sandboxRequired ?? process.argv.includes("--sandbox-required"); + // cli#363 slice B: carry the selected runtime into the re-exec so the + // sandboxed child runs the runtime runner rather than the default + // AgentRuntime. Only the three attested runtimes ever reach here. + const selectedRuntime = args.runtime; // The pinned ABSOLUTE path (never PATH) — the same resolution the // launcher performs, so the decision to launch and the launch itself // cannot disagree about which nono is in play (cli#350 round 4e). Using @@ -869,6 +879,10 @@ export async function runAgent(args: AgentArgs): Promise { "--sandboxed", ]; if (sandboxRequired) relaunch.push("--sandbox-required"); + // Carry the runtime through the attested launch (cli#363 slice B): + // the sandboxed child re-enters bin/tps.ts, which runs the runtime + // runner on the execution side. + if (selectedRuntime) relaunch.push("--runtime", selectedRuntime); // THE ATTESTED LAUNCH (cli#350 round 4e): the launcher creates the // private dir, plants the canaries, spawns nono by absolute path, // and releases the child over its own socket only after `nono ps` diff --git a/packages/cli/src/utils/launch-attestation.ts b/packages/cli/src/utils/launch-attestation.ts index da691a98..7b0af9e7 100644 --- a/packages/cli/src/utils/launch-attestation.ts +++ b/packages/cli/src/utils/launch-attestation.ts @@ -7,19 +7,12 @@ * AND to the pid the child reports, with enforcement verified BEHAVIOURALLY from * outside the sandbox — or the child is never released. * - * KNOWN EXEMPTION, stated here so this comment does not overstate its own reach - * (found in review of the v0.6.0 release, 2026-09-17): `tps agent start - * --runtime claude-code|codex|gemini` branches in `bin/tps.ts` BEFORE reaching - * `runAgent`, and spawns the runtime directly — so it never arrives here and is - * NOT confined by nono. `launchesAgent()` (`nono.ts`) still keys on the command - * name, so that path still reads as an agent launch to the gate; the gate - * therefore refuses an invocation carrying `--sandbox-required` on it before - * dispatch, unless an earlier launch control has already refused it (cli#363 - * slice A), because the - * flag asserts an isolation the path cannot deliver. Do not read "every launch - * through `tps agent start`" anywhere in this file or the release notes as - * covering those three runtimes. Routing them through this attestation is - * tracked in cli#363. + * `tps agent start --runtime claude-code|codex|gemini` reaches this launch too + * (cli#363 slice B): `bin/tps.ts` carries the runtime into the re-exec, so the + * runtime runner runs inside the nono session this module starts — the same + * confinement as every other agent launch. A `--sandbox-required` launch on + * those runtimes is confined, or refused before anything is spawned when + * confinement is unavailable. * * Why behavioural, not a nono audit record (round 4e): nono 0.74.0 writes its * per-session `sandbox_runtime` audit record ONLY when tool-sandbox is active diff --git a/packages/cli/src/utils/nono.ts b/packages/cli/src/utils/nono.ts index 6c7ac53f..a3e8f3b5 100644 --- a/packages/cli/src/utils/nono.ts +++ b/packages/cli/src/utils/nono.ts @@ -21,12 +21,12 @@ * - A non-interactive invocation that launches an agent MUST carry * `--sandbox-required`; a launcher that dropped it is refused rather than * silently running unsandboxed. See `evaluateLaunchControl`. - * - an `agent start --runtime claude-code|codex|gemini` invocation carrying - * `--sandbox-required` is refused, by this rule unless an earlier one - * already refused it: those runtimes are spawned directly and never - * reach the attested launch, so the flag would assert an isolation that - * path cannot deliver (cli#363 slice A; routing them through the attested - * launch is slice B). + * - `agent start --runtime claude-code|codex|gemini` reaches the SAME attested + * launch as the default path (cli#363 slice B): the runtime is carried into + * the re-exec and runs inside the launcher's nono session, so a + * `--sandbox-required` launch is confined, or refused before anything is + * spawned when confinement is unavailable. There is no unconfined runtime + * path left. * - Under `--sandboxed` the child must hold the launcher's release for a live * nono session bound to its own pid (cli#350 round 4e): see * `launch-attestation.ts`. `--sandboxed` means "my launcher released me" — @@ -671,30 +671,6 @@ export function launchesAgent(command: string | undefined, rest: readonly string return false; } -/** - * The three runtimes that skip the attested launch (cli#363). `bin/tps.ts` - * branches on `--runtime ` BEFORE `runAgent({action:"start"})`, - * spawns the runtime directly, and never reaches `launchAttested()`: a launch on - * that path is NOT confined by nono, so it cannot honour `--sandbox-required`. - */ -export const ATTESTATION_EXEMPT_RUNTIMES: readonly string[] = ["claude-code", "codex", "gemini"]; - -/** The exempt runtime `argv` selects via `--runtime `, or undefined. */ -export function attestationExemptRuntime(argv: readonly string[] = process.argv): string | undefined { - const i = argv.indexOf("--runtime"); - const value = i >= 0 ? argv[i + 1] : undefined; - return value !== undefined && ATTESTATION_EXEMPT_RUNTIMES.includes(value) ? value : undefined; -} - -/** The refusal for `--sandbox-required` on a path that does not run attested. */ -export function attestationExemptRefusal(runtime: string): string { - return ( - `${SANDBOX_REQUIRED_FLAG} is refused on the \`--runtime ${runtime}\` path: that runtime is spawned ` + - `directly and is not launched through the attested sandbox yet (cli#363), so the flag would ` + - `assert an isolation this path cannot deliver. Refusing to launch.` - ); -} - export interface LaunchControlInput { /** Top-level command word (argv[2]). */ command?: string; @@ -772,23 +748,6 @@ export function evaluateLaunchControl(input: LaunchControlInput = {}): LaunchCon ); } - // (2b) cli#363 — a launch on the `--runtime` branch cannot honour - // `--sandbox-required`, because it never reaches the attested launch. The - // command-name check above is satisfied by `agent start`, so before this rule - // the flag passed the gate and the process then ran unconfined: a guarantee - // the path cannot deliver, read as delivered. Refused, TTY included, unless an - // earlier rule has already refused, until those runtimes are routed through - // `launchAttested()`. - const exemptRuntime = attestationExemptRuntime(argv); - if ( - exemptRuntime !== undefined && - input.command === "agent" && - input.rest?.[0] === "start" && - argv.includes(SANDBOX_REQUIRED_FLAG) - ) { - return deny(attestationExemptRefusal(exemptRuntime)); - } - return { allowed: true, refusalExitCode: 0 }; } diff --git a/packages/cli/test/runtime-attested-launch.test.ts b/packages/cli/test/runtime-attested-launch.test.ts new file mode 100644 index 00000000..6ccf28ff --- /dev/null +++ b/packages/cli/test/runtime-attested-launch.test.ts @@ -0,0 +1,319 @@ +/** + * cli#363 slice B — `agent start --runtime claude-code|codex|gemini` runs + * through the attested launch, so it is confined like every other agent launch. + * + * Slice A refused `--sandbox-required` on these runtimes because `bin/tps.ts` + * branched on `--runtime` BEFORE `runAgent({action:"start"})` and spawned the + * runtime directly, so it never reached `launchAttested()` and was not confined + * by nono. Slice B routes them through the same attested launch (the runtime is + * carried into the nono re-exec), so the flag is honoured rather than refused. + * + * These tests are black box and FAIL on `main`: they spawn the built CLI with + * piped stdio (non-TTY, the shape a generated unit or a wrapper uses) against a + * fake nono at an absolute path, and assert the REAL launch decision — that the + * launcher spawned nono for a re-exec that carries `--runtime ` and released + * the child — never an exported helper. The fake nono "confines" the way the + * profile does (it denies the child the launcher's OUTSIDE canary), so the child + * attests and the launcher releases it; on `main` the slice-A refusal fires + * instead and nono is never spawned. + */ +import { describe, test, expect, beforeAll, setDefaultTimeout } from "bun:test"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +const TPS_BIN = resolve(import.meta.dir, "../dist/bin/tps.js"); +const NODE = + process.env.TPS_TEST_NODE ?? + (spawnSync("which", ["node"], { encoding: "utf-8" }).stdout?.trim() || "node"); +const SANDBOX_REQUIRED = "--sandbox-required"; +const NONO_BIN_ENV = "NONO_BIN"; +const TIMEOUT_ENV = "TPS_LAUNCH_TIMEOUT_MS"; +const RUNTIMES = ["claude-code", "codex", "gemini"] as const; + +// Real launches wait on a release window; raise the file default above it. +setDefaultTimeout(60_000); + +beforeAll(() => { + if (!existsSync(TPS_BIN)) throw new Error(`tps binary not found at ${TPS_BIN}. Run 'bun run build' first.`); +}); + +interface Sandbox { + root: string; + home: string; + tmp: string; + ws: string; + nonoDir: string; + nonoLog: string; +} + +/** A fixture HOME (agent.yaml, mail, identity, JSON profile pair) + a nono dir. */ +function seedHome(home: string, ws: string): void { + const agentDir = join(home, ".tps", "agents", "probe"); + const profileDir = join(home, ".config", "nono", "profiles"); + for (const d of [agentDir, profileDir, join(home, ".tps", "mail"), join(home, ".tps", "identity")]) { + mkdirSync(d, { recursive: true }); + } + const base = { + $schema: "https://nono.sh/schemas/nono-profile.schema.json", + meta: { name: "tps-base-fixture" }, + workdir: { access: "readwrite" }, + filesystem: { read: ["/usr", "/bin", "/lib", "/lib64"], deny: [] }, + }; + const run = { + $schema: "https://nono.sh/schemas/nono-profile.schema.json", + extends: "tps-base-fixture", + meta: { name: "tps-agent-run" }, + workdir: { access: "readwrite" }, + }; + writeFileSync(join(profileDir, "tps-base-fixture.json"), JSON.stringify(base, null, 2)); + writeFileSync(join(profileDir, "tps-agent-run.json"), JSON.stringify(run, null, 2)); + writeFileSync( + join(agentDir, "agent.yaml"), + `agentId: probe\nname: probe\nworkspace: ${ws}\n` + + `mailDir: ${join(home, ".tps", "mail")}\n` + + `memoryPath: ${join(agentDir, "memory.jsonl")}\n` + + `llm:\n provider: ollama\n model: probe-model\n` + ); + writeFileSync(join(home, ".tps", "identity", "probe.key"), "fixture-key\n"); + writeFileSync(join(home, ".tps", "identity", "probe.pub"), "fixture-pub\n"); +} + +/** OUTSIDE /tmp: the launch grants /tmp, so a HOME under /tmp would put the + * private dir inside that grant and the overlap assert would refuse (correctly). */ +function makeSandbox(): Sandbox { + const base = existsSync("/var/tmp") ? "/var/tmp" : homedir(); + const root = mkdtempSync(join(base, "tps-363-rt-")); + const home = join(root, "home"); + const tmp = join(root, "tmp"); + const ws = join(root, "ws"); + const nonoDir = join(root, "nono"); + for (const d of [home, tmp, ws, nonoDir]) mkdirSync(d, { recursive: true }); + seedHome(home, ws); + return { root, home, tmp, ws, nonoDir, nonoLog: join(root, "nono.log") }; +} + +/** + * A fake nono that "confines": for `run` it denies the child the launcher's + * OUTSIDE canary (chmod 000 — the launcher read it BEFORE the spawn), starts the + * wrapped command as its own child, and publishes a `ps` store bound to the real + * pids it spawned, so the launcher's canary + binding checks pass and it + * RELEASES the child. Anything else (`--version`, `profile validate`) exits 0. + */ +const CONFINING_FAKE_NONO = `#!/usr/bin/env bash +set -u +if [ "\${1:-}" = "--version" ]; then echo "nono 0.74.0"; exit 0; fi +log="\${FAKE_NONO_LOG:?}" +printf '%s\\n' "ARGV $*" >> "$log" +if [ "\${1:-}" = "ps" ]; then + if [ -n "\${FAKE_NONO_PS_JSON:-}" ] && [ -f "\${FAKE_NONO_PS_JSON}" ]; then cat "\${FAKE_NONO_PS_JSON}"; else echo "[]"; fi + exit 0 +fi +if [ "\${1:-}" = "run" ]; then + cmd=(); seen=0 + for a in "$@"; do if [ "$seen" = 1 ]; then cmd+=("$a"); fi; if [ "$a" = "--" ]; then seen=1; fi; done + if [ -n "\${TPS_LAUNCH_SOCK:-}" ]; then + priv="$(dirname "$(dirname "$TPS_LAUNCH_SOCK")")" + [ -f "$priv/canary-outside" ] && chmod 000 "$priv/canary-outside" + fi + "\${cmd[@]}" & + child=$! + echo "CHILD $child SUP \$\$" >> "$log" + prof=""; args=("$@"); i=0 + for ((i=0; i<\${#args[@]}; i++)); do [ "\${args[$i]}" = "--profile" ] && prof="\${args[$((i+1))]}"; done + printf '[{"session_id":"fixture","supervisor_pid":%s,"child_pid":%s,"status":"running","profile":"%s"}]\\n' "$$" "$child" "$prof" > "\${FAKE_NONO_PS_JSON:?}" + wait "$child"; exit $? +fi +exit 0 +`; + +function writeFakeNono(sb: Sandbox, script: string): string { + const path = join(sb.nonoDir, "nono"); + writeFileSync(path, script); + chmodSync(path, 0o755); + return path; +} + +function cliEnv(sb: Sandbox, extra: Record = {}): Record { + const base: Record = { + ...(process.env as Record), + HOME: sb.home, + TMPDIR: sb.tmp, + FAKE_NONO_LOG: sb.nonoLog, + [TIMEOUT_ENV]: "8000", + }; + for (const [k, v] of Object.entries(extra)) { + if (v === undefined) delete base[k]; + else base[k] = v; + } + return base; +} + +/** ARGV lines the fake nono logged for a `run` (the launcher's spawn). */ +function fakeNonoRuns(sb: Sandbox): string[] { + if (!existsSync(sb.nonoLog)) return []; + return readFileSync(sb.nonoLog, "utf-8") + .split("\n") + .filter((l) => l.startsWith("ARGV run")) + .map((l) => l.slice(5)); +} + +/** Spawn the launcher, accumulate output, stop the whole tree when done. */ +async function runUntil( + sb: Sandbox, + args: string[], + extra: Record, + done: (text: string) => boolean, + waitMs: number +): Promise<{ text: string; stopped: boolean }> { + const child: ChildProcess = spawn(NODE, [TPS_BIN, ...args], { + cwd: sb.ws, + env: cliEnv(sb, extra), + // Own process group so a fixture can stop the launcher, the fake nono and + // the wrapped runtime in one signal — never a pattern kill. + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let text = ""; + const reached = await new Promise((resolvePromise) => { + const timer = setTimeout(() => resolvePromise(false), waitMs); + const onData = (chunk: Buffer) => { + text += chunk.toString("utf-8"); + if (done(text)) { + clearTimeout(timer); + resolvePromise(true); + } + }; + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + child.on("exit", () => { + clearTimeout(timer); + resolvePromise(done(text)); + }); + }); + try { + if (child.pid) process.kill(-child.pid, "SIGKILL"); + } catch { + /* already gone */ + } + return { text, stopped: reached }; +} + +const real = process.getuid?.() !== 0 ? describe : describe.skip; + +real("cli#363 slice B — the three runtimes reach the attested launch", () => { + for (const rt of RUNTIMES) { + test(`agent start --runtime ${rt} --sandbox-required is routed through the launcher and released`, async () => { + const sb = makeSandbox(); + try { + const bin = writeFakeNono(sb, CONFINING_FAKE_NONO); + const { text, stopped } = await runUntil( + sb, + ["agent", "start", "--id", "probe", "--runtime", rt, SANDBOX_REQUIRED], + { [NONO_BIN_ENV]: bin, FAKE_NONO_PS_JSON: join(sb.root, "ps.json") }, + (t) => t.includes("released under nono session"), + 30_000 + ); + expect(stopped).toBe(true); + // The launcher's release is proof of confinement (canaries + session + // binding); its absence would mean the control refused. + expect(text).toContain("released under nono session"); + // The runtime is not refused by the retired slice-A rule. + expect(text).not.toContain("not launched through the attested sandbox"); + expect(text).not.toContain("READ the OUTSIDE canary"); + // The launcher spawned nono for a re-exec that carries this runtime and + // the sandboxed marker — i.e. the runtime launch went through the + // attested launch, not a direct spawn. + const runs = fakeNonoRuns(sb).join("\n"); + expect(runs).toContain("run --profile"); + expect(runs).toContain(`--runtime ${rt}`); + expect(runs).toContain("--sandboxed"); + expect(runs).toContain("agent start"); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }, 40_000); + } +}); + +describe("cli#363 slice B — the existing launch refusals still apply on the runtime path", () => { + for (const rt of RUNTIMES) { + test(`agent start --runtime ${rt} --no-sandbox outside a TTY is refused 78, before any spawn`, () => { + const sb = makeSandbox(); + try { + const r = spawnSync(NODE, [TPS_BIN, "agent", "start", "--id", "probe", "--runtime", rt, "--no-sandbox"], { + encoding: "utf-8", + cwd: sb.ws, + timeout: 20_000, + env: cliEnv(sb), + }); + const text = `${r.stdout ?? ""}${r.stderr ?? ""}`; + expect(text).toContain("--no-sandbox is refused"); + expect(r.status).toBe(78); + expect(fakeNonoRuns(sb)).toEqual([]); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }, 25_000); + } + + for (const rt of RUNTIMES) { + test(`agent start --runtime ${rt} without --sandbox-required (non-TTY) is refused 78, before any spawn`, () => { + const sb = makeSandbox(); + try { + const r = spawnSync(NODE, [TPS_BIN, "agent", "start", "--id", "probe", "--runtime", rt], { + encoding: "utf-8", + cwd: sb.ws, + timeout: 20_000, + env: cliEnv(sb), + }); + const text = `${r.stdout ?? ""}${r.stderr ?? ""}`; + expect(text).toContain(`${SANDBOX_REQUIRED} is required`); + expect(r.status).toBe(78); + expect(fakeNonoRuns(sb)).toEqual([]); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }, 25_000); + } +}); + +describe("cli#363 slice B — confinement unavailable is refused before any spawn", () => { + for (const rt of RUNTIMES) { + test(`agent start --runtime ${rt} --sandbox-required with no pinned nono refuses 78, before spawning the runtime`, () => { + const sb = makeSandbox(); + try { + // A fake runtime binary on PATH: if the CLI spawned the runtime despite + // no nono, it would write this marker. It must not. + const fakeBinDir = join(sb.root, "fakebin"); + mkdirSync(fakeBinDir, { recursive: true }); + const marker = join(sb.root, "runtime-spawned"); + const runtimeCmd = rt === "claude-code" ? "claude" : rt; // claude-code spawns `claude` + const fake = join(fakeBinDir, runtimeCmd); + writeFileSync(fake, `#!/usr/bin/env bash\necho spawned > ${JSON.stringify(marker)}\n`); + chmodSync(fake, 0o755); + + const r = spawnSync(NODE, [TPS_BIN, "agent", "start", "--id", "probe", "--runtime", rt, SANDBOX_REQUIRED], { + encoding: "utf-8", + cwd: sb.ws, + timeout: 20_000, + env: cliEnv(sb, { + [NONO_BIN_ENV]: join(sb.nonoDir, "does-not-exist"), + PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, + }), + }); + const text = `${r.stdout ?? ""}${r.stderr ?? ""}`; + // On `main` the retired slice-A rule refuses instead; this reason is the + // attested launch's own missing-nono refusal, so the assertion is red there. + expect(text).toContain("no nono at the pinned absolute path"); + expect(text).not.toContain("not launched through the attested sandbox"); + expect(r.status).toBe(78); + expect(fakeNonoRuns(sb)).toEqual([]); // nothing was launched + expect(existsSync(marker)).toBe(false); // the runtime was never spawned + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }, 25_000); + } +}); diff --git a/packages/cli/test/runtime-sandbox-required-refuses.test.ts b/packages/cli/test/runtime-sandbox-required-refuses.test.ts deleted file mode 100644 index ce045679..00000000 --- a/packages/cli/test/runtime-sandbox-required-refuses.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * cli#363 slice A — `--sandbox-required` is refused on the `agent start - * --runtime claude-code|codex|gemini` path. - * - * Those three runtimes branch in `bin/tps.ts` BEFORE `runAgent({action:"start"})` - * and spawn the runtime directly, so they never reach the attested launch - * (`launch-attestation.ts`) and are NOT confined by nono. `launchesAgent()` - * (`nono.ts`) keys on the command name, so the launch gate used to accept - * `--sandbox-required` on that path and the process then ran unconfined: the - * flag asserted an isolation the path cannot deliver. The gate now refuses the - * flag there, before dispatch, TTY included, unless an earlier launch control - * has already refused (those paths fail closed too). - * - * Black-box: spawns the built CLI with piped stdio (non-TTY), the same shape a - * wrapper or a unit passes `--runtime` through. Flag literals are duplicated - * here on purpose: this file must fail on `main`, where the refusal does not - * exist. - */ -import { describe, test, expect, beforeAll } from "bun:test"; -import { resolve, join } from "node:path"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { spawnSync } from "node:child_process"; -import { evaluateLaunchControl } from "../src/utils/nono.js"; - -const TPS_BIN = resolve(import.meta.dir, "../dist/bin/tps.js"); -const SANDBOX_REQUIRED = "--sandbox-required"; -const RUNTIMES = ["claude-code", "codex", "gemini"] as const; - -interface Probe { - home: string; - env: Record; - cleanup: () => void; -} - -/** A throwaway HOME. */ -function probe(): Probe { - const home = mkdtempSync(join(tmpdir(), "tps-363-runtime-")); - const env: Record = { - ...process.env, - HOME: home, - TPS_HOME: home, - }; - // The refusal's exit code depends on supervisor detection; this test pins the - // launcher (78) rather than the supervised 0. - delete env.TPS_SUPERVISED; - return { home, env, cleanup: () => rmSync(home, { recursive: true, force: true }) }; -} - -function runLauncher(args: string[], env: Record) { - return spawnSync("bun", [TPS_BIN, ...args], { - encoding: "utf-8", - timeout: 5000, - killSignal: "SIGKILL", - env, - }); -} - -function output(r: { stdout?: string | null; stderr?: string | null }): string { - return `${r.stdout ?? ""}${r.stderr ?? ""}`; -} - -beforeAll(() => { - if (!existsSync(TPS_BIN)) throw new Error(`tps binary not found at ${TPS_BIN}. Run 'bun run build' first.`); -}); - -describe("cli#363 — --sandbox-required is refused on the unattested runtime path", () => { - for (const rt of RUNTIMES) { - test(`agent start --runtime ${rt} --sandbox-required: refused 78, naming the runtime`, () => { - const p = probe(); - try { - const r = runLauncher( - ["agent", "start", "--id", "ghost", "--runtime", rt, SANDBOX_REQUIRED], - p.env, - ); - const out = output(r); - expect(r.status).toBe(78); - expect(out).toContain(`--runtime ${rt}`); // names the runtime - expect(out).toContain(SANDBOX_REQUIRED); - expect(out).toContain("not launched through the attested sandbox"); - expect(out).toContain("#363"); // names the reason's tracking issue - } finally { - p.cleanup(); - } - }); - } - - for (const rt of RUNTIMES) { - test(`agent start --runtime ${rt} without --sandbox-required: the refusal for the flag is not what fires (unchanged)`, () => { - const p = probe(); - try { - const r = runLauncher(["agent", "start", "--id", "ghost", "--runtime", rt], p.env); - const out = output(r); - // Today's behaviour in a non-TTY is the pre-existing rule-2 refusal - // (the launcher must assert the flag). This change does not touch it. - expect(r.status).toBe(78); - expect(out).toContain(`${SANDBOX_REQUIRED} is required`); - expect(out).not.toContain("not launched through the attested sandbox"); - } finally { - p.cleanup(); - } - }); - } -}); - -describe("cli#363 — the launch gate does not treat the runtime branch as attested", () => { - const argv = (runtime: string) => ["bun", "tps", "agent", "start", "--runtime", runtime, SANDBOX_REQUIRED]; - - for (const rt of RUNTIMES) { - test(`evaluateLaunchControl refuses the flag on --runtime ${rt}`, () => { - const r = evaluateLaunchControl({ - command: "agent", - rest: ["start", "--runtime", rt, SANDBOX_REQUIRED], - argv: argv(rt), - interactiveTty: false, - }); - expect(r.allowed).toBe(false); - expect(r.refusal).toContain(`--runtime ${rt}`); - expect(r.refusal).toContain("not launched through the attested sandbox"); - }); - } - - test("the attested path is not refused by that rule (positive control)", () => { - const r = evaluateLaunchControl({ - command: "agent", - rest: ["start", "--id", "ghost", SANDBOX_REQUIRED], - argv: ["bun", "tps", "agent", "start", "--id", "ghost", SANDBOX_REQUIRED], - interactiveTty: false, - }); - expect(r.allowed).toBe(true); - }); - - test("an interactive TTY does not rescue the flag on the runtime branch", () => { - // The TTY case is where the flag was silently ignored: the gate's - // "must assert --sandbox-required" rule needs !tty, and the runtime branch - // never looked at the flag at all. - const r = evaluateLaunchControl({ - command: "agent", - rest: ["start", "--runtime", "codex", SANDBOX_REQUIRED], - argv: ["bun", "tps", "agent", "start", "--runtime", "codex", SANDBOX_REQUIRED], - interactiveTty: true, - }); - expect(r.allowed).toBe(false); - expect(r.refusal).toContain("--runtime codex"); - }); -}); From bf58049f35b1592aa50af4809f62812531adf630 Mon Sep 17 00:00:00 2001 From: flint Date: Fri, 2 Oct 2026 13:45:25 -0700 Subject: [PATCH 2/7] fix(launch): conflicting sandbox flags refused; per-runtime credential grants; tests observe each runner start (#474 review) Co-Authored-By: Claude Opus 5.5 --- .../fixed-363-runtimes-attested-launch.md | 10 +- .github/workflows/test.yml | 2 +- packages/cli/bin/tps.ts | 11 +-- .../tps-agent-run-claude-code.json | 12 +++ .../nono-profiles/tps-agent-run-codex.json | 12 +++ .../nono-profiles/tps-agent-run-gemini.json | 12 +++ packages/cli/src/commands/agent.ts | 10 +- packages/cli/src/utils/launch-attestation.ts | 10 +- packages/cli/src/utils/nono.ts | 49 ++++++++-- .../cli/test/nono-profiles-install.test.ts | 2 +- packages/cli/test/reviewer/ci-job.test.ts | 2 +- .../cli/test/runtime-attested-launch.test.ts | 97 ++++++++++++------- .../cli/test/sandbox-launch-control.test.ts | 2 +- scripts/check-nono-profiles.sh | 2 + scripts/check-runtime-nono-paths.ts | 50 ++++++++++ 15 files changed, 210 insertions(+), 73 deletions(-) create mode 100644 packages/cli/nono-profiles/tps-agent-run-claude-code.json create mode 100644 packages/cli/nono-profiles/tps-agent-run-codex.json create mode 100644 packages/cli/nono-profiles/tps-agent-run-gemini.json create mode 100644 scripts/check-runtime-nono-paths.ts diff --git a/.changelog/unreleased/fixed-363-runtimes-attested-launch.md b/.changelog/unreleased/fixed-363-runtimes-attested-launch.md index 75f6986f..25c6e641 100644 --- a/.changelog/unreleased/fixed-363-runtimes-attested-launch.md +++ b/.changelog/unreleased/fixed-363-runtimes-attested-launch.md @@ -1,8 +1,2 @@ -- **`agent start --runtime claude-code|codex|gemini` now runs through the attested launch and is confined by nono (Closes #363).** - - Those three runtimes branched in the CLI before the attested launch and - spawned the runtime directly, so they ran outside nono and a - `--sandbox-required` launch asserted an isolation the path could not deliver. - They now reach the same attested launch as every other agent start: a launch - that asserts `--sandbox-required` is confined, or refused before anything is - spawned when confinement is unavailable. +- **CLI Claude Code, Codex and Gemini runners require launcher release or an interactive TTY `--no-sandbox` opt-out (Closes #363).** + `--sandbox-required --no-sandbox` is refused. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f8171002..819c2479 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -120,7 +120,7 @@ jobs: # not an OS boundary; the ~/.tps metadata snapshot is a diagnostic. run: | iso_home="$(mktemp -d)" - HOME="$iso_home" bun run test + HOME="$iso_home" TMPDIR="${RUNNER_TEMP:-/var/tmp}" bun run test if [ -e "$iso_home/.tps" ]; then echo "::error::the suite wrote $iso_home/.tps — HOME is not isolated" exit 1 diff --git a/packages/cli/bin/tps.ts b/packages/cli/bin/tps.ts index 8f8c20f4..fcb8fdc8 100755 --- a/packages/cli/bin/tps.ts +++ b/packages/cli/bin/tps.ts @@ -468,15 +468,8 @@ async function main() { const attestedRuntime = runtimeArg === "claude-code" || runtimeArg === "codex" || runtimeArg === "gemini"; const sandboxed = process.argv.includes("--sandboxed"); const noSandbox = process.argv.includes("--no-sandbox"); - // cli#363 slice B: the three runtime runners are reached only on the - // EXECUTION side — inside the launcher's nono session (`--sandboxed`), - // or under an interactive human `--no-sandbox` opt-out. Every other - // invocation routes through `runAgent({action:"start"})` carrying - // `--runtime`, so the runtime reaches the SAME attested launch as every - // other agent start and is confined like it. There is no unconfined - // runtime spawn path: `--sandboxed` is only honoured with the - // launcher's release (attested), and `--no-sandbox` is the documented - // interactive escape hatch the launch gate already governs. + // Selected runners execute after launcher release or an interactive + // TTY `--no-sandbox` opt-out. if (attestedRuntime && (sandboxed || noSandbox)) { // Claude Code CLI runtime — OAuth, no TPS proxy needed const { join } = await import("node:path"); diff --git a/packages/cli/nono-profiles/tps-agent-run-claude-code.json b/packages/cli/nono-profiles/tps-agent-run-claude-code.json new file mode 100644 index 00000000..54e96194 --- /dev/null +++ b/packages/cli/nono-profiles/tps-agent-run-claude-code.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://nono.sh/schemas/nono-profile.schema.json", + "extends": "tps-agent-run", + "meta": { + "name": "tps-agent-run-claude-code" + }, + "groups": { + "exclude": [ + "system_read_macos" + ] + } +} diff --git a/packages/cli/nono-profiles/tps-agent-run-codex.json b/packages/cli/nono-profiles/tps-agent-run-codex.json new file mode 100644 index 00000000..6894d7b1 --- /dev/null +++ b/packages/cli/nono-profiles/tps-agent-run-codex.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://nono.sh/schemas/nono-profile.schema.json", + "extends": "tps-agent-run", + "meta": { + "name": "tps-agent-run-codex" + }, + "groups": { + "exclude": [ + "system_read_macos" + ] + } +} diff --git a/packages/cli/nono-profiles/tps-agent-run-gemini.json b/packages/cli/nono-profiles/tps-agent-run-gemini.json new file mode 100644 index 00000000..09e94b6a --- /dev/null +++ b/packages/cli/nono-profiles/tps-agent-run-gemini.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://nono.sh/schemas/nono-profile.schema.json", + "extends": "tps-agent-run", + "meta": { + "name": "tps-agent-run-gemini" + }, + "groups": { + "exclude": [ + "system_read_macos" + ] + } +} diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index efd89804..d1c3c8ed 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -26,6 +26,8 @@ import { isSupervised, harnessReadPaths, harnessReadFiles, + runtimeNonoOptions, + runtimeNonoProfile, REFUSAL_EXIT_CODE, SUPERVISED_REFUSAL_EXIT_CODE, } from "../utils/nono.js"; @@ -822,7 +824,7 @@ export async function runAgent(args: AgentArgs): Promise { const sandboxRequired = (args as any).sandboxRequired ?? process.argv.includes("--sandbox-required"); // cli#363 slice B: carry the selected runtime into the re-exec so the // sandboxed child runs the runtime runner rather than the default - // AgentRuntime. Only the three attested runtimes ever reach here. + // AgentRuntime. Its CLI caller selects only those three runtimes. const selectedRuntime = args.runtime; // The pinned ABSOLUTE path (never PATH) — the same resolution the // launcher performs, so the decision to launch and the launch itself @@ -888,8 +890,9 @@ export async function runAgent(args: AgentArgs): Promise { // and releases the child over its own socket only after `nono ps` // binds a live session to the pid it spawned and to the pid the // child reports, with the OUTSIDE canary still unreadable to it. + const runtimeGrants = runtimeNonoOptions(selectedRuntime); const exitCode = await launchAttested( - "tps-agent-run", + runtimeNonoProfile(selectedRuntime), { workdir: config.workspace, // CHANGE (cli#341 S1b): this used to grant a read of the @@ -901,12 +904,13 @@ export async function runAgent(args: AgentArgs): Promise { // Exactly this agent's own identity files, not the shared // identity directory (cli#351 r4). readFiles: harnessReadFiles(launchId), + allowFiles: runtimeGrants.allowFiles, // Bun's own temp dir is /tmp regardless of TMPDIR, and an // unreadable temp dir is fatal to it — grant BOTH /tmp and the // configured TMPDIR (cli#350 r4g). On macOS launchd sets TMPDIR // to /var/folders/…, so /tmp would otherwise not be granted at // all; on Linux TMPDIR is usually /tmp and the Set dedupes. - allow: [...new Set([mailDir, tmpDir, "/tmp", config.workspace, agentDir])], + allow: [...new Set([mailDir, tmpDir, "/tmp", config.workspace, agentDir, ...(runtimeGrants.allow ?? [])])], }, relaunch, ); diff --git a/packages/cli/src/utils/launch-attestation.ts b/packages/cli/src/utils/launch-attestation.ts index 7b0af9e7..9201812e 100644 --- a/packages/cli/src/utils/launch-attestation.ts +++ b/packages/cli/src/utils/launch-attestation.ts @@ -7,12 +7,8 @@ * AND to the pid the child reports, with enforcement verified BEHAVIOURALLY from * outside the sandbox — or the child is never released. * - * `tps agent start --runtime claude-code|codex|gemini` reaches this launch too - * (cli#363 slice B): `bin/tps.ts` carries the runtime into the re-exec, so the - * runtime runner runs inside the nono session this module starts — the same - * confinement as every other agent launch. A `--sandbox-required` launch on - * those runtimes is confined, or refused before anything is spawned when - * confinement is unavailable. + * CLI selected runtime runners require launcher release or an interactive TTY + * `--no-sandbox` opt-out. Conflicting `--sandbox-required` is refused by the gate. * * Why behavioural, not a nono audit record (round 4e): nono 0.74.0 writes its * per-session `sandbox_runtime` audit record ONLY when tool-sandbox is active @@ -275,7 +271,7 @@ export function grantsOfOptions( workdir: options.workdir, cwd, read: [...(options.read ?? [])], - readFiles: [...(options.readFiles ?? [])], + readFiles: [...(options.readFiles ?? []), ...(options.allowFiles ?? [])], allow: [...(options.allow ?? []), ...extraAllow], }; } diff --git a/packages/cli/src/utils/nono.ts b/packages/cli/src/utils/nono.ts index a3e8f3b5..1895e6c4 100644 --- a/packages/cli/src/utils/nono.ts +++ b/packages/cli/src/utils/nono.ts @@ -21,12 +21,8 @@ * - A non-interactive invocation that launches an agent MUST carry * `--sandbox-required`; a launcher that dropped it is refused rather than * silently running unsandboxed. See `evaluateLaunchControl`. - * - `agent start --runtime claude-code|codex|gemini` reaches the SAME attested - * launch as the default path (cli#363 slice B): the runtime is carried into - * the re-exec and runs inside the launcher's nono session, so a - * `--sandbox-required` launch is confined, or refused before anything is - * spawned when confinement is unavailable. There is no unconfined runtime - * path left. + * - CLI selected runtime runners require launcher release or an interactive + * TTY `--no-sandbox` opt-out. `--sandbox-required` conflicts with that opt-out. * - Under `--sandboxed` the child must hold the launcher's release for a live * nono session bound to its own pid (cli#350 round 4e): see * `launch-attestation.ts`. `--sandboxed` means "my launcher released me" — @@ -63,7 +59,10 @@ export type NonoProfile = | "tps-backup" | "tps-restore" | "tps-status" - | "tps-agent-run"; + | "tps-agent-run" + | "tps-agent-run-claude-code" + | "tps-agent-run-codex" + | "tps-agent-run-gemini"; export interface NonoOptions { /** Override workdir for the nono sandbox (--workdir flag) */ @@ -79,6 +78,7 @@ export interface NonoOptions { readFiles?: string[]; /** Extra read-write paths to allow */ allow?: string[]; + allowFiles?: string[]; } /** @@ -285,6 +285,33 @@ export function harnessReadFiles(agentId?: string): string[] { return files; } +export function runtimeNonoProfile(runtime?: string): NonoProfile { + return runtime === "claude-code" || runtime === "codex" || runtime === "gemini" + ? `tps-agent-run-${runtime}` + : "tps-agent-run"; +} + +export function runtimeNonoOptions( + runtime?: string, + env: NodeJS.ProcessEnv = process.env, +): NonoOptions { + const home = env.HOME || homedir(); + const xdg = env.XDG_CONFIG_HOME || join(home, ".config"); + let allow: string[] = []; + let allowFiles: string[] = []; + if (runtime === "claude-code") { + allow = [env.CLAUDE_CONFIG_DIR || join(home, ".claude"), join(home, ".claude")]; + allowFiles = [join(home, ".claude.json"), join(home, ".claude.lock")]; + } else if (runtime === "codex") { + allow = [env.CODEX_HOME || join(home, ".codex"), join(home, ".config", "codex")]; + allowFiles = [join(home, ".tps", "auth", "openai.json")]; + } else if (runtime === "gemini") { + allow = [join(home, ".gemini"), join(xdg, "gemini")]; + } + for (const path of allow) mkdirSync(path, { recursive: true, mode: 0o700 }); + return { allow: [...new Set(allow)], allowFiles }; +} + /** * Build the nono command args for a given profile and subcommand. * @@ -316,6 +343,10 @@ export function buildNonoArgs( args.push("--read-file", p); } + for (const p of options.allowFiles ?? []) { + args.push("--allow-file", p); + } + for (const p of options.allow ?? []) { args.push("--allow", p); } @@ -706,6 +737,10 @@ export function evaluateLaunchControl(input: LaunchControlInput = {}): LaunchCon const refusalExitCode = supervised ? SUPERVISED_REFUSAL_EXIT_CODE : REFUSAL_EXIT_CODE; const deny = (refusal: string): LaunchControlResult => ({ allowed: false, refusal, refusalExitCode }); + if (argv.includes(SANDBOX_REQUIRED_FLAG) && argv.includes(NO_SANDBOX_FLAG)) { + return deny(`${SANDBOX_REQUIRED_FLAG} conflicts with ${NO_SANDBOX_FLAG}; remove ${NO_SANDBOX_FLAG} to require isolation.`); + } + // (1) --no-sandbox is honoured only from an interactive TTY. if (argv.includes(NO_SANDBOX_FLAG) && !tty) { return deny( diff --git a/packages/cli/test/nono-profiles-install.test.ts b/packages/cli/test/nono-profiles-install.test.ts index a199f3cf..f22bf7bb 100644 --- a/packages/cli/test/nono-profiles-install.test.ts +++ b/packages/cli/test/nono-profiles-install.test.ts @@ -117,7 +117,7 @@ describe("installNonoProfiles migration", () => { installNonoProfiles(profilesDir, true); const names = readdirSync(profilesDir); - expect(names.filter((f) => f.endsWith(".json")).length).toBe(13); + expect(names.filter((f) => f.endsWith(".json")).length).toBe(16); expect(names).toContain("tps-base.json"); const child = JSON.parse(readFileSync(join(profilesDir, "tps-agent-run.json"), "utf-8")); expect(child.meta.version).toBe("2.0.0"); // bundled wins over the stale child diff --git a/packages/cli/test/reviewer/ci-job.test.ts b/packages/cli/test/reviewer/ci-job.test.ts index 5ce6db2e..d9040e42 100644 --- a/packages/cli/test/reviewer/ci-job.test.ts +++ b/packages/cli/test/reviewer/ci-job.test.ts @@ -104,7 +104,7 @@ describe("this repository's test job", () => { expect(t.steps[3].script).toBe( [ `iso_home="$(mktemp -d)"`, - `HOME="$iso_home" bun run test`, + `HOME="$iso_home" TMPDIR="\${RUNNER_TEMP:-/var/tmp}" bun run test`, `if [ -e "$iso_home/.tps" ]; then`, ` echo "::error::the suite wrote $iso_home/.tps — HOME is not isolated"`, " exit 1", diff --git a/packages/cli/test/runtime-attested-launch.test.ts b/packages/cli/test/runtime-attested-launch.test.ts index 6ccf28ff..61a970f3 100644 --- a/packages/cli/test/runtime-attested-launch.test.ts +++ b/packages/cli/test/runtime-attested-launch.test.ts @@ -1,26 +1,10 @@ /** - * cli#363 slice B — `agent start --runtime claude-code|codex|gemini` runs - * through the attested launch, so it is confined like every other agent launch. - * - * Slice A refused `--sandbox-required` on these runtimes because `bin/tps.ts` - * branched on `--runtime` BEFORE `runAgent({action:"start"})` and spawned the - * runtime directly, so it never reached `launchAttested()` and was not confined - * by nono. Slice B routes them through the same attested launch (the runtime is - * carried into the nono re-exec), so the flag is honoured rather than refused. - * - * These tests are black box and FAIL on `main`: they spawn the built CLI with - * piped stdio (non-TTY, the shape a generated unit or a wrapper uses) against a - * fake nono at an absolute path, and assert the REAL launch decision — that the - * launcher spawned nono for a re-exec that carries `--runtime ` and released - * the child — never an exported helper. The fake nono "confines" the way the - * profile does (it denies the child the launcher's OUTSIDE canary), so the child - * attests and the launcher releases it; on `main` the slice-A refusal fires - * instead and nono is never spawned. + * Selected runtime re-exec and startup with simulated canary denial. */ import { describe, test, expect, beforeAll, setDefaultTimeout } from "bun:test"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; const TPS_BIN = resolve(import.meta.dir, "../dist/bin/tps.js"); @@ -32,6 +16,12 @@ const NONO_BIN_ENV = "NONO_BIN"; const TIMEOUT_ENV = "TPS_LAUNCH_TIMEOUT_MS"; const RUNTIMES = ["claude-code", "codex", "gemini"] as const; +const startup = { + "claude-code": "Claude Code runtime started.", + codex: "Codex runtime started.", + gemini: "Gemini runtime started.", +}; + // Real launches wait on a release window; raise the file default above it. setDefaultTimeout(60_000); @@ -80,10 +70,8 @@ function seedHome(home: string, ws: string): void { writeFileSync(join(home, ".tps", "identity", "probe.pub"), "fixture-pub\n"); } -/** OUTSIDE /tmp: the launch grants /tmp, so a HOME under /tmp would put the - * private dir inside that grant and the overlap assert would refuse (correctly). */ function makeSandbox(): Sandbox { - const base = existsSync("/var/tmp") ? "/var/tmp" : homedir(); + const base = tmpdir(); const root = mkdtempSync(join(base, "tps-363-rt-")); const home = join(root, "home"); const tmp = join(root, "tmp"); @@ -94,14 +82,8 @@ function makeSandbox(): Sandbox { return { root, home, tmp, ws, nonoDir, nonoLog: join(root, "nono.log") }; } -/** - * A fake nono that "confines": for `run` it denies the child the launcher's - * OUTSIDE canary (chmod 000 — the launcher read it BEFORE the spawn), starts the - * wrapped command as its own child, and publishes a `ps` store bound to the real - * pids it spawned, so the launcher's canary + binding checks pass and it - * RELEASES the child. Anything else (`--version`, `profile validate`) exits 0. - */ -const CONFINING_FAKE_NONO = `#!/usr/bin/env bash +/** Simulates canary denial and a session record; provides no sandbox. */ +const CANARY_FAKE_NONO = `#!/usr/bin/env bash set -u if [ "\${1:-}" = "--version" ]; then echo "nono 0.74.0"; exit 0; fi log="\${FAKE_NONO_LOG:?}" @@ -138,7 +120,8 @@ function writeFakeNono(sb: Sandbox, script: string): string { function cliEnv(sb: Sandbox, extra: Record = {}): Record { const base: Record = { ...(process.env as Record), - HOME: sb.home, + HOME: "../home", + SNOOPLOGG: "tps:agent*", TMPDIR: sb.tmp, FAKE_NONO_LOG: sb.nonoLog, [TIMEOUT_ENV]: "8000", @@ -204,20 +187,19 @@ const real = process.getuid?.() !== 0 ? describe : describe.skip; real("cli#363 slice B — the three runtimes reach the attested launch", () => { for (const rt of RUNTIMES) { - test(`agent start --runtime ${rt} --sandbox-required is routed through the launcher and released`, async () => { + test(`agent start --runtime ${rt} --sandbox-required is released and starts its runner`, async () => { const sb = makeSandbox(); try { - const bin = writeFakeNono(sb, CONFINING_FAKE_NONO); + const bin = writeFakeNono(sb, CANARY_FAKE_NONO); const { text, stopped } = await runUntil( sb, ["agent", "start", "--id", "probe", "--runtime", rt, SANDBOX_REQUIRED], { [NONO_BIN_ENV]: bin, FAKE_NONO_PS_JSON: join(sb.root, "ps.json") }, - (t) => t.includes("released under nono session"), + (t) => t.includes(startup[rt]), 30_000 ); + expect(text).toContain(startup[rt]); expect(stopped).toBe(true); - // The launcher's release is proof of confinement (canaries + session - // binding); its absence would mean the control refused. expect(text).toContain("released under nono session"); // The runtime is not refused by the retired slice-A rule. expect(text).not.toContain("not launched through the attested sandbox"); @@ -317,3 +299,48 @@ describe("cli#363 slice B — confinement unavailable is refused before any spaw }, 25_000); } }); + +describe("conflicting sandbox flags", () => { + for (const rt of RUNTIMES) { + for (const tty of [true, false]) { + test(`${rt}: conflicting flags are refused before dispatch (TTY=${tty})`, () => { + const sb = makeSandbox(); + try { + const preload = join(sb.root, "tty.cjs"); + writeFileSync(preload, `Object.defineProperty(process.stdin, "isTTY", {value: ${tty}});\nObject.defineProperty(process.stdout, "isTTY", {value: ${tty}});\n`); + const r = spawnSync(NODE, ["--require", preload, TPS_BIN, "agent", "start", "--id", "probe", "--runtime", rt, "--sandbox-required", "--no-sandbox"], { + cwd: sb.ws, env: cliEnv(sb), encoding: "utf-8", timeout: 3000, killSignal: "SIGKILL", + }); + const text = `${r.stdout ?? ""}${r.stderr ?? ""}`; + expect(text).toContain("--sandbox-required conflicts with --no-sandbox"); + expect(r.status).toBe(78); + expect(text).not.toContain(startup[rt]); + expect(fakeNonoRuns(sb)).toEqual([]); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); + } + } +}); + +describe("selected runner startup with interactive opt-out", () => { + for (const rt of RUNTIMES) { + test(`${rt}: starts its selected runner`, async () => { + const sb = makeSandbox(); + try { + const preload = join(sb.root, "tty.cjs"); + writeFileSync(preload, 'Object.defineProperty(process.stdin, "isTTY", {value: true});\nObject.defineProperty(process.stdout, "isTTY", {value: true});\n'); + const { text, stopped } = await runUntil(sb, + ["agent", "start", "--id", "probe", "--runtime", rt, "--no-sandbox"], + { NODE_OPTIONS: `--require=${preload}` }, + (t) => t.includes(startup[rt]), 5000); + expect(text).toContain(startup[rt]); + expect(stopped).toBe(true); + expect(fakeNonoRuns(sb)).toEqual([]); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); + } +}); diff --git a/packages/cli/test/sandbox-launch-control.test.ts b/packages/cli/test/sandbox-launch-control.test.ts index da9c225a..c8c4bfee 100644 --- a/packages/cli/test/sandbox-launch-control.test.ts +++ b/packages/cli/test/sandbox-launch-control.test.ts @@ -62,7 +62,7 @@ beforeAll(() => { describe("T2 — env bypass gone / --no-sandbox is TTY-only", () => { test("TPS_FORCE_NO_NONO=1 cannot rescue a non-TTY --no-sandbox (refused, exit 78)", () => { - const r = runLauncher(["agent", "start", "--id", "ghost", NO_SANDBOX, SANDBOX_REQUIRED], { + const r = runLauncher(["agent", "start", "--id", "ghost", NO_SANDBOX], { TPS_FORCE_NO_NONO: "1", }); const out = output(r).toLowerCase(); diff --git a/scripts/check-nono-profiles.sh b/scripts/check-nono-profiles.sh index f9b3a84d..28bc3867 100755 --- a/scripts/check-nono-profiles.sh +++ b/scripts/check-nono-profiles.sh @@ -167,6 +167,8 @@ for f in "${PROFILE_DIR}"/*.json; do esac done +(cd "${REPO_ROOT}" && NONO_BIN="${NONO_BIN}" bun run scripts/check-runtime-nono-paths.ts) + # ── 5. WORKLOAD smoke under the EXACT launch args (cli#351 r5) ─────────────── # Built with the SAME helper the launch uses (harnessReadPaths/harnessReadFiles), # so the gate exercises what the agent actually gets: a shell redirect to diff --git a/scripts/check-runtime-nono-paths.ts b/scripts/check-runtime-nono-paths.ts new file mode 100644 index 00000000..1cad2293 --- /dev/null +++ b/scripts/check-runtime-nono-paths.ts @@ -0,0 +1,50 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { buildNonoArgs, harnessReadPaths, runtimeNonoOptions, runtimeNonoProfile } from "../packages/cli/src/utils/nono.ts"; + +const bin = process.env.NONO_BIN; +if (!bin) throw new Error("NONO_BIN must name the real pinned nono"); +const root = mkdtempSync(join(tmpdir(), "runtime-nono-")); +const home = join(root, "home"); +const ws = join(root, "ws"); +const env = { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, ".config"), CODEX_HOME: join(home, "codex-custom"), CLAUDE_CONFIG_DIR: join(home, "claude-custom"), XDG_STATE_HOME: join(root, "state"), NONO_NO_UPDATE_CHECK: "1" }; +const paths = { + "claude-code": [".claude/.credentials.json", "claude-custom/.credentials.json", ".claude.json", ".claude.lock", ".claude/projects/session", "claude-custom/projects/session"], + codex: ["codex-custom/auth.json", "codex-custom/sessions/session", ".config/codex/auth.json", ".config/codex/sessions/session", ".tps/auth/openai.json"], + gemini: [".gemini/oauth_creds.json", ".gemini/tmp/session", ".config/gemini/oauth_creds.json", ".config/gemini/tmp/session"], +}; +const defaults = { ...env }; +delete defaults.CODEX_HOME; +delete defaults.CLAUDE_CONFIG_DIR; +const cases = [ + { runtime: "claude-code", names: paths["claude-code"], env }, + { runtime: "codex", names: paths.codex, env }, + { runtime: "codex", names: [".codex/auth.json", ".codex/sessions/session", ".config/codex/auth.json", ".config/codex/sessions/session", ".tps/auth/openai.json"], env: defaults }, + { runtime: "gemini", names: paths.gemini, env }, + { runtime: "gemini", names: [".gemini/oauth_creds.json", ".gemini/tmp/session", "xdg-custom/gemini/oauth_creds.json", "xdg-custom/gemini/tmp/session"], env: { ...env, XDG_CONFIG_HOME: join(home, "xdg-custom") } }, +]; +const credentials = [".aws/credentials", ".tps/auth/openai.json", ".tps/auth/anthropic.json", ".tps/auth/google.json", ".tps/secrets/token", ".claude/.credentials.json", "codex-custom/auth.json", ".codex/auth.json", ".gemini/oauth_creds.json"]; +try { + mkdirSync(ws, { recursive: true }); + for (const name of new Set([...cases.flatMap((c) => c.names), ...credentials])) { + const path = join(home, name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "fixture-secret"); + } + for (const {runtime, names, env: caseEnv} of cases) { + const grants = runtimeNonoOptions(runtime, caseEnv); + const profile = runtimeNonoProfile(runtime); + const deniedPaths = credentials.filter((p) => !names.includes(p)); + const argv = buildNonoArgs(profile, { ...grants, workdir: ws, read: harnessReadPaths() }, [ + "sh", "-c", 'n="$1"; shift; while [ "$n" -gt 0 ]; do [ "$(cat "$1")" = fixture-secret ] || exit 1; printf fixture-secret > "$1" || exit 1; shift; n=$((n-1)); done; for p do if out=$(cat "$p" 2>/dev/null); then exit 2; fi; [ -z "$out" ] || exit 3; done', + "probe", String(names.length), ...names.map((p) => join(home, p)), ...deniedPaths.map((p) => join(home, p)), + ], caseEnv); + const result = spawnSync(bin, argv, { env: caseEnv, cwd: ws, encoding: "utf8", timeout: 30_000 }); + if (result.status !== 0) throw new Error(`${runtime}: credential/state and denial probe failed: ${result.stderr}`); + console.log(`${runtime}: credential/state reads and writes allowed; unrelated credentials denied`); + } +} finally { + rmSync(root, { recursive: true, force: true }); +} From 5d95a7a9d197364673322fa9bccb450aae37e546 Mon Sep 17 00:00:00 2001 From: flint Date: Fri, 2 Oct 2026 21:18:35 +0000 Subject: [PATCH 3/7] ci: the test step keeps the default TMPDIR (RUNNER_TEMP is inside the runner home) (#474 CI) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 819c2479..f8171002 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -120,7 +120,7 @@ jobs: # not an OS boundary; the ~/.tps metadata snapshot is a diagnostic. run: | iso_home="$(mktemp -d)" - HOME="$iso_home" TMPDIR="${RUNNER_TEMP:-/var/tmp}" bun run test + HOME="$iso_home" bun run test if [ -e "$iso_home/.tps" ]; then echo "::error::the suite wrote $iso_home/.tps — HOME is not isolated" exit 1 From a98ca1342e8c05fc60ba6a807414aba9d1b01251 Mon Sep 17 00:00:00 2001 From: flint Date: Fri, 2 Oct 2026 15:22:03 -0700 Subject: [PATCH 4/7] test(cli): Linux runtime fixture roots outside the granted tmpdir; ci-job pin matches the reverted test step (#363) Co-Authored-By: Claude Opus 5.5 --- packages/cli/test/reviewer/ci-job.test.ts | 2 +- packages/cli/test/runtime-attested-launch.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/reviewer/ci-job.test.ts b/packages/cli/test/reviewer/ci-job.test.ts index d9040e42..5ce6db2e 100644 --- a/packages/cli/test/reviewer/ci-job.test.ts +++ b/packages/cli/test/reviewer/ci-job.test.ts @@ -104,7 +104,7 @@ describe("this repository's test job", () => { expect(t.steps[3].script).toBe( [ `iso_home="$(mktemp -d)"`, - `HOME="$iso_home" TMPDIR="\${RUNNER_TEMP:-/var/tmp}" bun run test`, + `HOME="$iso_home" bun run test`, `if [ -e "$iso_home/.tps" ]; then`, ` echo "::error::the suite wrote $iso_home/.tps — HOME is not isolated"`, " exit 1", diff --git a/packages/cli/test/runtime-attested-launch.test.ts b/packages/cli/test/runtime-attested-launch.test.ts index 61a970f3..4299975f 100644 --- a/packages/cli/test/runtime-attested-launch.test.ts +++ b/packages/cli/test/runtime-attested-launch.test.ts @@ -71,7 +71,7 @@ function seedHome(home: string, ws: string): void { } function makeSandbox(): Sandbox { - const base = tmpdir(); + const base = process.platform === "linux" ? "/var/tmp" : tmpdir(); const root = mkdtempSync(join(base, "tps-363-rt-")); const home = join(root, "home"); const tmp = join(root, "tmp"); From adf3af4771d0f4681aa42d6836bbcc20c5268aa3 Mon Sep 17 00:00:00 2001 From: flint Date: Fri, 2 Oct 2026 16:34:09 -0700 Subject: [PATCH 5/7] fix(cli): a selected runtime refuses a missing nono unless the interactive --no-sandbox opt-out is given (#363) Co-Authored-By: Claude Opus 5.5 --- packages/cli/bin/tps.ts | 6 +- packages/cli/src/commands/agent.ts | 20 ++- .../cli/test/runtime-attested-launch.test.ts | 118 ++++++++++++++++++ scripts/check-runtime-nono-paths.ts | 2 +- 4 files changed, 132 insertions(+), 14 deletions(-) diff --git a/packages/cli/bin/tps.ts b/packages/cli/bin/tps.ts index fcb8fdc8..fde3a535 100755 --- a/packages/cli/bin/tps.ts +++ b/packages/cli/bin/tps.ts @@ -464,8 +464,12 @@ async function main() { const message = msgIdx >= 0 ? process.argv.slice(msgIdx + 1).join(" ") : undefined; await runAgent({ action: "run", config: configPath, id: agentId, message }); } else if (action === "start") { - const runtimeArg = process.argv.includes("--runtime") ? process.argv[process.argv.indexOf("--runtime") + 1] : undefined; + const runtimeArg = process.argv.some((arg) => arg === "--runtime" || arg.startsWith("--runtime=")) ? cli.flags.runtime : undefined; const attestedRuntime = runtimeArg === "claude-code" || runtimeArg === "codex" || runtimeArg === "gemini"; + if (runtimeArg !== undefined && !attestedRuntime) { + console.error(`❌ refusing to launch runtime '${runtimeArg}': unsupported runtime`); + process.exit(78); + } const sandboxed = process.argv.includes("--sandboxed"); const noSandbox = process.argv.includes("--no-sandbox"); // Selected runners execute after launcher release or an interactive diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index d1c3c8ed..8309a93f 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -839,20 +839,16 @@ export async function runAgent(args: AgentArgs): Promise { // to runtime. Whether that claim is TRUE was settled by the gate: this // process only reaches here holding the launcher's release // (cli#350 round 4e). - } else if (sandbox || isNonoStrict()) { + } else if (sandbox || selectedRuntime || isNonoStrict()) { if (!nonoAvailable) { - // Fail closed, in every context the launch control governs. A - // non-interactive launch MUST NOT fall back to running the agent - // unsandboxed: that is the silent no-op the unit cannot see (under - // TPS_SUPERVISED the refusal exits 0 and KeepAlive never - // relaunches). An interactive human keeps the old warning — they - // can see it and decide. - if (isNonoStrict() || sandboxRequired || !isInteractiveTty()) { - const why = isNonoStrict() - ? "TPS_NONO_STRICT=1" - : "this launch is not interactive"; + if (selectedRuntime || isNonoStrict() || sandboxRequired || !isInteractiveTty()) { + const why = selectedRuntime + ? `runtime '${selectedRuntime}' requires isolation; use --no-sandbox in an interactive TTY to opt out` + : isNonoStrict() + ? "TPS_NONO_STRICT=1" + : "this launch is not interactive"; console.error( - `❌ refusing to launch the agent: no nono at the pinned absolute path ` + + `❌ refusing to launch ${selectedRuntime ? `runtime '${selectedRuntime}'` : "the agent"}: no nono at the pinned absolute path ` + `(${resolveNonoBinary().reason ?? "unknown"}) — ${why}, so the agent cannot ` + `run without isolation. Install nono >= 0.70 or set NONO_BIN.` ); diff --git a/packages/cli/test/runtime-attested-launch.test.ts b/packages/cli/test/runtime-attested-launch.test.ts index 4299975f..e67fdc41 100644 --- a/packages/cli/test/runtime-attested-launch.test.ts +++ b/packages/cli/test/runtime-attested-launch.test.ts @@ -344,3 +344,121 @@ describe("selected runner startup with interactive opt-out", () => { }); } }); + +function interactiveRuntimeProbe(sb: Sandbox, rt: string | undefined, noSandbox = false, equals = false) { + const defaultMarker = join(sb.root, "default-started"); + const selectedMarker = join(sb.root, "selected-started"); + const preload = join(sb.root, "interactive.mjs"); + const agentModule = resolve(import.meta.dir, "../../agent/dist/index.js"); + writeFileSync(preload, ` +import { AgentRuntime } from ${JSON.stringify(agentModule)}; +import { writeFileSync } from "node:fs"; +Object.defineProperty(process.stdin, "isTTY", {value: true}); +Object.defineProperty(process.stdout, "isTTY", {value: true}); +AgentRuntime.prototype.start = async function () { + writeFileSync(${JSON.stringify(defaultMarker)}, "started"); + process.exit(0); +}; +for (const stream of [process.stdout, process.stderr]) { + const write = stream.write.bind(stream); + stream.write = function (chunk, ...args) { + if (String(chunk).includes(${JSON.stringify(startup[rt as keyof typeof startup] ?? "unsupported runtime started.")})) { + writeFileSync(${JSON.stringify(selectedMarker)}, "started"); + write(chunk, ...args); + process.exit(0); + } + return write(chunk, ...args); + }; +} +`); + const result = spawnSync(NODE, ["--import", preload, TPS_BIN, "agent", "start", "--id", "probe", ...(rt ? equals ? [`--runtime=${rt}`] : ["--runtime", rt] : []), ...(noSandbox ? ["--no-sandbox"] : [])], { + cwd: sb.ws, + env: cliEnv(sb, { [NONO_BIN_ENV]: join(sb.nonoDir, "missing"), TPS_NONO_STRICT: undefined, TPS_SUPERVISED: undefined }), + encoding: "utf8", timeout: 5000, killSignal: "SIGKILL", + }); + return { status: result.status, text: `${result.stdout ?? ""}${result.stderr ?? ""}`, defaultMarker, selectedMarker }; +} + +describe("interactive selected runtime with no nono", () => { + for (const rt of RUNTIMES) { + test(`${rt}: refuses before either runtime starts`, () => { + const sb = makeSandbox(); + try { + const result = interactiveRuntimeProbe(sb, rt); + expect(result.text).toContain(`refusing to launch runtime '${rt}'`); + expect(result.text).toContain("no nono at the pinned absolute path"); + expect(result.text).toContain("--no-sandbox"); + expect(result.status).toBe(78); + expect(existsSync(result.defaultMarker)).toBe(false); + expect(existsSync(result.selectedMarker)).toBe(false); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); + + test(`${rt}: refused launch can retry with explicit --no-sandbox`, () => { + const sb = makeSandbox(); + try { + const refused = interactiveRuntimeProbe(sb, rt); + expect(refused.status).toBe(78); + expect(existsSync(refused.defaultMarker)).toBe(false); + expect(existsSync(refused.selectedMarker)).toBe(false); + const optedOut = interactiveRuntimeProbe(sb, rt, true); + expect(optedOut.status).toBe(0); + expect(optedOut.text).toContain(startup[rt]); + expect(existsSync(optedOut.selectedMarker)).toBe(true); + expect(existsSync(optedOut.defaultMarker)).toBe(false); + expect(fakeNonoRuns(sb)).toEqual([]); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); + } +}); + + +test("interactive default runtime with no nono still warns and starts", () => { + const sb = makeSandbox(); + try { + const result = interactiveRuntimeProbe(sb, undefined); + expect(result.status).toBe(0); + expect(result.text).toContain("nono not found — starting WITHOUT sandbox isolation"); + expect(existsSync(result.defaultMarker)).toBe(true); + expect(existsSync(result.selectedMarker)).toBe(false); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } +}); + +for (const noSandbox of [false, true]) { + test(`unsupported runtime never falls back to the default (opt-out=${noSandbox})`, () => { + const sb = makeSandbox(); + try { + const result = interactiveRuntimeProbe(sb, "unsupported", noSandbox); + expect(result.status).toBe(78); + expect(result.text).toContain("refusing to launch runtime 'unsupported': unsupported runtime"); + expect(existsSync(result.defaultMarker)).toBe(false); + expect(existsSync(result.selectedMarker)).toBe(false); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); +} + +for (const rt of RUNTIMES) { + test(`${rt}: --runtime=value also requires an explicit opt-out`, () => { + const sb = makeSandbox(); + try { + const refused = interactiveRuntimeProbe(sb, rt, false, true); + expect(refused.status).toBe(78); + expect(existsSync(refused.defaultMarker)).toBe(false); + expect(existsSync(refused.selectedMarker)).toBe(false); + const optedOut = interactiveRuntimeProbe(sb, rt, true, true); + expect(optedOut.status).toBe(0); + expect(existsSync(optedOut.selectedMarker)).toBe(true); + expect(existsSync(optedOut.defaultMarker)).toBe(false); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); +} diff --git a/scripts/check-runtime-nono-paths.ts b/scripts/check-runtime-nono-paths.ts index 1cad2293..71ae80ea 100644 --- a/scripts/check-runtime-nono-paths.ts +++ b/scripts/check-runtime-nono-paths.ts @@ -43,7 +43,7 @@ try { ], caseEnv); const result = spawnSync(bin, argv, { env: caseEnv, cwd: ws, encoding: "utf8", timeout: 30_000 }); if (result.status !== 0) throw new Error(`${runtime}: credential/state and denial probe failed: ${result.stderr}`); - console.log(`${runtime}: credential/state reads and writes allowed; unrelated credentials denied`); + console.log(`${runtime}: credential/state reads and writes allowed; tested unrelated credential paths denied`); } } finally { rmSync(root, { recursive: true, force: true }); From 676bf84cc3d98e6568dfc0c70014606b090aeb0d Mon Sep 17 00:00:00 2001 From: flint Date: Fri, 2 Oct 2026 18:16:53 -0700 Subject: [PATCH 6/7] fix(cli): the launch gate reads every spelling of the sandbox flags as the parser does; --runtime openclaw stays the default runtime (#363) Co-Authored-By: Claude Opus 5.5 --- packages/cli/bin/tps.ts | 18 ++-- packages/cli/src/utils/nono.ts | 53 +++++++++-- .../cli/test/runtime-attested-launch.test.ts | 92 ++++++++++++++++++- .../cli/test/sandbox-launch-control.test.ts | 27 ++++++ 4 files changed, 171 insertions(+), 19 deletions(-) diff --git a/packages/cli/bin/tps.ts b/packages/cli/bin/tps.ts index fde3a535..a6809767 100755 --- a/packages/cli/bin/tps.ts +++ b/packages/cli/bin/tps.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import meow from "meow"; +import { launchFlagDefinitions, readLaunchFlags } from "../src/utils/nono.js"; // Injected at compile time via --define flag; falls back to "dev" in dev mode. declare const INJECTED_VERSION: string; @@ -93,11 +94,7 @@ const cli = meow( // sandbox bypass. Renamed from --nonono (kept as a hidden deprecated alias). quietNonoCheck: { type: "boolean", default: false }, nonono: { type: "boolean", default: false }, - // Launch-path control (cli#341 S1a): --sandbox-required is asserted by every - // generated agent unit. (--no-sandbox is the interactive-TTY-only escape hatch; - // it is read from process.argv directly because yargs parses `--no-x` as a - // negation, which would shadow a declared `noSandbox` key.) - sandboxRequired: { type: "boolean", default: false }, + ...launchFlagDefinitions, inject: { type: "boolean", default: true }, runtime: { type: "string", default: "openclaw" }, baseModel: { type: "string" }, @@ -185,6 +182,7 @@ const cli = meow( ); const [command, ...rest] = cli.input; +const launchFlags = readLaunchFlags(process.argv, cli.flags); /** * Launch-path control (cli#341 S1a). Fail-closed: refuses `--no-sandbox` outside @@ -218,8 +216,8 @@ async function enforceLaunchControlOrExit(): Promise { confinement = { released: attestation.ok, reason: attestation.reason }; } } - enforceLaunchControl({ command, rest, argv: process.argv, confinement }); - if (process.argv.includes(NO_SANDBOX_FLAG) && isInteractiveTty()) { + enforceLaunchControl({ command, rest, argv: process.argv, parsedFlags: cli.flags, confinement }); + if (launchFlags.noSandbox && isInteractiveTty()) { console.warn(`⚠️ ${NO_SANDBOX_FLAG}: running WITHOUT nono isolation (interactive override).`); } } @@ -466,12 +464,12 @@ async function main() { } else if (action === "start") { const runtimeArg = process.argv.some((arg) => arg === "--runtime" || arg.startsWith("--runtime=")) ? cli.flags.runtime : undefined; const attestedRuntime = runtimeArg === "claude-code" || runtimeArg === "codex" || runtimeArg === "gemini"; - if (runtimeArg !== undefined && !attestedRuntime) { + if (runtimeArg !== undefined && runtimeArg !== "openclaw" && !attestedRuntime) { console.error(`❌ refusing to launch runtime '${runtimeArg}': unsupported runtime`); process.exit(78); } const sandboxed = process.argv.includes("--sandboxed"); - const noSandbox = process.argv.includes("--no-sandbox"); + const noSandbox = launchFlags.noSandbox; // Selected runners execute after launcher release or an interactive // TTY `--no-sandbox` opt-out. if (attestedRuntime && (sandboxed || noSandbox)) { @@ -602,7 +600,7 @@ async function main() { id: agentId, sandbox: !noSandbox, sandboxed, - sandboxRequired: process.argv.includes("--sandbox-required"), + sandboxRequired: launchFlags.sandboxRequired, // Carry the runtime into the re-exec so the sandboxed child runs the // runtime runner (cli#363 slice B); undefined for the default path. runtime: attestedRuntime ? runtimeArg : undefined, diff --git a/packages/cli/src/utils/nono.ts b/packages/cli/src/utils/nono.ts index 1895e6c4..6c8f7335 100644 --- a/packages/cli/src/utils/nono.ts +++ b/packages/cli/src/utils/nono.ts @@ -41,6 +41,7 @@ * }); */ +import meow from "meow"; import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, copyFileSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; import { createHash } from "node:crypto"; @@ -702,6 +703,45 @@ export function launchesAgent(command: string | undefined, rest: readonly string return false; } +export const launchFlagDefinitions = { + sandboxRequired: { type: "boolean" as const, default: false }, +}; + +export function readLaunchFlags(argv: readonly string[], parsedFlags?: Record): { + sandboxRequired: boolean; noSandbox: boolean; refusal?: string; +} { + const title = process.title; + const parse = (args: readonly string[], flags: typeof launchFlagDefinitions | Record) => + meow("", { importMeta: import.meta, argv: [...args], flags, autoHelp: false, autoVersion: false }).flags; + const booleanValue = (value: unknown): boolean => { + if (value === undefined || value === false || value === "false" || value === 0 || value === "0") return false; + if (value === true || value === "true" || value === 1 || value === "1") return true; + throw new Error("cannot interpret sandbox flag value"); + }; + try { + for (const arg of argv.slice(2)) { + if (!arg.startsWith("--") || !arg.includes("=")) continue; + const raw = parse([arg], {}); + if (["sandboxRequired", "noSandbox", "sandbox", "noSandboxRequired"].some(key => Object.hasOwn(raw, key))) { + if (!["true", "false", "1", "0"].includes(arg.slice(arg.indexOf("=") + 1))) { + throw new Error(`cannot interpret sandbox flag ${arg}`); + } + } + } + const flags = parsedFlags ?? parse(argv.slice(2), launchFlagDefinitions); + const noSandbox = booleanValue(flags.noSandbox); + return { + sandboxRequired: booleanValue(flags.sandboxRequired), + noSandbox: flags.sandbox === false || noSandbox, + }; + } catch (error) { + return { sandboxRequired: false, noSandbox: false, + refusal: `cannot interpret sandbox flag: ${error instanceof Error ? error.message : String(error)}` }; + } finally { + process.title = title; + } +} + export interface LaunchControlInput { /** Top-level command word (argv[2]). */ command?: string; @@ -711,6 +751,7 @@ export interface LaunchControlInput { argv?: readonly string[]; /** Override TTY detection (tests). */ interactiveTty?: boolean; + parsedFlags?: Record; /** Override supervisor detection (tests). */ supervised?: boolean; /** The launcher's release verdict (see `launch-attestation.ts`). Absent when @@ -726,10 +767,6 @@ export interface LaunchControlResult { refusalExitCode: number; } -/** - * Pure decision function for the launch-path control. Never touches the - * process; the caller applies the result. - */ export function evaluateLaunchControl(input: LaunchControlInput = {}): LaunchControlResult { const argv = input.argv ?? process.argv; const tty = input.interactiveTty ?? isInteractiveTty(); @@ -737,12 +774,14 @@ export function evaluateLaunchControl(input: LaunchControlInput = {}): LaunchCon const refusalExitCode = supervised ? SUPERVISED_REFUSAL_EXIT_CODE : REFUSAL_EXIT_CODE; const deny = (refusal: string): LaunchControlResult => ({ allowed: false, refusal, refusalExitCode }); - if (argv.includes(SANDBOX_REQUIRED_FLAG) && argv.includes(NO_SANDBOX_FLAG)) { + const flags = readLaunchFlags(argv, input.parsedFlags); + if (flags.refusal) return deny(flags.refusal); + if (flags.sandboxRequired && flags.noSandbox) { return deny(`${SANDBOX_REQUIRED_FLAG} conflicts with ${NO_SANDBOX_FLAG}; remove ${NO_SANDBOX_FLAG} to require isolation.`); } // (1) --no-sandbox is honoured only from an interactive TTY. - if (argv.includes(NO_SANDBOX_FLAG) && !tty) { + if (flags.noSandbox && !tty) { return deny( `${NO_SANDBOX_FLAG} is refused: it is only honoured from an interactive TTY ` + "(stdin AND stdout must both be terminals). This invocation is not interactive, " + @@ -773,7 +812,7 @@ export function evaluateLaunchControl(input: LaunchControlInput = {}): LaunchCon } // (2) Non-interactive agent launch must assert --sandbox-required. - if (launchesAgent(input.command, input.rest) && !tty && !argv.includes(SANDBOX_REQUIRED_FLAG)) { + if (launchesAgent(input.command, input.rest) && !tty && !flags.sandboxRequired) { const sub = input.rest?.[0] ?? ""; return deny( `${SANDBOX_REQUIRED_FLAG} is required: this non-interactive context is launching an agent ` + diff --git a/packages/cli/test/runtime-attested-launch.test.ts b/packages/cli/test/runtime-attested-launch.test.ts index e67fdc41..b279362a 100644 --- a/packages/cli/test/runtime-attested-launch.test.ts +++ b/packages/cli/test/runtime-attested-launch.test.ts @@ -345,7 +345,7 @@ describe("selected runner startup with interactive opt-out", () => { } }); -function interactiveRuntimeProbe(sb: Sandbox, rt: string | undefined, noSandbox = false, equals = false) { +function interactiveRuntimeProbe(sb: Sandbox, rt: string | undefined, noSandbox = false, equals = false, flags: string[] = []) { const defaultMarker = join(sb.root, "default-started"); const selectedMarker = join(sb.root, "selected-started"); const preload = join(sb.root, "interactive.mjs"); @@ -371,7 +371,7 @@ for (const stream of [process.stdout, process.stderr]) { }; } `); - const result = spawnSync(NODE, ["--import", preload, TPS_BIN, "agent", "start", "--id", "probe", ...(rt ? equals ? [`--runtime=${rt}`] : ["--runtime", rt] : []), ...(noSandbox ? ["--no-sandbox"] : [])], { + const result = spawnSync(NODE, ["--import", preload, TPS_BIN, "agent", "start", "--id", "probe", ...(rt ? equals ? [`--runtime=${rt}`] : ["--runtime", rt] : []), ...(noSandbox ? ["--no-sandbox"] : []), ...flags], { cwd: sb.ws, env: cliEnv(sb, { [NONO_BIN_ENV]: join(sb.nonoDir, "missing"), TPS_NONO_STRICT: undefined, TPS_SUPERVISED: undefined }), encoding: "utf8", timeout: 5000, killSignal: "SIGKILL", @@ -462,3 +462,91 @@ for (const rt of RUNTIMES) { } }); } + +for (const rt of RUNTIMES) { + for (const flags of [ + ["--sandbox-required=true", "--no-sandbox"], + ["--sandboxRequired=true", "--noSandbox"], + ["--sandbox-required=true", "--no_sandbox=true"], + ["--sandbox-required=true", "--no-sandbox=true"], + ["--sandbox-required=true", "--no-sandbox=1"], + ["--sandbox-required=unknown", "--no-sandbox"], + ["--no-sandbox=unknown"], + ["--noSandbox", "unknown", "--no-sandbox"], + ]) { + test(`${rt}: TTY refuses ${flags.join(" ")} before either runner starts`, () => { + const sb = makeSandbox(); + try { + const result = interactiveRuntimeProbe(sb, rt, false, false, flags); + expect(result.status).toBe(78); + expect(result.text).toContain(flags.some(f => f.includes("unknown")) ? "cannot interpret sandbox flag" : "--sandbox-required conflicts with --no-sandbox"); + expect(existsSync(result.defaultMarker)).toBe(false); + expect(existsSync(result.selectedMarker)).toBe(false); + expect(fakeNonoRuns(sb)).toEqual([]); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); + } + for (const value of ["false", "1", "0"]) { + test(`${rt}: --sandbox-required=${value} reads as false and allows TTY opt-out`, () => { + const sb = makeSandbox(); + try { + const result = interactiveRuntimeProbe(sb, rt, true, false, [`--sandbox-required=${value}`]); + expect(result.status).toBe(0); + expect(existsSync(result.selectedMarker)).toBe(true); + expect(existsSync(result.defaultMarker)).toBe(false); + expect(fakeNonoRuns(sb)).toEqual([]); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); + } + for (const value of ["false", "0"]) { + test(`${rt}: --no-sandbox=${value} does not opt out`, () => { + const sb = makeSandbox(); + try { + const result = interactiveRuntimeProbe(sb, rt, false, false, [`--no-sandbox=${value}`]); + expect(result.status).toBe(78); + expect(result.text).toContain("no nono at the pinned absolute path"); + expect(existsSync(result.defaultMarker)).toBe(false); + expect(existsSync(result.selectedMarker)).toBe(false); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); + } +} +for (const equals of [false, true]) { + test(`explicit openclaw keeps the default runtime (equals=${equals})`, () => { + const sb = makeSandbox(); + try { + const result = interactiveRuntimeProbe(sb, "openclaw", false, equals); + expect(result.status).toBe(0); + expect(result.text).toContain("nono not found — starting WITHOUT sandbox isolation"); + expect(existsSync(result.defaultMarker)).toBe(true); + expect(existsSync(result.selectedMarker)).toBe(false); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); +} + +for (const rt of RUNTIMES) { + for (const spelling of ["no-sandbox", "noSandbox"]) { + for (const value of ["true", "1"]) { + test(`${rt}: TTY --${spelling}=${value} starts only the selected runner`, () => { + const sb = makeSandbox(); + try { + const result = interactiveRuntimeProbe(sb, rt, false, false, [`--${spelling}=${value}`]); + expect(result.status).toBe(0); + expect(existsSync(result.selectedMarker)).toBe(true); + expect(existsSync(result.defaultMarker)).toBe(false); + expect(fakeNonoRuns(sb)).toEqual([]); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); + } + } +} diff --git a/packages/cli/test/sandbox-launch-control.test.ts b/packages/cli/test/sandbox-launch-control.test.ts index c8c4bfee..720edd57 100644 --- a/packages/cli/test/sandbox-launch-control.test.ts +++ b/packages/cli/test/sandbox-launch-control.test.ts @@ -30,6 +30,8 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { spawnSync } from "node:child_process"; +import { evaluateLaunchControl } from "../src/utils/nono.js"; +import meow from "meow"; import { buildPlist } from "../src/commands/mail-watch.js"; import { generateOfficePlist, generateTunnelPlist } from "../src/commands/office-supervision.js"; @@ -239,3 +241,28 @@ describe("T4 — KeepAlive {SuccessfulExit:false} only with exit-0-on-refusal", expect(r.status).toBe(0); }); }); + +for (const spelling of ["sandbox-required", "sandboxRequired"]) { + for (const value of ["true", "false", "1", "0"]) { + test(`launch gate matches parser for --${spelling}=${value}`, () => { + const argv = ["node", "tps", "agent", "start", `--${spelling}=${value}`]; + const flags = meow("", {importMeta: import.meta, argv: argv.slice(2), + autoHelp: false, autoVersion: false, + flags: {sandboxRequired: {type: "boolean", default: false}}, + }).flags; + const result = evaluateLaunchControl({command: "agent", rest: ["start"], argv, + interactiveTty: false, supervised: false}); + expect(flags.sandboxRequired).toBe(value === "true"); + expect(result.allowed).toBe(flags.sandboxRequired); + }); + } +} +for (const spelling of ["no-sandbox", "noSandbox", "no_sandbox"]) { + for (const value of ["true", "false", "1", "0"]) { + test(`non-TTY launch gate interprets --${spelling}=${value}`, () => { + const result = evaluateLaunchControl({argv: ["node", "tps", `--${spelling}=${value}`], + interactiveTty: false, supervised: false}); + expect(result.allowed).toBe(value === "false" || value === "0"); + }); + } +} From 82e1f62a395b986748745f762ddb2da62eb82770 Mon Sep 17 00:00:00 2001 From: flint Date: Fri, 2 Oct 2026 21:29:27 -0700 Subject: [PATCH 7/7] fix(cli): a valued sandbox flag accepts only true or false; any other value is refused by name (#363) Co-Authored-By: Claude Opus 5.5 --- .../fixed-363-runtimes-attested-launch.md | 2 +- docs/commands.md | 4 ++ packages/cli/bin/tps.ts | 13 ++++-- packages/cli/src/utils/nono.ts | 25 +++++++----- .../cli/test/runtime-attested-launch.test.ts | 40 +++++++++++++++++-- .../cli/test/sandbox-launch-control.test.ts | 27 +++++++++++-- 6 files changed, 90 insertions(+), 21 deletions(-) diff --git a/.changelog/unreleased/fixed-363-runtimes-attested-launch.md b/.changelog/unreleased/fixed-363-runtimes-attested-launch.md index 25c6e641..ce2e8967 100644 --- a/.changelog/unreleased/fixed-363-runtimes-attested-launch.md +++ b/.changelog/unreleased/fixed-363-runtimes-attested-launch.md @@ -1,2 +1,2 @@ - **CLI Claude Code, Codex and Gemini runners require launcher release or an interactive TTY `--no-sandbox` opt-out (Closes #363).** - `--sandbox-required --no-sandbox` is refused. + Conflicting sandbox flags and values other than `true` or `false` are refused by name. diff --git a/docs/commands.md b/docs/commands.md index beda99cb..da5c225f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -9,6 +9,10 @@ The `tps` CLI is the control plane for the Agent OS. | `--config ` | Path to `openclaw.json` (defaults to auto-discovery). | | `--version` | Show version number. | | `--help` | Show help. | +| `--sandbox-required[=true|false]` / `--sandboxRequired[=true|false]` | Require isolation; conflicts with `--no-sandbox`. | +| `--no-sandbox` | Interactive TTY opt-out. `--sandbox=false` does not opt out. | + +Sandbox flag values must be exactly `true` or `false`; other values are refused by name. --- diff --git a/packages/cli/bin/tps.ts b/packages/cli/bin/tps.ts index de6b7693..29deb158 100755 --- a/packages/cli/bin/tps.ts +++ b/packages/cli/bin/tps.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import meow from "meow"; -import { launchFlagDefinitions, readLaunchFlags } from "../src/utils/nono.js"; +import { enforceLaunchControl, launchFlagDefinitions, readLaunchFlags } from "../src/utils/nono.js"; // Injected at compile time via --define flag; falls back to "dev" in dev mode. declare const INJECTED_VERSION: string; @@ -126,6 +126,9 @@ const RAW_VALUE_FLAGS: Record = { const helpArgs = parseHelpArgs(process.argv.slice(2)); +const launchFlags = readLaunchFlags(process.argv); +if (launchFlags.refusal) enforceLaunchControl({ argv: process.argv }); + const cli = meow( ` Usage @@ -163,6 +166,9 @@ const cli = meow( --help Show this help text --version Show version number --config Path to openclaw.json (default: auto-discover) + --sandbox-required[=true|false] Require isolation (alias: --sandboxRequired) + --no-sandbox Interactive TTY opt-out; conflicts with required isolation + Sandbox flag values: true or false only; --sandbox=false does not opt out Examples $ tps hire developer --name Fred @@ -200,7 +206,6 @@ const cli = meow( ); const [command, ...rest] = cli.input; -const launchFlags = readLaunchFlags(process.argv, cli.flags); /** * Launch-path control (cli#341 S1a). Fail-closed: refuses `--no-sandbox` outside @@ -276,7 +281,7 @@ const USAGE: Record = { " tps agent status --id [--json]\n" + " tps agent decommission --id [--force]\n" + " tps agent run --id --message \n" + - " tps agent start --id \n" + + " tps agent start --id [--runtime ] [--sandbox-required[=true|false]] [--no-sandbox]\n" + " tps agent health --id \n" + " tps agent logs --id [--lines ] [--follow]\n" + " tps agent healthcheck \n" + @@ -543,7 +548,7 @@ async function main() { " tps agent status --id [--json]\n" + " tps agent decommission --id [--force]\n" + " tps agent run --id --message \n" + - " tps agent start --id \n" + + " tps agent start --id [--runtime ] [--sandbox-required[=true|false]] [--no-sandbox]\n" + " tps agent health --id \n" + " tps agent logs --id [--lines ] [--follow]\n" + " tps agent healthcheck \n" + diff --git a/packages/cli/src/utils/nono.ts b/packages/cli/src/utils/nono.ts index 6c8f7335..e9080c92 100644 --- a/packages/cli/src/utils/nono.ts +++ b/packages/cli/src/utils/nono.ts @@ -714,18 +714,25 @@ export function readLaunchFlags(argv: readonly string[], parsedFlags?: Record) => meow("", { importMeta: import.meta, argv: [...args], flags, autoHelp: false, autoVersion: false }).flags; const booleanValue = (value: unknown): boolean => { - if (value === undefined || value === false || value === "false" || value === 0 || value === "0") return false; - if (value === true || value === "true" || value === 1 || value === "1") return true; + if (value === undefined || value === false || value === "false") return false; + if (value === true || value === "true") return true; throw new Error("cannot interpret sandbox flag value"); }; try { - for (const arg of argv.slice(2)) { - if (!arg.startsWith("--") || !arg.includes("=")) continue; - const raw = parse([arg], {}); - if (["sandboxRequired", "noSandbox", "sandbox", "noSandboxRequired"].some(key => Object.hasOwn(raw, key))) { - if (!["true", "false", "1", "0"].includes(arg.slice(arg.indexOf("=") + 1))) { - throw new Error(`cannot interpret sandbox flag ${arg}`); - } + for (let i = 2; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--") break; + if (!arg.startsWith("--")) continue; + const equals = arg.indexOf("="); + const name = equals === -1 ? arg : arg.slice(0, equals); + const raw = parse([name], {}); + if (!["sandboxRequired", "noSandbox", "sandbox", "noSandboxRequired", "sandboxed", "noSandboxed"] + .some(key => Object.hasOwn(raw, key))) continue; + const next = argv[i + 1]; + const value = equals !== -1 ? arg.slice(equals + 1) + : next !== undefined && (!next.startsWith("-") || /^-\d/.test(next)) ? next : undefined; + if (value !== undefined && value !== "true" && value !== "false") { + throw new Error(`${name} accepts only 'true' or 'false'; received ${JSON.stringify(value)}`); } } const flags = parsedFlags ?? parse(argv.slice(2), launchFlagDefinitions); diff --git a/packages/cli/test/runtime-attested-launch.test.ts b/packages/cli/test/runtime-attested-launch.test.ts index b279362a..9dd0252f 100644 --- a/packages/cli/test/runtime-attested-launch.test.ts +++ b/packages/cli/test/runtime-attested-launch.test.ts @@ -469,7 +469,6 @@ for (const rt of RUNTIMES) { ["--sandboxRequired=true", "--noSandbox"], ["--sandbox-required=true", "--no_sandbox=true"], ["--sandbox-required=true", "--no-sandbox=true"], - ["--sandbox-required=true", "--no-sandbox=1"], ["--sandbox-required=unknown", "--no-sandbox"], ["--no-sandbox=unknown"], ["--noSandbox", "unknown", "--no-sandbox"], @@ -488,7 +487,7 @@ for (const rt of RUNTIMES) { } }); } - for (const value of ["false", "1", "0"]) { + for (const value of ["false"]) { test(`${rt}: --sandbox-required=${value} reads as false and allows TTY opt-out`, () => { const sb = makeSandbox(); try { @@ -502,7 +501,7 @@ for (const rt of RUNTIMES) { } }); } - for (const value of ["false", "0"]) { + for (const value of ["false"]) { test(`${rt}: --no-sandbox=${value} does not opt out`, () => { const sb = makeSandbox(); try { @@ -534,7 +533,7 @@ for (const equals of [false, true]) { for (const rt of RUNTIMES) { for (const spelling of ["no-sandbox", "noSandbox"]) { - for (const value of ["true", "1"]) { + for (const value of ["true"]) { test(`${rt}: TTY --${spelling}=${value} starts only the selected runner`, () => { const sb = makeSandbox(); try { @@ -550,3 +549,36 @@ for (const rt of RUNTIMES) { } } } + +for (const rt of RUNTIMES) { + for (const spelling of ["sandbox-required", "sandboxRequired", "sandbox", "no-sandbox", "noSandbox", "sandboxed"]) { + for (const value of ["1", "0", "yes", "", "TRUE"]) { + test(`${rt}: invalid --${spelling}=${value} refuses before any runner`, () => { + const sb = makeSandbox(); + try { + const result = interactiveRuntimeProbe(sb, rt, true, false, [`--${spelling}=${value}`]); + expect(result.status).toBe(78); + expect(result.text).toContain(`--${spelling} accepts only 'true' or 'false'`); + expect(existsSync(result.selectedMarker)).toBe(false); + expect(existsSync(result.defaultMarker)).toBe(false); + expect(fakeNonoRuns(sb)).toEqual([]); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); + } + } + test(`${rt}: --sandbox=false does not opt out with missing nono`, () => { + const sb = makeSandbox(); + try { + const result = interactiveRuntimeProbe(sb, rt, false, false, ["--sandbox=false"]); + expect(result.status).toBe(78); + expect(result.text).toContain("no nono at the pinned absolute path"); + expect(existsSync(result.selectedMarker)).toBe(false); + expect(existsSync(result.defaultMarker)).toBe(false); + expect(fakeNonoRuns(sb)).toEqual([]); + } finally { + rmSync(sb.root, {recursive: true, force: true}); + } + }); +} diff --git a/packages/cli/test/sandbox-launch-control.test.ts b/packages/cli/test/sandbox-launch-control.test.ts index 720edd57..56740e91 100644 --- a/packages/cli/test/sandbox-launch-control.test.ts +++ b/packages/cli/test/sandbox-launch-control.test.ts @@ -243,7 +243,7 @@ describe("T4 — KeepAlive {SuccessfulExit:false} only with exit-0-on-refusal", }); for (const spelling of ["sandbox-required", "sandboxRequired"]) { - for (const value of ["true", "false", "1", "0"]) { + for (const value of ["true", "false"]) { test(`launch gate matches parser for --${spelling}=${value}`, () => { const argv = ["node", "tps", "agent", "start", `--${spelling}=${value}`]; const flags = meow("", {importMeta: import.meta, argv: argv.slice(2), @@ -258,11 +258,32 @@ for (const spelling of ["sandbox-required", "sandboxRequired"]) { } } for (const spelling of ["no-sandbox", "noSandbox", "no_sandbox"]) { - for (const value of ["true", "false", "1", "0"]) { + for (const value of ["true", "false"]) { test(`non-TTY launch gate interprets --${spelling}=${value}`, () => { const result = evaluateLaunchControl({argv: ["node", "tps", `--${spelling}=${value}`], interactiveTty: false, supervised: false}); - expect(result.allowed).toBe(value === "false" || value === "0"); + expect(result.allowed).toBe(value === "false"); }); } } + +for (const spelling of ["sandbox-required", "sandboxRequired", "sandbox_required", + "sandbox", "no-sandbox", "noSandbox", "no_sandbox", "no-sandbox-required", "noSandboxRequired", + "sandboxed", "no-sandboxed", "noSandboxed"]) { + for (const value of ["1", "0", "yes", "", "TRUE"]) { + for (const equals of [true, false]) { + test(`invalid raw sandbox value --${spelling}${equals ? "=" : " "}${value} is refused by name`, () => { + const args = equals ? [`--${spelling}=${value}`] : [`--${spelling}`, value]; + const r = runLauncher(["agent", "start", "--runtime", "codex", ...args, "--no-sandbox"]); + expect(r.status).toBe(78); + expect(output(r)).toContain(`--${spelling} accepts only 'true' or 'false'`); + }); + } + } +} + +test("an invalid required value cannot be overwritten by a later valid value", () => { + const r = runLauncher(["agent", "start", "--sandbox-required=1", "--sandboxRequired=false", "--no-sandbox"]); + expect(r.status).toBe(78); + expect(output(r)).toContain("--sandbox-required accepts only 'true' or 'false'"); +});