diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index 3a40c84246..c881cbc27b 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -9633,8 +9633,7 @@ test("every eval leg runs the same managed-policy step and the same probe", () = const probeOf = (s) => step(s, "probe") .run.replaceAll("no pull request case runs", "no case runs") - .replace("the next leg runs the eval", "the next leg runs the pass") - .replace(`echo "The managed policy denied the probe's Read, Grep and Glob calls."\n`, ""); + .replace("the next leg runs the eval", "the next leg runs the pass"); assert.equal(probeOf(steps), probeOf(first), `${file} ${id} probe`); const [denyIdx, isolateIdx, probeIdx] = ["Deny the eval's reads of process and runner state", "Isolate the runner", "Probe that the policy denies those reads"].map((n) => { const i = steps.findIndex((s) => s.name === n); @@ -9644,18 +9643,18 @@ test("every eval leg runs the same managed-policy step and the same probe", () = assert.ok(denyIdx < isolateIdx, `${file} ${id}: the policy is written while sudo works`); assert.ok(isolateIdx < probeIdx, `${file} ${id}`); } - const probe = step(first, "probe").run; - assert.match(probe, /jq -e -R -s --arg t "\$1" --arg p "\$2" '\[split\("\\n"\)\[\] \| fromjson\? \| objects\] \| last\(/); - assert.doesNotMatch(probe, /jq -e -s /, "a slurp fails on any line that is not JSON"); - assert.match(probe, /so no case runs \(probe: \$\(probe_facts\)\)"; exit 1; \}/); + // policy-probe.mjs reads the stream line by line and names the probe's closed facts. + assert.match(step(first, "probe").run, /PROBE="\$out" node \.base\/\.github\/scripts\/plugin-eval\/policy-probe\.mjs\) \|\| judged=\$\?\n/); }); // The model can decline the probe's reads and end with no tool call, // which proves nothing about the policy: that session runs again, -// three times at most. Any other shortfall stops the job on the first session. +// three times at most, and then stops the job as unproven, never as a policy +// failure. A call made and not denied stops the job on the first session. // Runs the step with a stub claude that replays one hand-written stream per -// call and a stub node standing in for the handoff classifier. -test("the policy probe reruns only a session with no tool call, at most three times", () => { +// call, a stub node standing in for the handoff classifier, and the real +// policy-probe.mjs. +test("the policy probe reruns a session with no tool call, at most three times, then stops as unproven", () => { const step = workflow("eval-hill-climb-pass.yml").jobs.pass.steps.find((s) => s.id === "probe"); const root = "/runner"; const calls = [ @@ -9682,7 +9681,8 @@ test("the policy probe reruns only a session with no tool call, at most three ti streams.forEach((s, i) => writeFileSync(join(dir, `stream-${i + 1}`), `${s}\n`)); const stub = (name, body) => writeFileSync(join(bin, name), `#!/usr/bin/env bash\n${body}\n`, { mode: 0o755 }); stub("claude", `n=$(($(cat "${dir}/calls" 2>/dev/null || echo 0) + 1)); echo "$n" >"${dir}/calls"\ncat "${dir}/stream-$n"`); - stub("node", "printf 'handoff=false\\nhandoff-reason=\\nlane-class=\\noutcome=success\\n'"); + const judge = fileURLToPath(new URL("../../scripts/plugin-eval/policy-probe.mjs", import.meta.url)); + stub("node", `case "$1" in *policy-probe.mjs) exec "${process.execPath}" "${judge}" ;; esac\nprintf 'handoff=false\\nhandoff-reason=\\nlane-class=\\noutcome=success\\n'`); const output = join(dir, "output"); writeFileSync(output, ""); const result = spawnSync("bash", ["-e", "-c", step.run], { @@ -9699,13 +9699,14 @@ test("the policy probe reruns only a session with no tool call, at most three ti assert.deepEqual([first.status, first.calls], [0, 1], first.stdout); const retried = probe([declined, allDenied]); assert.deepEqual([retried.status, retried.calls], [0, 2], retried.stdout); - assert.match(retried.stdout, /^::notice::the probe's session made no tool call, so it proved nothing about the policy \(attempt 1 of 3\)$/m); + assert.match(retried.stdout, /^::notice::the probe's session made no tool call, so it proved nothing about the policy \(probe: .* tool_calls=none denials=0 .*\) \(attempt 1 of 3\)$/m); const never = probe([declined, declined, declined, allDenied]); assert.deepEqual([never.status, never.calls], [1, 3], never.stdout); - assert.match(never.stdout, /^::error::the managed policy did not record a denied Read of \/proc\/self\/environ, so no case runs \(probe: .* tool_calls=none denials=0 /m); + assert.match(never.stdout, /^::error::the policy is unproven: in 3 attempts the probe's session never made every call it was asked for, so no case runs \(last attempt: the probe's session made no tool call, .* tool_calls=none denials=0 /m); + assert.doesNotMatch(never.stdout, /did not record a denied/); const undenied = probe([readNotDenied, allDenied]); assert.deepEqual([undenied.status, undenied.calls], [1, 1], undenied.stdout); - assert.match(undenied.stdout, /^::error::the managed policy did not record a denied Read of \/proc\/self\/environ, so no case runs \(probe: .* tool_calls=Read denials=0 /m); + assert.match(undenied.stdout, /^::error::the managed policy did not record a denied Read of \/proc\/self\/environ \(probe: .* tool_calls=Read denials=0 .*\), so no case runs$/m); }); // --- mutated code never runs beside a credential (#13586) --- diff --git a/.github/scripts/plugin-eval/policy-probe.mjs b/.github/scripts/plugin-eval/policy-probe.mjs new file mode 100755 index 0000000000..5e27201372 --- /dev/null +++ b/.github/scripts/plugin-eval/policy-probe.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +// policy-probe.mjs: judges the eval policy probe's stream (`claude -p +// --output-format stream-json --verbose`) for the four calls the eval legs' +// probe step asks for. Each call is denied (the result's permission_denials +// records it), made and not denied (a tool_use with no matching denial: the +// policy let it through), or not made. A session can end normally without +// making every call, since no `claude` flag forces a tool call; that proves +// nothing about the policy either way, so it is reported as unproven, never as +// a policy failure. +// +// Env: PROBE (the stream's path), RUNNER_ROOT. +// Exit 0: all four denied. Exit 1: a call made and not denied, or a session +// that errored before making every call. Exit 3: a session that ended normally +// without making every call, which the caller runs again. +// Prints one line: the reason, then the probe's facts. The facts hold only +// counts and tokens checked against fixed patterns, never text from the +// stream, which can hold what a call returned. +import { readFileSync } from "node:fs"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +export const expectedCalls = (runnerRoot) => [ + ["Read", "/proc/self/environ"], + ["Read", `${runnerRoot}/.runner`], + ["Grep", "/proc/self"], + ["Glob", "/proc/self"], +]; + +const pathOf = (input) => String(input?.file_path ?? input?.path ?? ""); +const token = (value, pattern) => (pattern.test(String(value)) ? String(value) : "other"); + +export function judgeProbe(text, runnerRoot) { + const lines = text.split("\n").filter((line) => line !== ""); + const messages = lines.flatMap((line) => { + try { + const value = JSON.parse(line); + return value && typeof value === "object" && !Array.isArray(value) ? [value] : []; + } catch { + return []; + } + }); + const result = messages.filter((m) => m.type === "result").at(-1) ?? null; + const uses = messages + .filter((m) => m.type === "assistant") + .flatMap((m) => (Array.isArray(m.message?.content) ? m.message.content : [])) + .filter((block) => block && typeof block === "object" && block.type === "tool_use"); + const denials = Array.isArray(result?.permission_denials) ? result.permission_denials : []; + + const facts = [ + `result=${result ? "present" : "none"}`, + `subtype=${token(result?.subtype ?? "none", /^[a-z_]{1,40}$/)}`, + `terminal_reason=${token(result?.terminal_reason ?? "none", /^[a-z_]{1,40}$/)}`, + `is_error=${token(result?.is_error ?? "none", /^(true|false|none)$/)}`, + `api_error_status=${token(result?.api_error_status ?? "none", /^([0-9]{3}|none)$/)}`, + `num_turns=${token(result?.num_turns ?? "none", /^([0-9]{1,4}|none)$/)}`, + `tool_calls=${uses.length ? uses.map((u) => (["Read", "Grep", "Glob"].includes(u.name) ? u.name : "other")).join(",") : "none"}`, + `denials=${denials.length}`, + `unparsable_lines=${lines.length - messages.length}`, + ].join(" "); + + const calls = expectedCalls(runnerRoot).map(([tool, prefix]) => ({ + tool, + prefix, + denied: denials.some((d) => d?.tool_name === tool && pathOf(d.tool_input).startsWith(prefix)), + made: uses.some((u) => u.name === tool && pathOf(u.input).startsWith(prefix)), + })); + const say = (code, reason) => ({ code, line: `${reason} (probe: ${facts})` }); + + if (calls.every((c) => c.denied)) return say(0, "the managed policy denied the probe's Read, Grep and Glob calls"); + const leaked = calls.find((c) => c.made && !c.denied); + if (leaked) return say(1, `the managed policy did not record a denied ${leaked.tool} of ${leaked.prefix}`); + const missing = calls.find((c) => !c.denied); + if (result?.subtype !== "success") { + return say(1, `the probe's session ended in error before its ${missing.tool} of ${missing.prefix}, so it proved nothing about the policy`); + } + if (uses.length === 0) return say(3, "the probe's session made no tool call, so it proved nothing about the policy"); + return say(3, `the probe's session never made its ${missing.tool} of ${missing.prefix}, so it proved nothing about the policy`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + let text = ""; + try { + text = readFileSync(process.env.PROBE ?? "", "utf8"); + } catch { + // An unreadable stream judges as an empty one: no result, so an error. + } + const { code, line } = judgeProbe(text, process.env.RUNNER_ROOT ?? ""); + process.stdout.write(`${line}\n`); + process.exitCode = code; +} diff --git a/.github/scripts/plugin-eval/policy-probe.test.mjs b/.github/scripts/plugin-eval/policy-probe.test.mjs new file mode 100644 index 0000000000..a26f4df7a6 --- /dev/null +++ b/.github/scripts/plugin-eval/policy-probe.test.mjs @@ -0,0 +1,72 @@ +// policy-probe.mjs proves the managed policy denied the probe's four reads, +// and a session that never makes them is unproven, never a policy failure: +// the workflow step stops the leg on both, so no case runs on a policy nobody +// proved. The stream shapes are stream-json's: tool_use blocks in assistant +// messages and the result's permission_denials (code.claude.com/docs/en/ +// agent-sdk/typescript, "SDKResultMessage"). The step itself runs end to end +// in callers.test.mjs. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { judgeProbe } from "./policy-probe.mjs"; + +const ROOT = "/home/runner/actions-runner"; +const CALLS = [ + ["Read", { file_path: "/proc/self/environ" }], + ["Read", { file_path: `${ROOT}/.runner` }], + ["Grep", { pattern: "policy-probe", path: "/proc/self/environ" }], + ["Glob", { pattern: "environ", path: "/proc/self" }], +]; +const stream = ({ calls = CALLS, denied = calls, subtype = "success" } = {}) => + [ + { type: "system", subtype: "init" }, + ...calls.map(([name, input]) => ({ type: "assistant", message: { content: [{ type: "tool_use", name, input }] } })), + { type: "assistant", message: { content: [{ type: "text", text: "done" }] } }, + { type: "result", subtype, is_error: subtype !== "success", num_turns: 2, permission_denials: denied.map(([tool_name, tool_input]) => ({ tool_name, tool_input })) }, + ] + .map((m) => JSON.stringify(m)) + .join("\n"); + +test("all four calls denied proves the policy", () => { + const { code, line } = judgeProbe(stream(), ROOT); + assert.equal(code, 0); + assert.match(line, /^the managed policy denied the probe's Read, Grep and Glob calls \(probe: .* tool_calls=Read,Read,Grep,Glob denials=4 /); +}); + +test("a session with no tool call is unproven and runs again, never a policy failure", () => { + const { code, line } = judgeProbe(stream({ calls: [] }), ROOT); + assert.equal(code, 3); + assert.match(line, /^the probe's session made no tool call, so it proved nothing about the policy \(probe: .*tool_calls=none denials=0/); + assert.doesNotMatch(line, /did not record/); +}); + +test("a session that skips a call is unproven, naming the call", () => { + const { code, line } = judgeProbe(stream({ calls: CALLS.slice(0, 3) }), ROOT); + assert.equal(code, 3); + assert.match(line, /^the probe's session never made its Glob of \/proc\/self, so it proved nothing/); +}); + +test("a call made and not denied is a policy failure", () => { + const { code, line } = judgeProbe(stream({ denied: [CALLS[0], CALLS[1], CALLS[3]] }), ROOT); + assert.equal(code, 1); + assert.match(line, /^the managed policy did not record a denied Grep of \/proc\/self \(probe: /); +}); + +test("a session that errored before its calls is its own error, not a policy failure", () => { + for (const text of [stream({ calls: [], subtype: "error_max_turns" }), "", "not json\n"]) { + const { code, line } = judgeProbe(text, ROOT); + assert.equal(code, 1); + assert.match(line, /^the probe's session ended in error before its Read of \/proc\/self\/environ, so it proved nothing/); + } +}); + +test("the facts carry no text from the stream", () => { + const leak = JSON.stringify({ type: "result", subtype: "SECRET value", terminal_reason: "x".repeat(50), api_error_status: "sk-1", num_turns: "lots" }); + const { line } = judgeProbe(`${leak}\n[1]\n`, ROOT); + assert.match(line, /subtype=other terminal_reason=other is_error=none api_error_status=other num_turns=other tool_calls=none denials=0 unparsable_lines=1\)$/); +}); + +test("a line that is not JSON cannot hide the denials", () => { + const { code, line } = judgeProbe(`not json\n${stream()}\n`, ROOT); + assert.equal(code, 0); + assert.match(line, /unparsable_lines=1\)$/); +}); diff --git a/.github/scripts/plugin-eval/workflow.test.mjs b/.github/scripts/plugin-eval/workflow.test.mjs index 9a33f616da..fabcca796e 100644 --- a/.github/scripts/plugin-eval/workflow.test.mjs +++ b/.github/scripts/plugin-eval/workflow.test.mjs @@ -34,16 +34,16 @@ test("a pull request run proves the policy denies the reads before any case runs assert.match(probe, /if: steps\.stage\.outputs\.go == 'true' && github\.event_name == 'pull_request'\n/); assert.match(probe, /RUNNER_ROOT: \$\{\{ steps\.deny\.outputs\.runner-root \}\}/); assert.match(probe, /--allowedTools Read Grep Glob /); - assert.match(probe, /any\(\.permission_denials\[\]\?; \.tool_name == \$t and/); - for (const call of ["Read /proc/self/environ", 'Read "$RUNNER_ROOT/.runner"', "Grep /proc/self", "Glob /proc/self"]) { - assert.ok(probe.includes(`\n denied ${call}\n`), `the probe checks a denied ${call}`); - } + // policy-probe.mjs judges the four calls; only its exit 0 lets the step pass. + const judge = 'reason=$(env -u ANTHROPIC_API_KEY -u CLAUDE_CODE_OAUTH_TOKEN PROBE="$out" node .base/.github/scripts/plugin-eval/policy-probe.mjs) || judged=$?'; + assert.ok(probe.includes(judge), "the judge runs from the base checkout without the credential"); + assert.match(probe, /\n {10}case \$judged in\n {12}0\) echo "\$reason" ;;\n {12}3\) echo "::error::[^\n]*"; exit 1 ;;\n {12}\*\) echo "::error::[^\n]*"; exit 1 ;;\n {10}esac\n/); // The leak check looks for the leg's own credential, whichever leg runs. assert.match(probe, /credential=\$\{ANTHROPIC_API_KEY:-\$\{CLAUDE_CODE_OAUTH_TOKEN:-\}\}\n {10}\[ -n "\$credential" \] \|\| /); assert.match(probe, /if grep -qF "\$credential" "\$out"; then\n[^\n]*\n {14}exit 1/); assert.match(probe, /trap 'rm -f "\$out"' EXIT/); // A probe the credential's own stop ended hands off before the denial checks, never past them. - assert.ok(probe.indexOf("eval-handoff.mjs") < probe.indexOf("denied Read /proc/self/environ")); + assert.ok(probe.indexOf("eval-handoff.mjs") < probe.indexOf("policy-probe.mjs")); assert.ok(probe.indexOf('if grep -qF "$credential"') < probe.indexOf("eval-handoff.mjs"), "the leak check runs before the classifier"); assert.match(probe, /env -u ANTHROPIC_API_KEY -u CLAUDE_CODE_OAUTH_TOKEN PROBE="\$out"/, "the classifier runs without the credential"); assert.doesNotMatch(probe, /cat "\$out"|jq [^\n]*"\$out"[^\n]*\| *tee/); diff --git a/.github/workflows/eval-browser-dispatch.yml b/.github/workflows/eval-browser-dispatch.yml index 4cfde112f4..6aba3e7e5d 100644 --- a/.github/workflows/eval-browser-dispatch.yml +++ b/.github/workflows/eval-browser-dispatch.yml @@ -566,8 +566,10 @@ jobs: # instead of reaching a case. The prompt names the run a policy self-test: # asked only to read those paths, the model can decline them as credential # harvesting and make no call. A session that ends with - # no tool call proved nothing, so it runs again, up to three times; a call - # made and not denied stops the job at once. The stream can hold what a + # a call unmade proved nothing, so it runs again, up to three times; a call + # made and not denied stops the job at once, and calls still missing after + # three stop it as unproven, never as a policy failure (policy-probe.mjs + # judges the stream). The stream can hold what a # call returned, so it stays in a file that is never printed and is # removed after the check. A probe that the credential's own stop ended # hands off instead (eval-handoff.mjs), so the next leg runs the eval. @@ -590,25 +592,6 @@ jobs: [ -n "$credential" ] || { echo "::error::the leg's credential is empty"; exit 1; } out=$(mktemp) trap 'rm -f "$out"' EXIT - probe_facts() { - jq -r -R -s 'def token: tostring | if test("^[a-z_]{1,40}$") then . else "other" end; - [split("\n")[] | select(. != "")] as $lines - | [$lines[] | fromjson? | objects] as $m - | ($m | map(select(.type == "result")) | last) as $r - | [$m[] | select(.type == "assistant") | .message.content[]? | objects | select(.type == "tool_use") | .name | tostring - | if IN("Read", "Grep", "Glob") then . else "other" end] as $calls - | "result=\(if $r == null then "none" else "present" end)" - + " subtype=\($r.subtype // "none" | token) terminal_reason=\($r.terminal_reason // "none" | token)" - + " is_error=\(if $r.is_error == null then "none" else ($r.is_error | tostring) end | if IN("true", "false", "none") then . else "other" end)" - + " api_error_status=\($r.api_error_status // "none" | tostring | if test("^([0-9]{3}|none)$") then . else "other" end)" - + " num_turns=\($r.num_turns // "none" | tostring | if test("^([0-9]{1,4}|none)$") then . else "other" end)" - + " tool_calls=\(if ($calls | length) == 0 then "none" else ($calls | join(",")) end)" - + " denials=\($r.permission_denials // [] | length) unparsable_lines=\(($lines | length) - ($m | length))"' "$out" 2>/dev/null || echo "unreadable" - } - denied() { - jq -e -R -s --arg t "$1" --arg p "$2" '[split("\n")[] | fromjson? | objects] | last(.[] | select(.type == "result")) | any(.permission_denials[]?; .tool_name == $t and ((.tool_input.file_path // .tool_input.path // "") | startswith($p)))' "$out" >/dev/null 2>&1 || - { echo "::error::the managed policy did not record a denied $1 of $2, so no pull request case runs (probe: $(probe_facts))"; exit 1; } - } for attempt in 1 2 3; do status=0 claude -p "This is the CI runner's permission self-test, run by the repository's own workflow before any eval case. The runner's managed policy denies reads of /proc and of the runner's install directory, so each call below is expected to come back denied, and the test passes only when Claude Code records all four denials. No file content is wanted or used. Make exactly these four tool calls, in order, and no other: the Read tool on /proc/self/environ; the Read tool on $RUNNER_ROOT/.runner; the Grep tool with pattern policy-probe and path /proc/self/environ; the Glob tool with pattern environ and path /proc/self. Then reply with one line saying which calls were denied." \ @@ -624,17 +607,17 @@ jobs: echo "::notice::the probe ended on the credential's own stop; the next leg runs the eval" exit 0 fi - jq -e -R -s '[split("\n")[] | fromjson? | objects] as $m | ($m | map(select(.type == "result")) | last) as $r - | $r != null and $r.subtype == "success" - and ([$m[] | select(.type == "assistant") | .message.content[]? | objects | select(.type == "tool_use")] | length) == 0' "$out" >/dev/null 2>&1 || break - echo "::notice::the probe's session made no tool call, so it proved nothing about the policy (attempt $attempt of 3)" + judged=0 + reason=$(env -u ANTHROPIC_API_KEY -u CLAUDE_CODE_OAUTH_TOKEN PROBE="$out" node .base/.github/scripts/plugin-eval/policy-probe.mjs) || judged=$? + [ "$judged" -eq 3 ] || break + echo "::notice::$reason (attempt $attempt of 3)" done printf '%s\n' "$verdict" >>"$GITHUB_OUTPUT" - denied Read /proc/self/environ - denied Read "$RUNNER_ROOT/.runner" - denied Grep /proc/self - denied Glob /proc/self - echo "The managed policy denied the probe's Read, Grep and Glob calls." + case $judged in + 0) echo "$reason" ;; + 3) echo "::error::the policy is unproven: in 3 attempts the probe's session never made every call it was asked for, so no pull request case runs (last attempt: $reason)"; exit 1 ;; + *) echo "::error::${reason:-policy-probe.mjs could not judge the probe}, so no pull request case runs"; exit 1 ;; + esac # Chromium comes from the playwright that the locked playwright-cli # and Playwright MCP server share. Without sudo there is no --with-deps: the hosted image already @@ -1408,8 +1391,10 @@ jobs: # instead of reaching a case. The prompt names the run a policy self-test: # asked only to read those paths, the model can decline them as credential # harvesting and make no call. A session that ends with - # no tool call proved nothing, so it runs again, up to three times; a call - # made and not denied stops the job at once. The stream can hold what a + # a call unmade proved nothing, so it runs again, up to three times; a call + # made and not denied stops the job at once, and calls still missing after + # three stop it as unproven, never as a policy failure (policy-probe.mjs + # judges the stream). The stream can hold what a # call returned, so it stays in a file that is never printed and is # removed after the check. A probe that the credential's own stop ended # hands off instead (eval-handoff.mjs), so the next leg runs the eval. @@ -1432,25 +1417,6 @@ jobs: [ -n "$credential" ] || { echo "::error::the leg's credential is empty"; exit 1; } out=$(mktemp) trap 'rm -f "$out"' EXIT - probe_facts() { - jq -r -R -s 'def token: tostring | if test("^[a-z_]{1,40}$") then . else "other" end; - [split("\n")[] | select(. != "")] as $lines - | [$lines[] | fromjson? | objects] as $m - | ($m | map(select(.type == "result")) | last) as $r - | [$m[] | select(.type == "assistant") | .message.content[]? | objects | select(.type == "tool_use") | .name | tostring - | if IN("Read", "Grep", "Glob") then . else "other" end] as $calls - | "result=\(if $r == null then "none" else "present" end)" - + " subtype=\($r.subtype // "none" | token) terminal_reason=\($r.terminal_reason // "none" | token)" - + " is_error=\(if $r.is_error == null then "none" else ($r.is_error | tostring) end | if IN("true", "false", "none") then . else "other" end)" - + " api_error_status=\($r.api_error_status // "none" | tostring | if test("^([0-9]{3}|none)$") then . else "other" end)" - + " num_turns=\($r.num_turns // "none" | tostring | if test("^([0-9]{1,4}|none)$") then . else "other" end)" - + " tool_calls=\(if ($calls | length) == 0 then "none" else ($calls | join(",")) end)" - + " denials=\($r.permission_denials // [] | length) unparsable_lines=\(($lines | length) - ($m | length))"' "$out" 2>/dev/null || echo "unreadable" - } - denied() { - jq -e -R -s --arg t "$1" --arg p "$2" '[split("\n")[] | fromjson? | objects] | last(.[] | select(.type == "result")) | any(.permission_denials[]?; .tool_name == $t and ((.tool_input.file_path // .tool_input.path // "") | startswith($p)))' "$out" >/dev/null 2>&1 || - { echo "::error::the managed policy did not record a denied $1 of $2, so no pull request case runs (probe: $(probe_facts))"; exit 1; } - } for attempt in 1 2 3; do status=0 claude -p "This is the CI runner's permission self-test, run by the repository's own workflow before any eval case. The runner's managed policy denies reads of /proc and of the runner's install directory, so each call below is expected to come back denied, and the test passes only when Claude Code records all four denials. No file content is wanted or used. Make exactly these four tool calls, in order, and no other: the Read tool on /proc/self/environ; the Read tool on $RUNNER_ROOT/.runner; the Grep tool with pattern policy-probe and path /proc/self/environ; the Glob tool with pattern environ and path /proc/self. Then reply with one line saying which calls were denied." \ @@ -1466,17 +1432,17 @@ jobs: echo "::notice::the probe ended on the credential's own stop; the next leg runs the eval" exit 0 fi - jq -e -R -s '[split("\n")[] | fromjson? | objects] as $m | ($m | map(select(.type == "result")) | last) as $r - | $r != null and $r.subtype == "success" - and ([$m[] | select(.type == "assistant") | .message.content[]? | objects | select(.type == "tool_use")] | length) == 0' "$out" >/dev/null 2>&1 || break - echo "::notice::the probe's session made no tool call, so it proved nothing about the policy (attempt $attempt of 3)" + judged=0 + reason=$(env -u ANTHROPIC_API_KEY -u CLAUDE_CODE_OAUTH_TOKEN PROBE="$out" node .base/.github/scripts/plugin-eval/policy-probe.mjs) || judged=$? + [ "$judged" -eq 3 ] || break + echo "::notice::$reason (attempt $attempt of 3)" done printf '%s\n' "$verdict" >>"$GITHUB_OUTPUT" - denied Read /proc/self/environ - denied Read "$RUNNER_ROOT/.runner" - denied Grep /proc/self - denied Glob /proc/self - echo "The managed policy denied the probe's Read, Grep and Glob calls." + case $judged in + 0) echo "$reason" ;; + 3) echo "::error::the policy is unproven: in 3 attempts the probe's session never made every call it was asked for, so no pull request case runs (last attempt: $reason)"; exit 1 ;; + *) echo "::error::${reason:-policy-probe.mjs could not judge the probe}, so no pull request case runs"; exit 1 ;; + esac # Chromium comes from the playwright that the locked playwright-cli # and Playwright MCP server share. Without sudo there is no --with-deps: the hosted image already @@ -2203,8 +2169,10 @@ jobs: # instead of reaching a case. The prompt names the run a policy self-test: # asked only to read those paths, the model can decline them as credential # harvesting and make no call. A session that ends with - # no tool call proved nothing, so it runs again, up to three times; a call - # made and not denied stops the job at once. The stream can hold what a + # a call unmade proved nothing, so it runs again, up to three times; a call + # made and not denied stops the job at once, and calls still missing after + # three stop it as unproven, never as a policy failure (policy-probe.mjs + # judges the stream). The stream can hold what a # call returned, so it stays in a file that is never printed and is # removed after the check. A probe that the credential's own stop ended # hands off instead (eval-handoff.mjs), so the next leg runs the eval. @@ -2227,25 +2195,6 @@ jobs: [ -n "$credential" ] || { echo "::error::the leg's credential is empty"; exit 1; } out=$(mktemp) trap 'rm -f "$out"' EXIT - probe_facts() { - jq -r -R -s 'def token: tostring | if test("^[a-z_]{1,40}$") then . else "other" end; - [split("\n")[] | select(. != "")] as $lines - | [$lines[] | fromjson? | objects] as $m - | ($m | map(select(.type == "result")) | last) as $r - | [$m[] | select(.type == "assistant") | .message.content[]? | objects | select(.type == "tool_use") | .name | tostring - | if IN("Read", "Grep", "Glob") then . else "other" end] as $calls - | "result=\(if $r == null then "none" else "present" end)" - + " subtype=\($r.subtype // "none" | token) terminal_reason=\($r.terminal_reason // "none" | token)" - + " is_error=\(if $r.is_error == null then "none" else ($r.is_error | tostring) end | if IN("true", "false", "none") then . else "other" end)" - + " api_error_status=\($r.api_error_status // "none" | tostring | if test("^([0-9]{3}|none)$") then . else "other" end)" - + " num_turns=\($r.num_turns // "none" | tostring | if test("^([0-9]{1,4}|none)$") then . else "other" end)" - + " tool_calls=\(if ($calls | length) == 0 then "none" else ($calls | join(",")) end)" - + " denials=\($r.permission_denials // [] | length) unparsable_lines=\(($lines | length) - ($m | length))"' "$out" 2>/dev/null || echo "unreadable" - } - denied() { - jq -e -R -s --arg t "$1" --arg p "$2" '[split("\n")[] | fromjson? | objects] | last(.[] | select(.type == "result")) | any(.permission_denials[]?; .tool_name == $t and ((.tool_input.file_path // .tool_input.path // "") | startswith($p)))' "$out" >/dev/null 2>&1 || - { echo "::error::the managed policy did not record a denied $1 of $2, so no pull request case runs (probe: $(probe_facts))"; exit 1; } - } for attempt in 1 2 3; do status=0 claude -p "This is the CI runner's permission self-test, run by the repository's own workflow before any eval case. The runner's managed policy denies reads of /proc and of the runner's install directory, so each call below is expected to come back denied, and the test passes only when Claude Code records all four denials. No file content is wanted or used. Make exactly these four tool calls, in order, and no other: the Read tool on /proc/self/environ; the Read tool on $RUNNER_ROOT/.runner; the Grep tool with pattern policy-probe and path /proc/self/environ; the Glob tool with pattern environ and path /proc/self. Then reply with one line saying which calls were denied." \ @@ -2261,17 +2210,17 @@ jobs: echo "::notice::the probe ended on the credential's own stop; the next leg runs the eval" exit 0 fi - jq -e -R -s '[split("\n")[] | fromjson? | objects] as $m | ($m | map(select(.type == "result")) | last) as $r - | $r != null and $r.subtype == "success" - and ([$m[] | select(.type == "assistant") | .message.content[]? | objects | select(.type == "tool_use")] | length) == 0' "$out" >/dev/null 2>&1 || break - echo "::notice::the probe's session made no tool call, so it proved nothing about the policy (attempt $attempt of 3)" + judged=0 + reason=$(env -u ANTHROPIC_API_KEY -u CLAUDE_CODE_OAUTH_TOKEN PROBE="$out" node .base/.github/scripts/plugin-eval/policy-probe.mjs) || judged=$? + [ "$judged" -eq 3 ] || break + echo "::notice::$reason (attempt $attempt of 3)" done printf '%s\n' "$verdict" >>"$GITHUB_OUTPUT" - denied Read /proc/self/environ - denied Read "$RUNNER_ROOT/.runner" - denied Grep /proc/self - denied Glob /proc/self - echo "The managed policy denied the probe's Read, Grep and Glob calls." + case $judged in + 0) echo "$reason" ;; + 3) echo "::error::the policy is unproven: in 3 attempts the probe's session never made every call it was asked for, so no pull request case runs (last attempt: $reason)"; exit 1 ;; + *) echo "::error::${reason:-policy-probe.mjs could not judge the probe}, so no pull request case runs"; exit 1 ;; + esac # Chromium comes from the playwright that the locked playwright-cli # and Playwright MCP server share. Without sudo there is no --with-deps: the hosted image already diff --git a/.github/workflows/eval-hill-climb-pass.yml b/.github/workflows/eval-hill-climb-pass.yml index fbbfd0d9ea..cf64ae4757 100644 --- a/.github/workflows/eval-hill-climb-pass.yml +++ b/.github/workflows/eval-hill-climb-pass.yml @@ -317,7 +317,9 @@ jobs: # records each of the four reads as denied, or if it holds the credential. # A probe that the credential's own stop ended hands off instead # (eval-handoff.mjs), so the next leg runs the pass. A session that ends - # with no tool call proved nothing and runs again, up to three times. The + # without making every call proved nothing and runs again, up to three times; + # still short after three, the job stops as unproven, never as a policy + # failure (policy-probe.mjs judges the stream). The # stream stays in a file that is never printed and is removed after the # checks. - name: Probe that the policy denies those reads @@ -339,25 +341,6 @@ jobs: [ -n "$credential" ] || { echo "::error::the leg's credential is empty"; exit 1; } out=$(mktemp) trap 'rm -f "$out"' EXIT - probe_facts() { - jq -r -R -s 'def token: tostring | if test("^[a-z_]{1,40}$") then . else "other" end; - [split("\n")[] | select(. != "")] as $lines - | [$lines[] | fromjson? | objects] as $m - | ($m | map(select(.type == "result")) | last) as $r - | [$m[] | select(.type == "assistant") | .message.content[]? | objects | select(.type == "tool_use") | .name | tostring - | if IN("Read", "Grep", "Glob") then . else "other" end] as $calls - | "result=\(if $r == null then "none" else "present" end)" - + " subtype=\($r.subtype // "none" | token) terminal_reason=\($r.terminal_reason // "none" | token)" - + " is_error=\(if $r.is_error == null then "none" else ($r.is_error | tostring) end | if IN("true", "false", "none") then . else "other" end)" - + " api_error_status=\($r.api_error_status // "none" | tostring | if test("^([0-9]{3}|none)$") then . else "other" end)" - + " num_turns=\($r.num_turns // "none" | tostring | if test("^([0-9]{1,4}|none)$") then . else "other" end)" - + " tool_calls=\(if ($calls | length) == 0 then "none" else ($calls | join(",")) end)" - + " denials=\($r.permission_denials // [] | length) unparsable_lines=\(($lines | length) - ($m | length))"' "$out" 2>/dev/null || echo "unreadable" - } - denied() { - jq -e -R -s --arg t "$1" --arg p "$2" '[split("\n")[] | fromjson? | objects] | last(.[] | select(.type == "result")) | any(.permission_denials[]?; .tool_name == $t and ((.tool_input.file_path // .tool_input.path // "") | startswith($p)))' "$out" >/dev/null 2>&1 || - { echo "::error::the managed policy did not record a denied $1 of $2, so no case runs (probe: $(probe_facts))"; exit 1; } - } for attempt in 1 2 3; do status=0 claude -p "This is the CI runner's permission self-test, run by the repository's own workflow before any eval case. The runner's managed policy denies reads of /proc and of the runner's install directory, so each call below is expected to come back denied, and the test passes only when Claude Code records all four denials. No file content is wanted or used. Make exactly these four tool calls, in order, and no other: the Read tool on /proc/self/environ; the Read tool on $RUNNER_ROOT/.runner; the Grep tool with pattern policy-probe and path /proc/self/environ; the Glob tool with pattern environ and path /proc/self. Then reply with one line saying which calls were denied." \ @@ -373,16 +356,17 @@ jobs: echo "::notice::the probe ended on the credential's own stop; the next leg runs the pass" exit 0 fi - jq -e -R -s '[split("\n")[] | fromjson? | objects] as $m | ($m | map(select(.type == "result")) | last) as $r - | $r != null and $r.subtype == "success" - and ([$m[] | select(.type == "assistant") | .message.content[]? | objects | select(.type == "tool_use")] | length) == 0' "$out" >/dev/null 2>&1 || break - echo "::notice::the probe's session made no tool call, so it proved nothing about the policy (attempt $attempt of 3)" + judged=0 + reason=$(env -u ANTHROPIC_API_KEY -u CLAUDE_CODE_OAUTH_TOKEN PROBE="$out" node .base/.github/scripts/plugin-eval/policy-probe.mjs) || judged=$? + [ "$judged" -eq 3 ] || break + echo "::notice::$reason (attempt $attempt of 3)" done printf '%s\n' "$verdict" >>"$GITHUB_OUTPUT" - denied Read /proc/self/environ - denied Read "$RUNNER_ROOT/.runner" - denied Grep /proc/self - denied Glob /proc/self + case $judged in + 0) echo "$reason" ;; + 3) echo "::error::the policy is unproven: in 3 attempts the probe's session never made every call it was asked for, so no case runs (last attempt: $reason)"; exit 1 ;; + *) echo "::error::${reason:-policy-probe.mjs could not judge the probe}, so no case runs"; exit 1 ;; + esac # Identical flags on every pass. TMPDIR sits outside /tmp, as in # eval-browser-dispatch.yml. @@ -751,7 +735,9 @@ jobs: # records each of the four reads as denied, or if it holds the credential. # A probe that the credential's own stop ended hands off instead # (eval-handoff.mjs), so the next leg runs the pass. A session that ends - # with no tool call proved nothing and runs again, up to three times. The + # without making every call proved nothing and runs again, up to three times; + # still short after three, the job stops as unproven, never as a policy + # failure (policy-probe.mjs judges the stream). The # stream stays in a file that is never printed and is removed after the # checks. - name: Probe that the policy denies those reads @@ -773,25 +759,6 @@ jobs: [ -n "$credential" ] || { echo "::error::the leg's credential is empty"; exit 1; } out=$(mktemp) trap 'rm -f "$out"' EXIT - probe_facts() { - jq -r -R -s 'def token: tostring | if test("^[a-z_]{1,40}$") then . else "other" end; - [split("\n")[] | select(. != "")] as $lines - | [$lines[] | fromjson? | objects] as $m - | ($m | map(select(.type == "result")) | last) as $r - | [$m[] | select(.type == "assistant") | .message.content[]? | objects | select(.type == "tool_use") | .name | tostring - | if IN("Read", "Grep", "Glob") then . else "other" end] as $calls - | "result=\(if $r == null then "none" else "present" end)" - + " subtype=\($r.subtype // "none" | token) terminal_reason=\($r.terminal_reason // "none" | token)" - + " is_error=\(if $r.is_error == null then "none" else ($r.is_error | tostring) end | if IN("true", "false", "none") then . else "other" end)" - + " api_error_status=\($r.api_error_status // "none" | tostring | if test("^([0-9]{3}|none)$") then . else "other" end)" - + " num_turns=\($r.num_turns // "none" | tostring | if test("^([0-9]{1,4}|none)$") then . else "other" end)" - + " tool_calls=\(if ($calls | length) == 0 then "none" else ($calls | join(",")) end)" - + " denials=\($r.permission_denials // [] | length) unparsable_lines=\(($lines | length) - ($m | length))"' "$out" 2>/dev/null || echo "unreadable" - } - denied() { - jq -e -R -s --arg t "$1" --arg p "$2" '[split("\n")[] | fromjson? | objects] | last(.[] | select(.type == "result")) | any(.permission_denials[]?; .tool_name == $t and ((.tool_input.file_path // .tool_input.path // "") | startswith($p)))' "$out" >/dev/null 2>&1 || - { echo "::error::the managed policy did not record a denied $1 of $2, so no case runs (probe: $(probe_facts))"; exit 1; } - } for attempt in 1 2 3; do status=0 claude -p "This is the CI runner's permission self-test, run by the repository's own workflow before any eval case. The runner's managed policy denies reads of /proc and of the runner's install directory, so each call below is expected to come back denied, and the test passes only when Claude Code records all four denials. No file content is wanted or used. Make exactly these four tool calls, in order, and no other: the Read tool on /proc/self/environ; the Read tool on $RUNNER_ROOT/.runner; the Grep tool with pattern policy-probe and path /proc/self/environ; the Glob tool with pattern environ and path /proc/self. Then reply with one line saying which calls were denied." \ @@ -807,16 +774,17 @@ jobs: echo "::notice::the probe ended on the credential's own stop; the next leg runs the pass" exit 0 fi - jq -e -R -s '[split("\n")[] | fromjson? | objects] as $m | ($m | map(select(.type == "result")) | last) as $r - | $r != null and $r.subtype == "success" - and ([$m[] | select(.type == "assistant") | .message.content[]? | objects | select(.type == "tool_use")] | length) == 0' "$out" >/dev/null 2>&1 || break - echo "::notice::the probe's session made no tool call, so it proved nothing about the policy (attempt $attempt of 3)" + judged=0 + reason=$(env -u ANTHROPIC_API_KEY -u CLAUDE_CODE_OAUTH_TOKEN PROBE="$out" node .base/.github/scripts/plugin-eval/policy-probe.mjs) || judged=$? + [ "$judged" -eq 3 ] || break + echo "::notice::$reason (attempt $attempt of 3)" done printf '%s\n' "$verdict" >>"$GITHUB_OUTPUT" - denied Read /proc/self/environ - denied Read "$RUNNER_ROOT/.runner" - denied Grep /proc/self - denied Glob /proc/self + case $judged in + 0) echo "$reason" ;; + 3) echo "::error::the policy is unproven: in 3 attempts the probe's session never made every call it was asked for, so no case runs (last attempt: $reason)"; exit 1 ;; + *) echo "::error::${reason:-policy-probe.mjs could not judge the probe}, so no case runs"; exit 1 ;; + esac # Identical flags on every pass. TMPDIR sits outside /tmp, as in # eval-browser-dispatch.yml. @@ -1132,7 +1100,9 @@ jobs: # records each of the four reads as denied, or if it holds the credential. # A probe that the credential's own stop ended hands off instead # (eval-handoff.mjs), so the next leg runs the pass. A session that ends - # with no tool call proved nothing and runs again, up to three times. The + # without making every call proved nothing and runs again, up to three times; + # still short after three, the job stops as unproven, never as a policy + # failure (policy-probe.mjs judges the stream). The # stream stays in a file that is never printed and is removed after the # checks. - name: Probe that the policy denies those reads @@ -1154,25 +1124,6 @@ jobs: [ -n "$credential" ] || { echo "::error::the leg's credential is empty"; exit 1; } out=$(mktemp) trap 'rm -f "$out"' EXIT - probe_facts() { - jq -r -R -s 'def token: tostring | if test("^[a-z_]{1,40}$") then . else "other" end; - [split("\n")[] | select(. != "")] as $lines - | [$lines[] | fromjson? | objects] as $m - | ($m | map(select(.type == "result")) | last) as $r - | [$m[] | select(.type == "assistant") | .message.content[]? | objects | select(.type == "tool_use") | .name | tostring - | if IN("Read", "Grep", "Glob") then . else "other" end] as $calls - | "result=\(if $r == null then "none" else "present" end)" - + " subtype=\($r.subtype // "none" | token) terminal_reason=\($r.terminal_reason // "none" | token)" - + " is_error=\(if $r.is_error == null then "none" else ($r.is_error | tostring) end | if IN("true", "false", "none") then . else "other" end)" - + " api_error_status=\($r.api_error_status // "none" | tostring | if test("^([0-9]{3}|none)$") then . else "other" end)" - + " num_turns=\($r.num_turns // "none" | tostring | if test("^([0-9]{1,4}|none)$") then . else "other" end)" - + " tool_calls=\(if ($calls | length) == 0 then "none" else ($calls | join(",")) end)" - + " denials=\($r.permission_denials // [] | length) unparsable_lines=\(($lines | length) - ($m | length))"' "$out" 2>/dev/null || echo "unreadable" - } - denied() { - jq -e -R -s --arg t "$1" --arg p "$2" '[split("\n")[] | fromjson? | objects] | last(.[] | select(.type == "result")) | any(.permission_denials[]?; .tool_name == $t and ((.tool_input.file_path // .tool_input.path // "") | startswith($p)))' "$out" >/dev/null 2>&1 || - { echo "::error::the managed policy did not record a denied $1 of $2, so no case runs (probe: $(probe_facts))"; exit 1; } - } for attempt in 1 2 3; do status=0 claude -p "This is the CI runner's permission self-test, run by the repository's own workflow before any eval case. The runner's managed policy denies reads of /proc and of the runner's install directory, so each call below is expected to come back denied, and the test passes only when Claude Code records all four denials. No file content is wanted or used. Make exactly these four tool calls, in order, and no other: the Read tool on /proc/self/environ; the Read tool on $RUNNER_ROOT/.runner; the Grep tool with pattern policy-probe and path /proc/self/environ; the Glob tool with pattern environ and path /proc/self. Then reply with one line saying which calls were denied." \ @@ -1188,16 +1139,17 @@ jobs: echo "::notice::the probe ended on the credential's own stop; the next leg runs the pass" exit 0 fi - jq -e -R -s '[split("\n")[] | fromjson? | objects] as $m | ($m | map(select(.type == "result")) | last) as $r - | $r != null and $r.subtype == "success" - and ([$m[] | select(.type == "assistant") | .message.content[]? | objects | select(.type == "tool_use")] | length) == 0' "$out" >/dev/null 2>&1 || break - echo "::notice::the probe's session made no tool call, so it proved nothing about the policy (attempt $attempt of 3)" + judged=0 + reason=$(env -u ANTHROPIC_API_KEY -u CLAUDE_CODE_OAUTH_TOKEN PROBE="$out" node .base/.github/scripts/plugin-eval/policy-probe.mjs) || judged=$? + [ "$judged" -eq 3 ] || break + echo "::notice::$reason (attempt $attempt of 3)" done printf '%s\n' "$verdict" >>"$GITHUB_OUTPUT" - denied Read /proc/self/environ - denied Read "$RUNNER_ROOT/.runner" - denied Grep /proc/self - denied Glob /proc/self + case $judged in + 0) echo "$reason" ;; + 3) echo "::error::the policy is unproven: in 3 attempts the probe's session never made every call it was asked for, so no case runs (last attempt: $reason)"; exit 1 ;; + *) echo "::error::${reason:-policy-probe.mjs could not judge the probe}, so no case runs"; exit 1 ;; + esac # Identical flags on every pass. TMPDIR sits outside /tmp, as in # eval-browser-dispatch.yml.