Files
Bo 1cebfee94d Measure one skill: sealed probe dispatch and the first current ledger row (#1101)
## Measure one skill: filesystem-sealed probe dispatch and the first
current ledger row

Provenance: the instrument-panel roadmap
(`docs/plans/2026-08-26-instrument-panel-roadmap.md`) closed out with
zero skills measured because control-arm reps read
`skills/<skill>/SKILL.md` from the checkout and the operator's skill
roots mid-run. Its named successor was filesystem-sealed dispatch. This
PR builds the seal, then uses it, and the instrument caught one more
leak on the way.

**The seal.** Codex's own seatbelt does not nest inside an outer
`sandbox-exec` profile, so a rep now runs under an outer profile with
Codex's sandbox bypassed for exactly that reason: `file-read*` denied on
the checkout and every skill root (`~/.agents`, `~/.claude/skills`,
`~/.gemini/skills`, `~/.codex/skills`), `file-write*` denied everywhere
except the rep's workspace, a scratch HOME, and the temp roots. HOME and
CODEX_HOME are scratch (auth symlinked). Dispatch refuses without
`sandbox-exec` unless `PROBE_SEAL=none` is set, which marks the run
coverage-ineligible. The seal is recorded in the capture contract
(`agentops-skill-probe-capture.v3`, a `seal` block), coverage
eligibility requires `seal.mode == seatbelt`, and `verify-scorecard`
re-checks that the denied roots include the repository and the four
skill roots. Existing 08-26 sets become `legacy-unsealed` and
ineligible, which changes nothing they were counting.

**The leak the seal did not cover.** The first sealed capture showed the
xhigh control-2 rep listing the shared live workspace, finding
`treatment-1.prompt`, and reading it; the treatment prompt embeds the
skill. So every rep now gets its own empty workspace, prompts reach a
rep on stdin only, raw JSONL and stderr live in a harness-private
dispatch directory the rep can only stat, and a `sibling-prompt-read`
trap degrades any rep whose successful command names a prompt, contract,
seal, or stage file or whose output lists a sibling's artifacts.
Replaying the contaminated set under the new trap turns its REGRESSIVE
into INERT with the contaminated rep degraded; that set is superseded
and not committed.

**The second pass, after the judges read the first.** A Fable validator
passed the first sealed capture with four findings and a Codex read
failed it with seven, all real: the whole per-user temp tree stayed
readable (hundreds of stale probe directories held prompt files with the
canonical skill bytes), sibling checkouts and the real `~/.codex`
sessions were readable because seatbelt matches the traversed path and
the skill roots are symlinks into the main checkout, the dispatch
directory was writable so a rep could rename an earlier prompt into its
workspace, the contract trusted asserted seal metadata (a hand-made
Linux record was coverage-eligible), the only real-Darwin test nested
seatbelt and proved nothing, and the operator's `config.toml` was
symlinked in whole, so every rep started the operator's MCP servers. The
seal is now one run directory removed on exit (`home/ ws/ tmp/
dispatch/`, reset per rep); reads are denied under the real HOME, the
real TMPDIR, `/tmp`, the checkout, the git common directory, and each
skill root's resolved target; `dispatch/` is write-denied with
metadata-only reads; `file-link` and `file-clone` are denied on every
denied root; the rep's config is rebuilt from top-level scalars only and
`auth.json` is copied, not linked. The contract binds platform,
mechanism, wrapper, the denied and allowed roots, the rep environment,
and the sanitization, and coverage refuses any record that omits them;
the Darwin test performs its reads inside the seal. One recorded hole
remains by necessity: the codex launcher under `~/.local/bin` and
`~/dev/dotfiles/bin` is re-allowed by literal path, and coverage refuses
any allowed path inside the checkout.

**The third pass, after the judges read the second.** Both judges failed
the hardened tip again, and the headline was network: bypassing codex's
own sandbox to nest the outer seal had dropped every inner deny, not
only the filesystem ones, so a rep could fetch the canonical skill from
GitHub while the disk was sealed. A rep now reaches the network only
through a harness-owned CONNECT proxy
(`scripts/lib/probe-connect-proxy.py`) with a host allowlist discovered
from real runs (`chatgpt.com`, `ab.chatgpt.com`, the rotating
`.oaiusercontent.com` suffix, and the API-key hosts), behind a seatbelt
`network*` deny that admits only the proxy; every attempt is logged, a
refused one degrades the rep, and web search is disabled in the rep's
config. The profile is now rendered from the bound seal block by one
function, so `verify-scorecard` reconstructs it and requires the digest
to match; the recorded launcher chain is the only allowed read under a
denied root; the wrap invokes `/usr/bin/sandbox-exec` by absolute path;
the real CODEX_HOME and the Darwin cache directory are denied and bound;
every non-stdio descriptor is closed and `/dev` writes narrowed to four
devices; each rep runs in its own process group that must be empty
before the next reset; one minimal config is generated per run, bound by
text and digest, and re-parsed after each rep; one guarded trap covers
the run root and any unpublished stage; and the coverage gate prints an
eligibility row for every ledger row that names a scorecard. What stays
open is stated in the README: allow-default still governs what the
profile does not deny.

**The fourth pass, after the judges read the third.** Both judges failed
the third tip on properties the record carried but the verifier did not
check. GNU `timeout` sets its own process group and ran outside the
seal, so the per-rep reap was vacuous (four sleeping survivors after the
test passed); the seal is now the outermost process, timeout runs inside
it with `--foreground`, its absolute path is bound, and the reap counts
survivors before signalling. The verifier now pins, not merely records:
the egress host set (a module constant), port 443 only, no unix sockets,
a loopback proxy, the generated config text equal to the renderer's
output, the real CODEX_HOME and cache root inside the denied roots,
writable roots under the run root, the four device paths, the
environment allowlist, the launcher chain's digest against the producer
identity, and it refuses unknown record fields. The proxy logs an
attempt before dialing, captures the rep at accept time, refuses names
that resolve to private or loopback ranges, and its source joins the
evaluator hash set; the per-rep egress log is published with the fixture
set. Withdrawn ledger rows get their own eligibility reason. The README
lists which seal fields the verifier pins and which it only records; the
cross-capture aggregate is recounted to 4 of 14. Still open and
disclosed: Mach IPC and Apple Events beyond the filesystem and network
seals, exact-byte environment sealing beyond the allowlist, and cleanup
ownership by inode.

**The fifth pass, after the judges read the fourth.** Both judges found
the seal, proxy, reap ordering, transcripts, and scorecards sound and
failed the tip on claims the tree did not hold: the egress log the docs
called published was caught by the repository's `*.log` ignore rule, a
`setsid()` child escaped the process-group reap, and one ledger sentence
said no rep ran a command when a control rep had run `rg` over its empty
workspace. The log is now tracked and required, strictly parsed, with
both allowed and refused counts recomputed per rep; the reap is fatal to
the capture when it cannot prove emptiness and an `lsof` sweep of the
run root catches a session escape; the rep launches through a real `env
-i` boundary; the timeout budget, the launcher chain's adjacency, every
required root in both read and link denies, the evaluator match, and the
trust-table value are pinned by the verifier; and every sentence in the
ledger, README, and runbook names the check that enforces it. The
remaining gap is stated: a child that both escapes the session and holds
nothing under the run root is not detected.

**The sixth pass, after CI read the fifth.** The first CI run of this PR
failed on Linux: the launcher-chain pin walked the capturing Mac's
filesystem, so on the runner both sets read as unsealed and the coverage
headline fell to zero. A pin that holds on one host is not a pin. The
chain is now bound as structure in the seal record (each link's path,
kind, symlink target, and the final file's digest, with the invoked path
as the head), the verifier checks that record on any host, and it
cross-checks the live filesystem only where the head exists, reporting
which happened. A gate-level test runs the coverage gate twice, once
with the chain made absent, and requires byte-identical output.

**The measurement.** `premortem-plan-shape-t2` (two seeded structural
defects, band [1,4]) captured seven times on 2026-09-03, once under each
seal; the first six sets were deleted with their scorecards when a judge
or CI broke that seal. The rows that count are the seventh capture: low
INERT (control 0/2, treatment 0/2) and xhigh INERT (control 1/2,
treatment 1/2); no rep ran a command, no egress was refused, both
per-rep egress logs are tracked and verified, and both scorecards verify
with every pinned field, the launcher chain checked as a record and
cross-checked on the capturing host. The superseded captures read, at
low, BEHAVIORAL 1/1, INERT, INERT, BEHAVIORAL 1/2, INERT, BEHAVIORAL 1/2
and, at xhigh, INERT, BEHAVIORAL 2/2, INERT, INERT, INERT (control 1/2),
INERT. The reversals across captures are recorded as an unresolved N=2
observation, not variance; across all seven the treatment arm put the
marks in band in 7 of 26 usable reps and the control arm in 2 of 28, and
the ledger marks that aggregate as disclosure, not a row. Headroom is
SEPARATED at both levels. This is the first current, manifest-backed
ledger row; it records response-shape behavior change, never quality
uplift, and what it records today is INERT at both levels.

**Evidence on the tip.** Probe, exec-lib, metadata, coverage, headroom,
and adapter bats suites green with the Darwin seal, survivor,
session-escape, and environment-boundary tests running;
`verify-scorecard` on both scorecards; `check-skill-probe-coverage.sh`
reports measured 1 of 12 with the 2026-08-26 row labelled
`verdict-withdrawn`; regen check clean; full gate green with a
HEAD-built binary; CI's bats command green; Go bar, lint, security, and
the gate-tightening ratchet green.

**Judgment, stated plainly.** The caller elected Fable 5.1 as the
binding validator. Its fifth-round verdict on the pre-CI tip was PASS
with no verdict-driving findings; its read of the portability delta
failed on one test-only point (the coverage test had dropped the
`measured` pin that CI tripped on) which this tip restores. The
cross-family Codex reads of the same tips are FAIL. They closed the
network seal, the environment boundary, the fatal reap, the data roots,
the config binding, and the documentation, and they keep these open,
recorded here as the next pass rather than hidden: the evaluator
identity check still accepts the pre-proxy four-file shape for legacy
sets; an inherited absolute `CODEX_EXEC_TIMEOUT_BIN` is trusted without
a probe; a refusal logged under a rep label outside the expected set is
not rejected; an empty top-level `[projects]` table passes the drift
check; the `lsof` sweep treats a failing `lsof` as a clean result; and
the launcher chain, now bound as a record that any host can check, is
corroborated against the filesystem only on the capturing host, so a
record whose head does not exist there is taken on its word (the docs
say so; Codex wants the live walk required at capture and the invoked
head bound into the producer identity). Fable also noted a test that
mis-resolves when TMPDIR sits under `/private/tmp`, a `2026-09-04` date
in one refusal message, and that the low control replies used a `S2:
FINDING <reason>` shape the discriminator scores as "plan not walked"
rather than by band (outcome-invariant). Each of those changes a harness
or verifier file and would orphan the two rows again, so they are filed
as the follow-up instead of an eighth capture today. Across six judging
rounds the open set shrank only from round four on; the convergence
law's stop signal was reached at round three and the run continued on
the caller's instruction to land the instrument. The two ledger rows are
honest about what they measure, and the seal they ran under is the one
this tree can verify on any host.
2026-09-04 01:40:41 +00:00

1736 lines
78 KiB
Bash
Executable File

#!/usr/bin/env bash
# probe-skill.sh — skill BEHAVIORAL probe harness (age-e508.1).
#
# ============================ HONESTY HEADER =================================
# A probe measures BEHAVIOR-CHANGE, NOT quality-uplift. It answers exactly one
# question: when the declared treatment source is injected (treatment) versus
# omitted (control), does the agent actually DO the thing differently — a tool call made, an
# artifact produced, a sequence followed? It NEVER scores whether the text
# mentions the skill, and it NEVER claims the skill makes output better. A
# BEHAVIORAL verdict means the treatment increased the scored behavior; INERT
# means equal rates; REGRESSIVE means the treatment reduced it. The historical 2026-06-30 graphify report (0/2
# treatment responses obeyed the guidance) predates immutable capture metadata
# and remains LEGACY-UNVERIFIED, not a current harness result. Small N (default
# 2-3) is DIRECTIONAL, not statistical. Do not overclaim (ADR-0011 discipline).
# ============================================================================
#
# A PROBE is a directory under evals/skill-probes/<id>/ carrying:
# probe.json metadata: id, skill, reps, behavior, discriminator
# question.md the scenario question — IDENTICAL for both arms
# treatment-prelude.md an optional distilled treatment injected ONLY when
# probe.json declares treatment_source=injected-prelude;
# this mode measures the bound prelude, not full-skill
# activation, and does not qualify as skill-tier coverage
# discriminator.sh a DETERMINISTIC check over a prompt-free response envelope:
# exit 0 = behavior PRESENT, 1 = ABSENT, 2 = infra error
# fixtures/ recorded transcripts control-<n>.txt / treatment-<n>.txt
# as one bound prompt event followed by native Codex JSONL
# fixtures/capture-contract.json
# pre-dispatch binding over exact prompt bytes, producer
# request/runtime executable identity, schedule, and scoring
# fixtures/fixture-set.json
# immutable capture metadata: exact transcripts and thread
# ids, evaluation-input and canonical SKILL.md inventories,
# evaluator identity, per-file SHA-256 digests, and one
# binding digest over the complete capture contract
#
# MODES:
# live (default) dispatch a cross-family worker (codex exec — the sanctioned
# headless path; NEVER claude -p, LAW 0) for each arm x rep,
# capture the transcript, run the discriminator, and publish
# a new immutable fixture set so its bound classification is
# replayable. Existing fixture sets are never overwritten.
# --replay skip dispatch; verify immutable capture metadata and then run
# the discriminator over the bound transcripts. Legacy fixture
# sets without metadata fail closed; they are not retroactively
# blessed as verified captures.
#
# VERDICT: BEHAVIORAL iff treatment_rate > control_rate; REGRESSIVE iff lower;
# INERT iff equal; UNMEASURED iff either arm has no usable reps or a live capture
# is incomplete (a durable delta needs two measured arms and replayable evidence).
#
# Usage:
# bash scripts/probe-skill.sh --probe rpi --replay
# bash scripts/probe-skill.sh --probe rpi --replay --fixtures fixtures-xhigh-2026-08-04
# bash scripts/probe-skill.sh --probe rpi --reps 2 --output out.json
# bash scripts/probe-skill.sh --probe rpi --live --capture
# bash scripts/probe-skill.sh --probe rpi --live --model gpt-5-mini
#
# Flags: --probe <id> (required) · --replay | --live · --capture · --reps N ·
# --fixtures <directory-name> ·
# --output <path> · --timeout <secs> · --model <id> (weaker producer, the
# ratchet when a frontier producer aces both arms) · --effort <level>
# (low|medium|high|xhigh — sets codex model_reasoning_effort; the SECOND
# ratchet: when even a weaker model id aces both arms at the config
# default effort, lower the effort to surface headroom. 2026-08-04 wave-1
# finding: gpt-5.6-luna at xhigh aced 4/6 control arms).
#
# Env overrides (test seams): SKILL_PROBES_DIR (default $REPO_ROOT/evals/skill-probes),
# SKILL_PROBE_SKILLS_DIR (default $REPO_ROOT/skills), PROBE_FIXTURE_SET
# (default fixtures), PROBE_SEAL (seatbelt|none, default seatbelt)
#
# FILESYSTEM SEAL (move 2): a live rep must not be able to read the skill it
# is being measured on. Without a seal a rep inherits the operator's skill
# roots — codex walks $HOME/.agents/skills regardless of CODEX_HOME, and
# ~/.agents/skills, ~/.codex/skills, ~/.claude/skills and ~/.gemini/skills all
# symlink into this checkout — and 2026-08-28 control-arm reps read
# skills/<skill>/SKILL.md off disk by absolute path (skill-read-contamination).
#
# ONE RUN DIRECTORY. Every live capture creates $PROBE_RUN
# ($TMPDIR/probe-run.XXXXXX, mode 0700, resolved to its realpath) holding four
# fixed children: home/ (the scratch HOME; home/.codex is CODEX_HOME), ws/ (the
# rep workspace and cwd), tmp/ (the rep's TMPDIR) and dispatch/
# (harness-private: the materialized prompt, the raw codex JSONL, stderr). One
# EXIT trap removes the whole run directory, so a run leaves no probe material
# in the temp hierarchy for a later rep or run to read.
#
# RESET, NOT RELOCATE. The paths are the same for every rep so the seatbelt
# profile is constant and the capture contract binds ONE profile digest. Before
# each rep the harness recreates ws/ and tmp/ empty and rebuilds home/.codex
# from a SANITIZED config plus a COPY of auth.json, and refuses the rep if ws/
# is not empty afterwards. The sanitized config keeps only the operator's
# top-level scalar keys: every table is dropped, so a rep starts none of the
# operator's MCP servers and inherits no [projects] trust entry. auth.json is
# copied rather than symlinked because the real home is read-denied.
#
# THE NETWORK SEAL. The outer profile is `(allow default)` and codex's own
# sandbox is bypassed inside it (seatbelt does not nest), so before this a rep
# could `curl` the canonical SKILL.md off the forge and the filesystem seal
# proved nothing about what it read. Every capture now starts a harness-owned
# CONNECT proxy (scripts/lib/probe-connect-proxy.py) on 127.0.0.1 and an
# ephemeral port; the profile denies `network*` except outbound to that port;
# the rep gets HTTPS_PROXY/HTTP_PROXY/ALL_PROXY pointing at it. The proxy
# allows CONNECT only to the bound host allowlist and refuses everything else
# with 403 (and refuses a name that resolves into loopback, link-local or private
# space, so a rebinding answer cannot become a local tunnel), logging every
# CONNECT it accepts to the harness-private dispatch dir. A refused
# CONNECT degrades that rep (`network-egress`), and the per-rep counts plus the
# log digest are bound into the transcript's probe-input event.
#
# THE REP ENVIRONMENT. The rep runs with exactly the variables the seal's
# env_allowlist names, in its own process group, with every non-stdio descriptor
# closed, under a GENERATED config (not the operator's) whose text and digest
# the seal binds. After the rep the harness reaps the process group (a survivor
# degrades the rep as `rep-survivor`; the count is taken BEFORE the group is
# signalled, because by then the rep's own codex tree has already exited) and
# re-reads the config: the only
# permitted growth is codex's own `[projects."<ws>"]` trust table, anything else
# degrades the rep as `config-mutated`.
#
# THE PROFILE (seatbelt, macOS sandbox-exec; last matching rule wins). It is
# RENDERED from the bound seal block by scripts/lib/probe-fixture-metadata.py,
# and coverage requires the block to rebuild it to the recorded digest, so a
# recorded root is the bytes the kernel enforced rather than a claim beside
# them:
# * file-write* denied everywhere except run home/, ws/, tmp/ and /dev, and
# denied again on run dispatch/;
# * file-read* denied on the real TMPDIR, /tmp, /private/tmp, the real HOME
# (which subsumes ~/.agents, ~/.claude/skills, ~/.gemini/skills,
# ~/.codex/skills and every checkout under it), this checkout, the resolved
# skills dir, the git common directory's parent (the main checkout a linked
# worktree shares), and each skill root's resolved entry for the skill under
# test. Seatbelt matches the traversed path, so both the literal and the
# resolved form of every root is denied;
# * file-read* re-allowed on run home/, ws/ and tmp/, with file-read-metadata
# on their ancestors so the rep can cd into its workspace (a denied ancestor
# breaks getcwd), and file-read-metadata only on run dispatch/ (node stats
# its stdio files at startup; contents and listing stay denied);
# * file-link and file-clone denied on run dispatch/ and every denied read
# root, so a rep cannot launder a denied file into its readable workspace;
# * file-read* allowed on the producer executable itself when it resolves
# under a denied root (the codex launcher can live under the real HOME);
# every such path is recorded in the seal as allowed_read_paths.
# The profile is handed to scripts/lib/codex-exec.sh as the CODEX_EXEC_WRAP
# command prefix `(sandbox-exec -p "<profile>")`.
#
# Fail closed: when sandbox-exec is absent the live dispatch refuses. Only an
# explicit PROBE_SEAL=none runs unsealed; that run prints
# `seal: none (coverage-ineligible)` and records seal_mode=none. The seal record
# (seal.json) is written into the capture stage before the first rep and echoed
# into the scorecard under `seal`; the capture contract binds its mechanism,
# wrap, roots, rep env, config sanitization and profile digest, and tier
# coverage requires every one of them.
#
# practices: [measurement-over-assertion, ab-testing]
# shellcheck source=scripts/lib/preamble.sh disable=SC1007,SC1091
. "$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/preamble.sh"
# shellcheck source=scripts/lib/codex-exec.sh disable=SC1091
. "$REPO_ROOT/scripts/lib/codex-exec.sh"
PROBES_DIR="${SKILL_PROBES_DIR:-$REPO_ROOT/evals/skill-probes}"
SKILLS_DIR="${SKILL_PROBE_SKILLS_DIR:-$REPO_ROOT/skills}"
PROBE=""
REPLAY=0
CAPTURE=0
REPS=""
REPS_EXPLICIT=0
OUTPUT=""
TIMEOUT="${PROBE_TIMEOUT:-240}"
MODEL="${PROBE_MODEL:-}"
EFFORT="${PROBE_EFFORT:-}"
MODEL_CONSTRAINT=0
EFFORT_CONSTRAINT=0
if [[ -n "$MODEL" ]]; then MODEL_CONSTRAINT=1; fi
if [[ -n "$EFFORT" ]]; then EFFORT_CONSTRAINT=1; fi
FIXTURE_SET="${PROBE_FIXTURE_SET:-fixtures}"
usage() { grep '^#' "$0" | sed 's/^# \?//'; }
while [[ $# -gt 0 ]]; do
case "$1" in
--probe) PROBE="${2:-}"; shift 2;;
--replay) REPLAY=1; shift;;
--live) REPLAY=0; shift;;
--capture) CAPTURE=1; shift;;
--reps) REPS="${2:-}"; REPS_EXPLICIT=1; shift 2;;
--fixtures|--fixture-set) FIXTURE_SET="${2:-}"; shift 2;;
--output) OUTPUT="${2:-}"; shift 2;;
--timeout) TIMEOUT="${2:-}"; shift 2;;
--model) MODEL="${2:-}"; MODEL_CONSTRAINT=1; shift 2;;
--effort) EFFORT="${2:-}"; EFFORT_CONSTRAINT=1; shift 2;;
-h|--help) usage; exit 0;;
*) echo "Unknown flag: $1" >&2; exit 2;;
esac
done
[[ -n "$PROBE" ]] || { echo "error: --probe <id> required" >&2; exit 2; }
[[ "$PROBE" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \
|| { echo "error: unsafe probe id: $PROBE" >&2; exit 2; }
[[ "$FIXTURE_SET" =~ ^fixtures([_-][A-Za-z0-9][A-Za-z0-9._-]*)?$ ]] \
|| { echo "error: --fixtures must name a fixtures directory inside the probe: $FIXTURE_SET" >&2; exit 2; }
PROBE_DIR="$PROBES_DIR/$PROBE"
[[ -d "$PROBE_DIR" ]] || { echo "error: probe not found: $PROBE_DIR" >&2; exit 2; }
DISC="$PROBE_DIR/discriminator.sh"
QUESTION="$PROBE_DIR/question.md"
META="$PROBE_DIR/probe.json"
if [[ $REPLAY -eq 0 ]]; then
for f in "$DISC" "$QUESTION" "$META"; do
[[ -f "$f" && ! -L "$f" ]] || { echo "error: probe file missing or unsafe: $f" >&2; exit 2; }
done
fi
# Read probe metadata (python3, no jq dependency).
json_get() { python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get(sys.argv[2],""))' "$META" "$1"; }
FIXDIR="$PROBE_DIR/$FIXTURE_SET"
FIXTURE_META_TOOL="$REPO_ROOT/scripts/lib/probe-fixture-metadata.py"
[[ -f "$FIXTURE_META_TOOL" ]] || { echo "error: fixture metadata helper missing: $FIXTURE_META_TOOL" >&2; exit 2; }
HARNESS_PATH="$REPO_ROOT/scripts/probe-skill.sh"
PREAMBLE_PATH="$REPO_ROOT/scripts/lib/preamble.sh"
DISPATCH_HELPER_PATH="$REPO_ROOT/scripts/lib/codex-exec.sh"
if [[ -n "$OUTPUT" ]]; then
OUTPUT_DIR="$(dirname "$OUTPUT")"
[[ -d "$OUTPUT_DIR" ]] || { echo "error: scorecard output directory does not exist: $OUTPUT_DIR" >&2; exit 2; }
[[ ! -e "$OUTPUT" && ! -L "$OUTPUT" ]] \
|| { echo "error: refusing to overwrite immutable scorecard output: $OUTPUT" >&2; exit 2; }
fi
summary_get() {
local summary="$1" path="$2"
python3 -c '
import json, sys
value = json.loads(sys.argv[1])
for part in sys.argv[2].split("."):
if not isinstance(value, dict) or part not in value:
value = None
break
value = value[part]
print("" if value is None else value)
' "$summary" "$path"
}
summary_json() {
local summary="$1" path="$2"
python3 -c '
import json, sys
value = json.loads(sys.argv[1])
for part in sys.argv[2].split("."):
value = value[part]
print(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
' "$summary" "$path"
}
SKILL=""
TREATMENT_SOURCE=""
if [[ $REPLAY -eq 0 ]]; then
if ! PROBE_CONTRACT="$(python3 "$FIXTURE_META_TOOL" probe-contract \
--probe-dir "$PROBE_DIR" \
--skills-dir "$SKILLS_DIR" \
--probe "$PROBE")"; then
echo "error: probe contract is incomplete or unsafe" >&2
exit 2
fi
SKILL="$(summary_get "$PROBE_CONTRACT" canonical_skill.name)"
CONTRACT_REPS="$(summary_get "$PROBE_CONTRACT" reps)"
TREATMENT_SOURCE="$(summary_get "$PROBE_CONTRACT" treatment_source)"
fi
PRODUCER_MODEL=""
PRODUCER_EFFORT=""
PRODUCER_JSON=""
CAPTURE_REQUESTED_MODEL="$MODEL"
CAPTURE_REQUESTED_EFFORT="$EFFORT"
FIXTURE_BINDING=""
FIXTURE_SCHEMA=""
CAPTURE_EVALUATOR=""
CURRENT_EVALUATOR="$(python3 "$FIXTURE_META_TOOL" identity \
--harness "$HARNESS_PATH" \
--preamble "$PREAMBLE_PATH" \
--dispatch-helper "$DISPATCH_HELPER_PATH")"
if [[ $REPLAY -eq 1 ]]; then
[[ -d "$FIXDIR" && ! -L "$FIXDIR" ]] \
|| { echo "error: replay fixture set not found or unsafe: $FIXDIR" >&2; exit 2; }
if ! FIXTURE_METADATA="$(python3 "$FIXTURE_META_TOOL" verify --fixture-dir "$FIXDIR" --probe-dir "$PROBE_DIR" --skills-dir "$SKILLS_DIR" --probe "$PROBE")"; then
echo "error: replay refused: fixture metadata is missing or failed verification" >&2
exit 2
fi
MANIFEST_REPS="$(summary_get "$FIXTURE_METADATA" reps)"
if [[ $REPS_EXPLICIT -eq 1 && "$REPS" != "$MANIFEST_REPS" ]]; then
echo "error: --reps $REPS does not match fixture metadata reps $MANIFEST_REPS" >&2
exit 2
fi
REPS="$MANIFEST_REPS"
PRODUCER_MODEL="$(summary_get "$FIXTURE_METADATA" producer.model)"
PRODUCER_EFFORT="$(summary_get "$FIXTURE_METADATA" producer.effort)"
PRODUCER_JSON="$(summary_json "$FIXTURE_METADATA" producer)"
CAPTURE_REQUESTED_MODEL="$(summary_get "$FIXTURE_METADATA" requested_producer.model)"
CAPTURE_REQUESTED_EFFORT="$(summary_get "$FIXTURE_METADATA" requested_producer.effort)"
FIXTURE_BINDING="$(summary_get "$FIXTURE_METADATA" binding_sha256)"
FIXTURE_SCHEMA="$(summary_get "$FIXTURE_METADATA" schema)"
CAPTURE_EVALUATOR="$(python3 -c 'import json,sys; print(json.dumps(json.loads(sys.argv[1])["capture_evaluator"],sort_keys=True,separators=(",",":")))' "$FIXTURE_METADATA")"
TREATMENT_SOURCE="$(summary_get "$FIXTURE_METADATA" treatment_source)"
SKILL="$(summary_get "$FIXTURE_METADATA" canonical_skill.name)"
if [[ -z "$SKILL" && -f "$META" && ! -L "$META" ]]; then
SKILL="$(json_get skill)"
fi
[[ -n "$SKILL" ]] || { echo "error: verified fixture does not identify a skill" >&2; exit 2; }
if [[ $MODEL_CONSTRAINT -eq 1 && "$MODEL" != "$PRODUCER_MODEL" ]]; then
echo "error: replay --model $MODEL does not match bound fixture producer request $PRODUCER_MODEL" >&2
exit 2
fi
if [[ $EFFORT_CONSTRAINT -eq 1 && "$EFFORT" != "$PRODUCER_EFFORT" ]]; then
echo "error: replay --effort $EFFORT does not match bound fixture producer request $PRODUCER_EFFORT" >&2
exit 2
fi
else
[[ -n "$REPS" ]] || REPS="$CONTRACT_REPS"
if [[ "$REPS" != "$CONTRACT_REPS" ]]; then
echo "error: --reps $REPS does not match bound probe.json reps $CONTRACT_REPS" >&2
exit 2
fi
[[ ! -e "$FIXDIR" && ! -L "$FIXDIR" ]] || {
echo "error: refusing to overwrite immutable fixture set: $FIXDIR" >&2
echo " choose a new --fixtures name for this live capture" >&2
exit 2
}
fi
# --- filesystem seal ---------------------------------------------------------
PROBE_SEAL="${PROBE_SEAL:-seatbelt}"
SEAL_MODE=""
SEAL_PROFILE=""
SEAL_PROFILE_FILE=""
SEAL_PROFILE_SHA=""
SEAL_SANDBOX_EXEC=""
SEAL_JSON=""
SEAL_DENIED_ROOTS=()
SEAL_DENIED_DATA_ROOTS=()
SEAL_DENIED_LINK_ROOTS=()
SEAL_WRITABLE_ROOTS=()
SEAL_ALLOWED_READ_PATHS=()
SEAL_LAUNCHER_CHAIN=()
SEAL_LAUNCHER_CHAIN_JSON="[]"
SEAL_LAUNCHER_INVOKED=""
SEAL_LAUNCHER_SHA=""
SEAL_CONFIG_KEPT=""
SEAL_CONFIG_SHA=""
SEAL_CONFIG_TEXT=""
SEAL_CONFIG_SOURCE=""
SEAL_AUTH_COPIED=0
SEAL_GIT_COMMON_ROOT=""
SEAL_REAL_TMPDIR=""
SEAL_CACHE_ROOT=""
PROBE_RUN=""
RUN_HOME=""
RUN_WS=""
RUN_TMP=""
RUN_DISPATCH=""
REAL_HOME="${HOME:-}"
REAL_CODEX_HOME="${CODEX_HOME:-$REAL_HOME/.codex}"
REP_HOME="$REAL_HOME"
REP_CODEX_HOME="$REAL_CODEX_HOME"
REP_TMPDIR="${TMPDIR:-/tmp}"
# The devices a sealed rep may write. `/dev` as a whole was allowed before,
# which is a writable tree inside an otherwise read-only filesystem.
SEAL_DEV_WRITE_PATHS=(/dev/null /dev/zero /dev/dtracehelper /dev/tty)
# The variables the rep is launched with, from `env -i`. Anything not named
# here does not reach the producer: the operator's shell environment carried
# proxy settings, tokens, editor hooks and PATH entries the seal never saw.
# PWD, OLDPWD, SHLVL and _ are maintained by the shell itself and cannot be
# suppressed; they are listed so the record discloses them rather than hiding
# names the rep can actually see.
SEAL_ENV_ALLOWLIST=(PATH HOME CODEX_HOME TMPDIR LANG TERM PWD OLDPWD SHLVL _
HTTPS_PROXY HTTP_PROXY ALL_PROXY NO_PROXY
REVIEWER REVIEWER_MARKER
CODEX_EXEC_PROMPT_FILE CODEX_EXEC_DIR CODEX_EXEC_SANDBOX
CODEX_EXEC_SKIP_GIT_CHECK CODEX_EXEC_TIMEOUT CODEX_EXEC_MODEL
CODEX_EXEC_OUT_FILE CODEX_EXEC_STDERR_FILE CODEX_EXEC_EXPECT_OUTPUT
CODEX_EXEC_BIN PROBE_SEAL_PROFILE_FILE SKILL_PROBES_DIR SKILL_PROBE_SKILLS_DIR)
# Test seams: every exported PROBE_* name reaches the rep, so a stub producer
# can be told where to look. They are expanded to concrete names at seal time
# and recorded in the seal's env_allowlist, so the record discloses exactly what
# the rep was launched with rather than a pattern.
SEAL_ENV_SEAM_PREFIX="PROBE_"
# --- network seal ------------------------------------------------------------
# The hosts codex-cli 0.145 actually reached on this operator, observed by
# running reps through the proxy in discovery mode on 2026-09-03: chatgpt.com
# (the turn itself), ab.chatgpt.com (feature flags), and the OpenAI content
# hosts under oaiusercontent.com, which a real prompt needs and a trivial one
# does not. Those carry a rotating region prefix (sdmntprsouthcentralus,
# sdmntprcentralus, sdmntprwestcentralus were all seen in one capture), so they
# are allowed as one named domain suffix rather than a list that goes stale and
# nulls a capture. api.openai.com and auth.openai.com are kept for an API-key
# producer; they were NOT observed on a ChatGPT-auth account. None of these can
# serve this repository's SKILL.md.
PROBE_NETWORK_HOSTS_DEFAULT="chatgpt.com,ab.chatgpt.com,.oaiusercontent.com,api.openai.com,auth.openai.com"
PROBE_NETWORK_HOSTS="${PROBE_NETWORK_HOSTS:-$PROBE_NETWORK_HOSTS_DEFAULT}"
# An operator may still run a custom probe, but a capture that widened its own
# egress policy is recorded as `proxy-custom` and can never be coverage.
PROBE_NETWORK_CUSTOM=0
if [[ -n "${PROBE_NETWORK_HOSTS_OVERRIDE:-}" || "$PROBE_NETWORK_HOSTS" != "$PROBE_NETWORK_HOSTS_DEFAULT" ]]; then
PROBE_NETWORK_CUSTOM=1
fi
# No unix socket was needed: the proxy resolves DNS, so the rep never talks to
# mDNSResponder. Kept configurable because that is a platform detail.
PROBE_NETWORK_UNIX_SOCKETS="${PROBE_NETWORK_UNIX_SOCKETS:-}"
if [[ -n "$PROBE_NETWORK_UNIX_SOCKETS" ]]; then PROBE_NETWORK_CUSTOM=1; fi
# The one port a probe rep may reach. CONNECT to anything else is refused by the
# proxy and pinned by the verifier.
PROBE_NETWORK_PORT=443
SEAL_TIMEOUT_BIN=""
PROXY_SCRIPT="$REPO_ROOT/scripts/lib/probe-connect-proxy.py"
PROXY_PID=""
PROXY_PORT=""
PROXY_LOG=""
PROXY_REP_FILE=""
NETWORK_HOST_LIST=()
NETWORK_SOCKET_LIST=()
# shellcheck disable=SC2034 # consumed by codex_exec_guarded in the sourced library
CODEX_EXEC_WRAP=()
# resolve_seal_mode — decide seatbelt|none BEFORE any stage or workspace exists,
# so a refused seal leaves nothing behind. Absent sandbox-exec fails closed.
resolve_seal_mode() {
case "$PROBE_SEAL" in
none)
SEAL_MODE=none
echo "seal: none (coverage-ineligible)" >&2
;;
seatbelt)
# The SYSTEM binary by absolute path, never a PATH lookup: a stub
# named sandbox-exec earlier on PATH would have run instead while
# the record still claimed /usr/bin/sandbox-exec. The override is a
# test seam and is itself required to be executable, so the
# fail-closed path stays reachable.
if [[ -n "${PROBE_SEAL_SANDBOX_EXEC:-}" ]]; then
SEAL_SANDBOX_EXEC="$PROBE_SEAL_SANDBOX_EXEC"
if [[ ! -x "$SEAL_SANDBOX_EXEC" ]]; then
echo "error: PROBE_SEAL_SANDBOX_EXEC is not executable: $SEAL_SANDBOX_EXEC" >&2
exit 2
fi
elif [[ -x /usr/bin/sandbox-exec ]]; then
SEAL_SANDBOX_EXEC=/usr/bin/sandbox-exec
elif ! SEAL_SANDBOX_EXEC="$(command -v sandbox-exec 2>/dev/null)"; then
echo "error: filesystem seal unavailable: sandbox-exec is not on PATH" >&2
echo " an unsealed rep inherits the operator's skill roots (skill-read-contamination);" >&2
echo " set PROBE_SEAL=none explicitly to run unsealed (the capture is coverage-ineligible)" >&2
exit 2
fi
SEAL_MODE=seatbelt
;;
*)
echo "error: PROBE_SEAL must be seatbelt|none, got: $PROBE_SEAL" >&2
exit 2
;;
esac
}
seal_realpath() {
python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$1"
}
# seal_path_ok PATH — a seatbelt profile quotes paths with double quotes; refuse
# any path that could break out of the literal.
seal_path_ok() {
[[ -n "$1" && "$1" != *[\"\\]* && "$1" != *$'\n'* ]]
}
# seal_add_root ARRAY_NAME PATH — append PATH and its resolved form (deduped).
# A trailing slash is stripped: `(subpath "/x/")` matches nothing, and it also
# broke the prefix tests that decide whether a path sits under a denied root.
seal_add_root() {
local -n roots="$1"
local candidate resolved existing present raw
raw="$2"
[[ "$raw" == "/" ]] || raw="${raw%/}"
resolved="$(seal_realpath "$raw")"
for candidate in "$raw" "$resolved"; do
seal_path_ok "$candidate" || { echo "error: seal cannot quote path: $candidate" >&2; exit 2; }
present=0
for existing in "${roots[@]}"; do
[[ "$existing" != "$candidate" ]] || { present=1; break; }
done
[[ $present -eq 1 ]] || roots+=("$candidate")
done
}
# make_run_dir — the one run directory of this capture. Created before the seal
# so every rep-facing path is fixed and the profile can bind them, and removed
# whole by the EXIT trap so no probe material survives the run.
make_run_dir() {
local created
created="$(mktemp -d "${TMPDIR:-/tmp}/probe-run.XXXXXX")" \
|| { echo "error: could not create the probe run directory" >&2; exit 2; }
# Publish the path to the trap BEFORE anything that can fail: a chmod or a
# realpath that died left the directory behind when the trap was armed after
# them.
PROBE_RUN="$created"
chmod 0700 "$created"
PROBE_RUN="$(seal_realpath "$created")"
RUN_HOME="$PROBE_RUN/home"
RUN_WS="$PROBE_RUN/ws"
RUN_TMP="$PROBE_RUN/tmp"
RUN_DISPATCH="$PROBE_RUN/dispatch"
mkdir -p "$RUN_HOME" "$RUN_WS" "$RUN_TMP" "$RUN_DISPATCH" \
|| { echo "error: could not populate the probe run directory" >&2; exit 2; }
chmod 0700 "$RUN_HOME" "$RUN_WS" "$RUN_TMP" "$RUN_DISPATCH"
REP_TMPDIR="$RUN_TMP"
}
# seal_generate_config — ONE generated config for the whole capture, kept
# immutable under the dispatch dir and copied into each rep's scratch home. It
# is generated, never derived from the operator's file: even table-stripped,
# the operator's config carried `web_search` live (a second egress path), a
# `notify` hook naming an operator program, and a key set that moves under the
# harness. A generated file has one text and one digest to bind.
seal_generate_config() {
local summary
SEAL_CONFIG_SOURCE="$RUN_DISPATCH/config.toml"
local args=(probe-config --target "$SEAL_CONFIG_SOURCE")
if [[ -n "$EFFORT" ]]; then args+=(--effort "$EFFORT"); fi
if ! summary="$(python3 "$FIXTURE_META_TOOL" "${args[@]}")"; then
echo "error: could not generate the sealed rep config" >&2
exit 2
fi
SEAL_CONFIG_KEPT="$(summary_json "$summary" keys)"
SEAL_CONFIG_SHA="$(summary_get "$summary" sha256)"
# The record reads the text back from the generated file itself: a shell
# substitution would drop the trailing newline and the digest would no
# longer match the bytes the rep was given.
SEAL_CONFIG_TEXT="$SEAL_CONFIG_SOURCE"
}
# seal_install_rep_home — rebuild the scratch HOME/CODEX_HOME from scratch: a
# COPY of auth.json (the real home is read-denied, so a symlink cannot resolve)
# and a copy of the generated config.
seal_install_rep_home() {
rm -rf -- "$RUN_HOME"
mkdir -p "$RUN_HOME/.codex" \
|| { echo "error: could not create the scratch CODEX_HOME" >&2; exit 2; }
chmod 0700 "$RUN_HOME" "$RUN_HOME/.codex"
if [[ -f "$REAL_CODEX_HOME/auth.json" ]]; then
cp "$REAL_CODEX_HOME/auth.json" "$RUN_HOME/.codex/auth.json" \
|| { echo "error: could not copy auth.json into the scratch CODEX_HOME" >&2; exit 2; }
chmod 0600 "$RUN_HOME/.codex/auth.json"
SEAL_AUTH_COPIED=1
else
echo "seal: no auth.json under $REAL_CODEX_HOME; a real producer cannot authenticate" >&2
fi
if [[ -n "$SEAL_CONFIG_SOURCE" && -f "$SEAL_CONFIG_SOURCE" ]]; then
cp "$SEAL_CONFIG_SOURCE" "$RUN_HOME/.codex/config.toml" \
|| { echo "error: could not install the generated rep config" >&2; exit 2; }
chmod 0600 "$RUN_HOME/.codex/config.toml"
fi
REP_HOME="$RUN_HOME"
REP_CODEX_HOME="$RUN_HOME/.codex"
# The profile file lives in the scratch home, so it is rewritten with it.
if [[ -n "$SEAL_PROFILE" ]]; then
printf '%s\n' "$SEAL_PROFILE" > "$SEAL_PROFILE_FILE" \
|| { echo "error: could not write the seal profile file" >&2; exit 2; }
fi
}
# start_network_proxy — the harness-owned CONNECT proxy the rep's only egress
# runs through. Without it the outer profile is `(allow default)` for the
# network and codex's own sandbox is bypassed inside it, so a rep could fetch
# the canonical SKILL.md straight off the forge over HTTPS and the filesystem
# seal proved nothing about what it read.
start_network_proxy() {
local host args=()
[[ -f "$PROXY_SCRIPT" ]] || { echo "error: probe proxy missing: $PROXY_SCRIPT" >&2; exit 2; }
PROXY_LOG="$RUN_DISPATCH/network.log"
PROXY_REP_FILE="$RUN_DISPATCH/network.rep"
: > "$PROXY_LOG"
: > "$PROXY_REP_FILE"
NETWORK_HOST_LIST=()
# The trailing entry has no newline after it, so the read that returns it
# also reports EOF: without the `|| [[ -n ... ]]` guard the last host in the
# allowlist is silently dropped.
while IFS= read -r host || [[ -n "$host" ]]; do
[[ -n "$host" ]] || continue
NETWORK_HOST_LIST+=("$host")
args+=(--allow-host "$host")
done < <(printf '%s' "$PROBE_NETWORK_HOSTS" | tr ',' '\n')
[[ ${#NETWORK_HOST_LIST[@]} -gt 0 ]] \
|| { echo "error: the network allowlist is empty" >&2; exit 2; }
NETWORK_SOCKET_LIST=()
while IFS= read -r host || [[ -n "$host" ]]; do
[[ -n "$host" ]] || continue
NETWORK_SOCKET_LIST+=("$host")
done < <(printf '%s' "$PROBE_NETWORK_UNIX_SOCKETS" | tr ',' '\n')
local port_file="$RUN_DISPATCH/network.port"
python3 "$PROXY_SCRIPT" "${args[@]}" --allow-port "$PROBE_NETWORK_PORT" \
--log "$PROXY_LOG" --port-file "$port_file" --rep-file "$PROXY_REP_FILE" \
>"$RUN_DISPATCH/network.out" 2>"$RUN_DISPATCH/network.err" &
PROXY_PID=$!
local waited=0
while [[ ! -s "$port_file" ]]; do
if ! kill -0 "$PROXY_PID" 2>/dev/null; then
echo "error: the probe network proxy exited before it bound a port" >&2
cat "$RUN_DISPATCH/network.err" >&2 || true
exit 2
fi
waited=$((waited + 1))
[[ $waited -lt 100 ]] || { echo "error: the probe network proxy did not bind" >&2; exit 2; }
sleep 0.1
done
PROXY_PORT="$(tr -d '[:space:]' < "$port_file")"
[[ "$PROXY_PORT" =~ ^[1-9][0-9]*$ ]] \
|| { echo "error: the probe network proxy reported no port" >&2; exit 2; }
}
stop_network_proxy() {
[[ -n "$PROXY_PID" ]] || return 0
kill "$PROXY_PID" 2>/dev/null || true
wait "$PROXY_PID" 2>/dev/null || true
PROXY_PID=""
}
# seal_add_producer_path — the producer executable is resolved by the unsealed
# harness but exec'd inside the profile, so the sealed rep must be able to read
# it, and it can sit under a denied root (a codex launcher under the real HOME).
# The exception is the ONE hole in the read denies, so it is not a free-form
# list: the whole symlink chain from `command -v codex` to the real binary is
# recorded as launcher_chain, the allowance is exactly the links a denied root
# would otherwise cover, and coverage re-derives it. Without that tie, any HOME
# path holding a SKILL.md could be listed as an allowed "launcher".
seal_add_producer_path() {
local bin candidate next
bin="${CODEX_EXEC_BIN:-codex}"
if [[ "$bin" != */* ]]; then
bin="$(command -v "$bin" 2>/dev/null || true)"
fi
[[ -n "$bin" && -e "$bin" ]] || return 0
# The chain is a pure symlink walk from the INVOKED path, with ancestor
# directories resolved so each entry is the path the kernel matches. It has
# to be adjacent (entry N is a symlink to entry N+1, the last a regular
# file) or the coverage check cannot tell a launcher from any file someone
# prepended to the list.
candidate="$(seal_launcher_normalize "$bin")"
local guard=0
while :; do
seal_path_ok "$candidate" || { echo "error: seal cannot quote path: $candidate" >&2; exit 2; }
SEAL_LAUNCHER_CHAIN+=("$candidate")
[[ -L "$candidate" ]] || break
next="$(python3 -c '
import os, sys
target = os.readlink(sys.argv[1])
print(target if os.path.isabs(target) else os.path.normpath(
os.path.join(os.path.dirname(sys.argv[1]), target)))
' "$candidate")"
[[ -n "$next" ]] || break
next="$(seal_launcher_normalize "$next")"
[[ "$next" != "$candidate" ]] || break
candidate="$next"
guard=$((guard + 1))
[[ $guard -lt 16 ]] || break
done
SEAL_LAUNCHER_INVOKED="${SEAL_LAUNCHER_CHAIN[0]}"
SEAL_LAUNCHER_SHA="sha256:$(python3 -c '
import hashlib, sys
print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())
' "${SEAL_LAUNCHER_CHAIN[-1]}")"
# The chain is recorded as STRUCTURE, not as paths: what each entry is and
# where it points. A list of paths can only be checked by walking the
# filesystem it was captured on, which made the pin hold on this Mac and
# fail on every other host.
SEAL_LAUNCHER_CHAIN_JSON="$(python3 -c '
import hashlib, json, os, sys
entries = []
paths = sys.argv[1:]
for index, path in enumerate(paths):
if index == len(paths) - 1:
with open(path, "rb") as handle:
digest = hashlib.sha256(handle.read()).hexdigest()
entries.append({"path": path, "kind": "file", "sha256": "sha256:" + digest})
else:
entries.append(
{"path": path, "kind": "symlink", "target": paths[index + 1]}
)
print(json.dumps(entries, sort_keys=True))
' "${SEAL_LAUNCHER_CHAIN[@]}")"
local root covered
for candidate in "${SEAL_LAUNCHER_CHAIN[@]}"; do
covered=0
for root in "${SEAL_DENIED_ROOTS[@]}"; do
if [[ "$candidate" == "$root" || "$candidate" == "$root"/* ]]; then
covered=1
break
fi
done
[[ $covered -eq 1 ]] && SEAL_ALLOWED_READ_PATHS+=("$candidate")
done
return 0
}
# seal_launcher_normalize PATH — resolve the ancestor DIRECTORIES but not the
# final component, so the entry is what seatbelt matches while the symlink walk
# stays visible.
seal_launcher_normalize() {
python3 -c '
import os, sys
path = sys.argv[1]
print(os.path.join(os.path.realpath(os.path.dirname(path)), os.path.basename(path)))
' "$1"
}
# seal_git_common_root — the parent of the git common directory. In a linked
# worktree that is the MAIN checkout, which holds the same canonical SKILL.md
# bytes under a different path; the checkout deny alone does not cover it.
# Falls back to the checkout so the recorded root is never empty.
seal_git_common_root() {
local common
if common="$(git -C "$REPO_ROOT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" \
&& [[ -n "$common" ]]; then
seal_realpath "$(dirname "$common")"
return 0
fi
seal_realpath "$REPO_ROOT"
}
# build_seal — the facts of this capture. The PROFILE is rendered from them by
# scripts/lib/probe-fixture-metadata.py, which is also what a verifier uses to
# rebuild the profile from the bound block: one renderer, so a recorded root and
# the bytes the kernel enforced cannot drift apart.
build_seal() {
local checkout root name entry
seal_generate_config
start_network_proxy
seal_install_rep_home
checkout="$(seal_realpath "$REPO_ROOT")"
for root in "$RUN_HOME" "$RUN_WS" "$RUN_TMP"; do
if [[ "$root" == "$checkout" || "$root" == "$checkout"/* ]]; then
echo "error: seal cannot place a writable root inside the checkout: $root" >&2
exit 2
fi
done
SEAL_REAL_TMPDIR="$(seal_realpath "${TMPDIR:-/tmp}")"
SEAL_GIT_COMMON_ROOT="$(seal_git_common_root)"
# The timeout wrapper runs INSIDE the seal now, so which binary it is belongs
# in the record: a `timeout` resolved fresh from PATH at dispatch time could
# be a different one than this.
local timeout_rc=0
# Resolved and probed ONCE, here: the capability probe used to exec the
# PATH-resolved timeout outside the seal, at seal build and again on every
# rep. The absolute path is passed down so nothing re-resolves per rep.
SEAL_TIMEOUT_BIN="$(codex_exec_timeout_bin)" || timeout_rc=$?
if [[ "$timeout_rc" -ne 0 ]]; then
echo "error: the resolved timeout does not accept --foreground; a rep would run in its process group" >&2
exit 2
fi
if [[ -z "$SEAL_TIMEOUT_BIN" ]]; then
echo "error: no timeout binary is available; a sealed rep must run under a bound budget" >&2
exit 2
fi
# A recorded binary is not a budget: --timeout 0 omitted the wrapper while
# the record still named one.
if [[ ! "$TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then
echo "error: a sealed capture requires a positive --timeout; got: $TIMEOUT" >&2
exit 2
fi
# The Darwin per-user cache directory, sibling of the per-user T dir. It is
# not skill material, but it is an operator-writable tree the rep had full
# read of; node and codex start with it denied (verified 2026-09-03).
SEAL_CACHE_ROOT="$(getconf DARWIN_USER_CACHE_DIR 2>/dev/null || true)"
if [[ -n "$SEAL_CACHE_ROOT" ]]; then
SEAL_CACHE_ROOT="$(seal_realpath "${SEAL_CACHE_ROOT%/}")"
fi
# The whole per-user temp hierarchy: this run's own directory is allowed
# back below, so what stays denied is every other run's debris.
seal_add_root SEAL_DENIED_ROOTS "${TMPDIR:-/tmp}"
seal_add_root SEAL_DENIED_ROOTS /private/tmp
seal_add_root SEAL_DENIED_ROOTS /tmp
[[ -z "$SEAL_CACHE_ROOT" ]] || seal_add_root SEAL_DENIED_ROOTS "$SEAL_CACHE_ROOT"
# The real HOME subsumes the four skill roots, ~/.codex sessions (which
# carry canonical text) and every checkout under it; the explicit roots stay
# so the record names what it protects even when HOME moves. The real
# CODEX_HOME is denied WHOLE, not only its skills dir: its sessions and
# rollouts hold canonical text and its config names other checkouts, and it
# can be configured outside HOME entirely.
seal_add_root SEAL_DENIED_ROOTS "$REAL_HOME"
seal_add_root SEAL_DENIED_ROOTS "$REAL_CODEX_HOME"
seal_add_root SEAL_DENIED_ROOTS "$REPO_ROOT"
seal_add_root SEAL_DENIED_ROOTS "$SKILLS_DIR"
seal_add_root SEAL_DENIED_ROOTS "$SEAL_GIT_COMMON_ROOT"
seal_add_root SEAL_DENIED_ROOTS "$REAL_HOME/.agents"
seal_add_root SEAL_DENIED_ROOTS "$REAL_HOME/.claude/skills"
seal_add_root SEAL_DENIED_ROOTS "$REAL_HOME/.gemini/skills"
seal_add_root SEAL_DENIED_ROOTS "$REAL_HOME/.codex/skills"
# A skill root entry is a symlink INTO some checkout, and seatbelt matches
# the traversed path, so deny the target the entry resolves to as well.
if [[ -n "$SKILL" ]]; then
for name in "$REAL_HOME/.agents/skills" "$REAL_HOME/.claude/skills" \
"$REAL_HOME/.gemini/skills" "$REAL_HOME/.codex/skills" \
"$REAL_CODEX_HOME/skills"; do
entry="$name/$SKILL"
[[ -e "$entry" || -L "$entry" ]] || continue
seal_add_root SEAL_DENIED_ROOTS "$(dirname "$(seal_realpath "$entry")")"
done
fi
# The harness dispatch dir (prompt files, raw JSONL, stderr, the proxy log
# and the generated config of every rep): metadata only, so node can stat
# its stdio, with contents, listing and writes denied.
seal_add_root SEAL_DENIED_DATA_ROOTS "$RUN_DISPATCH"
seal_add_root SEAL_WRITABLE_ROOTS "$RUN_HOME"
seal_add_root SEAL_WRITABLE_ROOTS "$RUN_WS"
seal_add_root SEAL_WRITABLE_ROOTS "$RUN_TMP"
# Laundering roots: a hard link or a clone turns a denied file into a
# readable one inside the workspace, so link and clone are denied wherever
# reads are.
for root in "${SEAL_DENIED_ROOTS[@]}" "${SEAL_DENIED_DATA_ROOTS[@]}"; do
seal_add_root SEAL_DENIED_LINK_ROOTS "$root"
done
seal_add_producer_path
SEAL_PROFILE_FILE="$RUN_HOME/seal.sb"
rep_env_assignments
}
# seal_payload_file PATH — the JSON the renderer turns into a profile and a
# record. Every field the contract binds comes from here, so the harness never
# writes profile text of its own.
seal_payload_file() {
python3 - "$1" "$SEAL_MODE" "$SEAL_SANDBOX_EXEC" "$(uname -s)" \
"$SEAL_PROFILE_FILE" \
"$(printf '%s\n' ${SEAL_DENIED_ROOTS[@]+"${SEAL_DENIED_ROOTS[@]}"})" \
"$(printf '%s\n' ${SEAL_DENIED_DATA_ROOTS[@]+"${SEAL_DENIED_DATA_ROOTS[@]}"})" \
"$(printf '%s\n' ${SEAL_DENIED_LINK_ROOTS[@]+"${SEAL_DENIED_LINK_ROOTS[@]}"})" \
"$(printf '%s\n' ${SEAL_WRITABLE_ROOTS[@]+"${SEAL_WRITABLE_ROOTS[@]}"})" \
"$(printf '%s\n' ${SEAL_DEV_WRITE_PATHS[@]+"${SEAL_DEV_WRITE_PATHS[@]}"})" \
"$(printf '%s\n' ${SEAL_ALLOWED_READ_PATHS[@]+"${SEAL_ALLOWED_READ_PATHS[@]}"})" \
"$SEAL_LAUNCHER_CHAIN_JSON" \
"$SEAL_LAUNCHER_INVOKED" "$SEAL_LAUNCHER_SHA" \
"$(printf '%s\n' ${SEAL_ENV_ALLOWLIST[@]+"${SEAL_ENV_ALLOWLIST[@]}"})" \
"$REP_HOME" "$REP_CODEX_HOME" "$REP_TMPDIR" \
"$REAL_HOME" "$REAL_CODEX_HOME" "$SEAL_REAL_TMPDIR" "$SEAL_CACHE_ROOT" \
"$SEAL_GIT_COMMON_ROOT" "$PROBE_RUN" "$RUN_WS" "$RUN_DISPATCH" \
"$(printf '%s\n' ${NETWORK_HOST_LIST[@]+"${NETWORK_HOST_LIST[@]}"})" \
"$(printf '%s\n' ${NETWORK_SOCKET_LIST[@]+"${NETWORK_SOCKET_LIST[@]}"})" \
"$PROXY_PORT" "$SEAL_CONFIG_KEPT" "$SEAL_CONFIG_SHA" "$SEAL_CONFIG_TEXT" \
"$SEAL_AUTH_COPIED" "$SEAL_TIMEOUT_BIN" "$PROBE_NETWORK_PORT" \
"$PROBE_NETWORK_CUSTOM" "$TIMEOUT" <<'PY'
import json
import sys
(
path, mode, sandbox_exec, platform, profile_file,
denied, denied_data, denied_link, writable, dev_write, allowed_read,
launcher_chain, launcher_invoked, launcher_sha, env_allowlist,
rep_home, rep_codex_home, rep_tmpdir,
real_home, real_codex_home, real_tmpdir, cache_root, git_common_root,
run_root, workspace_root, dispatch_root,
hosts, sockets, proxy_port, config_keys, config_sha, config_text,
auth_copied, timeout_bin, network_port, network_custom, timeout_seconds,
) = sys.argv[1:]
# config_text arrives as the generated file's path (empty when unsealed);
# read the exact bytes so the digest matches what the rep was given.
if config_text:
with open(config_text, encoding="utf-8") as handle:
config_text = handle.read()
def lines(value):
return [line for line in value.split("\n") if line]
sealed = mode == "seatbelt"
payload = {
"seal_mode": mode,
"sandbox_exec": sandbox_exec,
"platform": platform,
"profile_file": profile_file,
"denied_read_roots": lines(denied),
"denied_read_data_roots": lines(denied_data),
"denied_link_roots": lines(denied_link),
"writable_roots": lines(writable),
"dev_write_paths": lines(dev_write) if sealed else [],
"allowed_read_paths": lines(allowed_read),
"launcher_chain": json.loads(launcher_chain) if sealed else [],
"launcher_invoked": launcher_invoked,
"launcher_sha256": launcher_sha,
"env_allowlist": lines(env_allowlist),
"rep_env": {
"HOME": rep_home,
"CODEX_HOME": rep_codex_home,
"TMPDIR": rep_tmpdir,
},
"real_home": real_home,
"real_codex_home": real_codex_home,
"real_tmpdir": real_tmpdir,
"cache_root": cache_root,
"git_common_root": git_common_root,
"run_root": run_root,
"workspace_root": workspace_root,
"dispatch_root": dispatch_root,
"network": {
"mode": (
("proxy-custom" if network_custom == "1" else "proxy-allowlist")
if sealed
else "open"
),
"hosts": sorted(lines(hosts)) if sealed else [],
"ports": [int(network_port)] if sealed and network_port else [],
"proxy": f"127.0.0.1:{proxy_port}" if sealed and proxy_port else None,
"unix_sockets": sorted(lines(sockets)) if sealed else [],
},
"timeout_bin": timeout_bin,
"timeout_seconds": int(timeout_seconds) if sealed and timeout_seconds else 0,
"config_sanitized": json.loads(config_keys) if config_keys else None,
"config_sha256": config_sha,
"config_text": config_text,
"auth_copied": auth_copied == "1",
}
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, sort_keys=True)
PY
}
# reset_rep_environment — same paths every rep, emptied between reps. The
# scratch CODEX_HOME is rebuilt too: codex writes its session rollout there, and
# a rollout holds the prompt bytes of the rep that wrote it.
reset_rep_environment() {
rm -rf -- "$RUN_WS" "$RUN_TMP" || return 1
mkdir -p "$RUN_WS" "$RUN_TMP" || return 1
chmod 0700 "$RUN_WS" "$RUN_TMP" || return 1
if [[ "$SEAL_MODE" == "seatbelt" ]]; then
seal_install_rep_home
fi
[[ -z "$(ls -A "$RUN_WS")" ]] || return 1
return 0
}
# write_seal_record PATH — hand the payload to the renderer, which writes
# seal.json with the rendered profile, its digest and the wrap. The harness
# reads the profile back so the dispatch and the record cannot disagree.
write_seal_record() {
local payload="$RUN_DISPATCH/seal-payload.json"
seal_payload_file "$payload" \
|| { echo "error: could not assemble the seal payload" >&2; exit 2; }
if ! SEAL_JSON="$(python3 "$FIXTURE_META_TOOL" seal-record \
--payload "$payload" --output "$1")"; then
echo "error: could not write seal record: $1" >&2
exit 2
fi
if [[ "$SEAL_MODE" == "seatbelt" ]]; then
SEAL_PROFILE="$(python3 -c '
import json, sys
print(json.loads(sys.argv[1])["profile"], end="")
' "$SEAL_JSON")"
# shellcheck disable=SC2034 # reported in the seal notice below
SEAL_PROFILE_SHA="$(summary_get "$SEAL_JSON" profile_sha256)"
printf '%s\n' "$SEAL_PROFILE" > "$SEAL_PROFILE_FILE" \
|| { echo "error: could not write the seal profile file" >&2; exit 2; }
# `env -i` is the outermost word: it is the environment boundary, and the
# seal runs inside it. The library appends the timeout argv and the
# producer binary after this prefix.
# shellcheck disable=SC2034 # consumed by codex_exec_guarded in the sourced library
CODEX_EXEC_WRAP=(/usr/bin/env -i
${REP_ENV_ASSIGNMENTS[@]+"${REP_ENV_ASSIGNMENTS[@]}"}
"$SEAL_SANDBOX_EXEC" -p "$SEAL_PROFILE")
fi
}
if [[ $REPLAY -eq 0 ]]; then
resolve_seal_mode
fi
[[ "$REPS" =~ ^[1-9][0-9]*$ ]] || { echo "error: --reps must be a positive integer, got: $REPS" >&2; exit 2; }
[[ "$REPS" -le 20 ]] || { echo "error: --reps must not exceed 20, got: $REPS" >&2; exit 2; }
[[ "$TIMEOUT" =~ ^[0-9]+$ ]] || { echo "error: --timeout must be a non-negative integer, got: $TIMEOUT" >&2; exit 2; }
# Structured JSONL is mandatory for new captures. --effort plumbs through the
# codex-exec lib's CODEX_EXEC_EXTRA_ARGS array
# (arrays cannot cross a process boundary, so the flag lives here, in the same
# shell that sources the lib). Applied to BOTH arms — the producer config must
# stay symmetric or the delta is confounded.
if [[ $REPLAY -eq 0 ]]; then
# shellcheck disable=SC2034 # consumed by codex_exec_guarded in the sourced library
CODEX_EXEC_EXTRA_ARGS=(--json --ephemeral)
fi
if [[ $REPLAY -eq 0 && -n "$EFFORT" ]]; then
# shellcheck disable=SC2034 # CODEX_EXEC_EXTRA_ARGS is consumed by codex_exec_guarded in the sourced codex-exec.sh
case "$EFFORT" in
low|medium|high|xhigh) CODEX_EXEC_EXTRA_ARGS+=(-c "model_reasoning_effort=\"$EFFORT\"");;
*) echo "error: --effort must be low|medium|high|xhigh, got: $EFFORT" >&2; exit 2;;
esac
fi
# rep_env_assignments — the EXACT environment the rep is launched with, as
# KEY=VALUE words for `env -i`. The previous shape emptied the environment from
# inside the dispatch subshell, which cannot clear bash's readonly exports
# (SHELLOPTS, BASHOPTS, UID, EUID, PPID): they survived the unset loop and
# reached the producer undeclared. Now that the seal is the outermost process
# and the launch is a plain argv, `env -i` is the boundary and the recorded
# allowlist is exactly what crosses it.
rep_env_assignments() {
REP_ENV_ASSIGNMENTS=()
SEAL_ENV_ALLOWLIST=()
local name value
local -a names=(PATH HOME CODEX_HOME TMPDIR LANG TERM)
if [[ -n "$PROXY_PORT" ]]; then
names+=(HTTPS_PROXY HTTP_PROXY ALL_PROXY NO_PROXY)
fi
# Test seams: every exported PROBE_* name plus the two probes-tree pointers,
# so a stub producer can be told where to look. They are expanded to concrete
# names here, so the record lists variables rather than a pattern.
while IFS= read -r name; do
[[ "$name" == "$SEAL_ENV_SEAM_PREFIX"* ]] || continue
names+=("$name")
done < <(compgen -e || true)
names+=(SKILL_PROBES_DIR SKILL_PROBE_SKILLS_DIR)
for name in "${names[@]}"; do
case "$name" in
HOME) value="$REP_HOME";;
CODEX_HOME) value="$REP_CODEX_HOME";;
TMPDIR) value="$REP_TMPDIR";;
HTTPS_PROXY|HTTP_PROXY|ALL_PROXY) value="http://127.0.0.1:$PROXY_PORT";;
NO_PROXY) value="";;
PROBE_SEAL_PROFILE_FILE) value="$SEAL_PROFILE_FILE";;
*)
[[ -n "${!name+set}" ]] || continue
value="${!name}";;
esac
seal_path_ok "$name" || { echo "error: unsafe rep environment name: $name" >&2; exit 2; }
case "$name" in
*=*) echo "error: unsafe rep environment name: $name" >&2; exit 2;;
esac
REP_ENV_ASSIGNMENTS+=("$name=$value")
SEAL_ENV_ALLOWLIST+=("$name")
done
# PROBE_SEAL_PROFILE_FILE is a harness-internal seam that no exported name
# carries, so it is added explicitly when the seal has one.
if [[ -n "$SEAL_PROFILE_FILE" ]]; then
local present=0 existing
for existing in "${SEAL_ENV_ALLOWLIST[@]}"; do
[[ "$existing" != "PROBE_SEAL_PROFILE_FILE" ]] || { present=1; break; }
done
if [[ $present -eq 0 ]]; then
REP_ENV_ASSIGNMENTS+=("PROBE_SEAL_PROFILE_FILE=$SEAL_PROFILE_FILE")
SEAL_ENV_ALLOWLIST+=(PROBE_SEAL_PROFILE_FILE)
fi
fi
# bash sets these for itself in any child shell; they are declared because
# the rep can see them, not because the harness passes them.
SEAL_ENV_ALLOWLIST+=(PWD SHLVL _)
}
# rep_group_members PGID — how many live processes are still in the group.
# `pgrep -g` is not usable here: on macOS an unmatched group id makes it list
# every process, which would report a survivor after every clean rep.
rep_group_members() {
local snapshot
# A failed `ps` must NOT read as zero members: piping straight into awk
# printed 0 for "nothing matched" and for "could not look", and the caller
# could not tell the difference.
snapshot="$(ps -A -o pgid=,pid= 2>/dev/null)" || { printf 'ERR'; return 1; }
[[ -n "$snapshot" ]] || { printf 'ERR'; return 1; }
printf '%s' "$snapshot" \
| awk -v want="$1" '$1 == want { count += 1 } END { print count + 0 }'
}
# run_root_residue — pids holding a cwd or an open descriptor under the run
# root. A child that calls setsid() leaves the rep's process group entirely, so
# the group reap cannot see it; it kept running with the run directory open and
# no `rep-survivor` fired.
run_root_residue() {
local out
[[ -n "$PROBE_RUN" && -d "$PROBE_RUN" ]] || { printf ''; return 0; }
command -v lsof >/dev/null 2>&1 || { printf 'ERR'; return 1; }
# +D walks the tree: a child's cwd is the WORKSPACE, one level under the run
# root, and `lsof -t -- <dir>` matches that exact path only.
out="$(lsof -t +D "$PROBE_RUN" 2>/dev/null || true)"
# The harness itself and its own proxy legitimately hold the run root: the
# proxy's stdout, stderr and log all live under dispatch/. Only processes
# that are neither are rep residue.
local pid keep=""
for pid in $out; do
[[ "$pid" =~ ^[0-9]+$ ]] || continue
[[ "$pid" != "$$" ]] || continue
[[ -z "$PROXY_PID" || "$pid" != "$PROXY_PID" ]] || continue
keep="$keep$pid "
done
printf '%s' "$keep"
}
# reap_run_root — kill anything still holding the run root, then prove it gone.
# Returns 0 clean, 1 residue found and cleared, 2 unprovable.
reap_run_root() {
local residue pid waited=0
residue="$(run_root_residue)" || return 2
[[ "$residue" != "ERR" ]] || return 2
[[ -n "${residue// /}" ]] || return 0
for pid in $residue; do
[[ "$pid" =~ ^[0-9]+$ ]] || continue
[[ "$pid" != "$$" ]] || continue
kill -TERM "$pid" 2>/dev/null || true
done
while :; do
local now
now="$(run_root_residue)" || return 2
[[ "$now" != "ERR" ]] || return 2
[[ -n "${now// /}" ]] || return 1
waited=$((waited + 1))
if [[ $waited -eq 20 ]]; then
for pid in $now; do
[[ "$pid" =~ ^[0-9]+$ ]] || continue
[[ "$pid" != "$$" ]] || continue
kill -KILL "$pid" 2>/dev/null || true
done
fi
[[ $waited -lt 40 ]] || return 2
sleep 0.1
done
}
# reap_rep_group PGID — count survivors BEFORE signalling, then kill the group
# and prove it is empty. The count has to come first: `wait` has already
# returned, so the rep's own codex tree is gone by definition and anything still
# in the group outlived the rep. It would see the next rep's workspace.
# Returns 0 clean, 1 when something outlived the rep, 2 when the group would not
# die even after KILL.
reap_rep_group() {
local pgid="$1" waited=0 survivors=0 members
survivors="$(rep_group_members "$pgid")" || return 2
[[ "$survivors" != "ERR" ]] || return 2
kill -TERM -"$pgid" 2>/dev/null || true
while :; do
members="$(rep_group_members "$pgid")" || return 2
[[ "$members" != "ERR" ]] || return 2
[[ "$members" != "0" ]] || break
waited=$((waited + 1))
if [[ $waited -eq 20 ]]; then
kill -KILL -"$pgid" 2>/dev/null || true
fi
[[ $waited -lt 40 ]] || return 2
sleep 0.1
done
[[ "$survivors" == "0" ]] || return 1
return 0
}
# dispatch_live ARM REP TRANSCRIPT_OUT -> bind the exact prompt event, capture
# native Codex JSONL, and populate TRANSCRIPT_OUT with one structured envelope.
dispatch_live() {
local arm="$1" rep="$2" transcript="$3" receipt_name="$4" result_name="$5"
local rc=0 receipt="" outcome="DEGRADED" had_noclobber=0 workspace=""
# Harness-private per-rep files stay in the read-denied dispatch dir; the
# rep's cwd is the run directory's one workspace, emptied before this rep.
local prompt_file="$RUN_DISPATCH/$arm-$rep.prompt"
local runtime_file="$RUN_DISPATCH/$arm-$rep.codex.jsonl"
local stderr_file="$RUN_DISPATCH/$arm-$rep.codex.stderr"
# Same path every rep so the profile stays constant; emptied, and the
# scratch CODEX_HOME rebuilt, so this rep starts from nothing.
if ! reset_rep_environment; then
echo "probe-skill: could not reset a fresh empty $arm-$rep workspace" >&2
printf -v "$receipt_name" '%s' ""
printf -v "$result_name" '%s' "$outcome"
return 1
fi
workspace="$RUN_WS"
if [[ -n "$PROXY_REP_FILE" ]]; then
printf '%s\n' "$arm-$rep" > "$PROXY_REP_FILE" \
|| { echo "probe-skill: could not tag the proxy log for $arm-$rep" >&2; }
fi
if ! python3 "$FIXTURE_META_TOOL" capture-file \
--fixture-dir "$LIVE_STAGE" --probe "$PROBE" \
--name "prompt-$arm" >"$prompt_file"; then
echo "probe-skill: could not materialize bound $arm-$rep prompt" >&2
printf -v "$receipt_name" '%s' ""
printf -v "$result_name" '%s' "$outcome"
return 1
fi
if [[ -o noclobber ]]; then
had_noclobber=1
else
set -o noclobber
fi
if ! exec 9> "$transcript"; then
[[ $had_noclobber -eq 1 ]] || set +o noclobber
echo "probe-skill: $arm dispatch refused unsafe/existing transcript sink" >&2
printf -v "$receipt_name" '%s' ""
printf -v "$result_name" '%s' "$outcome"
return 1
fi
[[ $had_noclobber -eq 1 ]] || set +o noclobber
# read-only sandbox: the probe only wants the agent's PLAN text, no mutation.
# --model routes a WEAKER producer (e.g. gpt-5-mini) — the ratchet for
# surfacing a skill's behavioral value when a frontier producer aces both arms
# (the membrane-eval-too-easy lesson). Empty => the codex default (frontier).
# The rep runs with the sealed HOME/CODEX_HOME and its own fresh workspace
# as cwd: a cwd inside the denied checkout makes every shell child print a
# getcwd error on stderr, which degrades the rep. The prompt file is fed on
# stdin by the (unsealed) harness shell; the rep never sees it on disk.
# CODEX_EXEC_WRAP (the seatbelt prefix) reaches the library through the
# shared shell, arrays never cross a process boundary.
# The rep runs in its OWN process group so a forked survivor cannot outlive
# the rep and read the next rep's workspace: `set -m` gives the background
# job its own pgid, and the group is signalled and reaped below.
local rep_pgid=""
local had_monitor=0
case "$-" in *m*) had_monitor=1;; esac
set -m
(
cd "$workspace" || exit 70
# Close every descriptor the harness owns before the producer starts:
# FD 9 is the open transcript sink, and the dispatch handles belong to
# the harness, not the rep.
exec 9>&-
local entry fd
for entry in /dev/fd/*; do
fd="${entry##*/}"
case "$fd" in 0|1|2|\*) continue;; esac
[[ "$fd" =~ ^[0-9]+$ ]] || continue
eval "exec ${fd}>&-" 2>/dev/null || true
done
# These are read by the sourced library to BUILD the argv; with `env -i`
# as the boundary they never reach the rep itself.
REVIEWER=codex \
REVIEWER_MARKER=turn.completed \
CODEX_EXEC_PROMPT_FILE="$prompt_file" \
CODEX_EXEC_DIR="$workspace" \
CODEX_EXEC_SANDBOX=read-only \
CODEX_EXEC_SKIP_GIT_CHECK=1 \
CODEX_EXEC_TIMEOUT="$TIMEOUT" \
CODEX_EXEC_TIMEOUT_BIN="$SEAL_TIMEOUT_BIN" \
CODEX_EXEC_MODEL="$MODEL" \
CODEX_EXEC_OUT_FILE="$runtime_file" \
CODEX_EXEC_STDERR_FILE="$stderr_file" \
CODEX_EXEC_EXPECT_OUTPUT=1 \
codex_exec_guarded >/dev/null
) &
rep_pgid=$!
[[ $had_monitor -eq 1 ]] || set +m
wait "$rep_pgid" || rc=$?
# A reap that cannot PROVE the group empty is fatal to the whole capture, not
# a degraded rep: a process that outlived KILL, or a `ps` that could not be
# read, means the next rep would start beside something unaccounted for.
local reap_rc=0
reap_rep_group "$rep_pgid" || reap_rc=$?
if [[ "$reap_rc" -eq 2 ]]; then
echo "error: could not prove the $arm-$rep process group empty; capture aborted" >&2
exit 1
fi
if [[ "$reap_rc" -eq 1 ]]; then
echo "probe-skill: $arm-$rep left a running process behind (rep-survivor)" >&2
[[ "$rc" -ne 0 ]] || rc=3
fi
# A child that calls setsid() leaves the process group, so the group reap
# cannot see it. Anything still holding the run root is the same defect by
# another route: degrade the rep, and abort when it cannot be cleared.
local residue_rc=0
reap_run_root || residue_rc=$?
if [[ "$residue_rc" -eq 2 ]]; then
echo "error: could not prove the run root free of $arm-$rep processes; capture aborted" >&2
exit 1
fi
if [[ "$residue_rc" -eq 1 ]]; then
echo "probe-skill: $arm-$rep left a process holding the run root (rep-survivor, session escape)" >&2
[[ "$rc" -ne 0 ]] || rc=3
fi
printf '%s\n' "" > "$PROXY_REP_FILE" 2>/dev/null || true
# Producer stderr fails the rep closed. ONE literal is excluded: codex-cli
# >= 0.14 announces stdin prompt delivery with "Reading prompt from
# stdin...", and THIS harness chose stdin delivery ten lines above
# (CODEX_EXEC_PROMPT_FILE), so that line is the harness hearing its own
# echo, not a producer diagnostic — on codex-cli 0.145.0 it accompanies a
# zero exit and a complete JSONL stream, which degraded 100% of live reps
# and made every live capture UNMEASURED. Every other byte the producer
# writes still degrades the rep, and the full stderr is still echoed. This
# admits a dispatch; it can never turn ABSENT into PRESENT, and the bound
# prompt event, transcript inventory, and discriminator still decide the
# rep.
if [[ -s "$stderr_file" ]]; then
cat "$stderr_file" >&2
if grep -qvxF 'Reading prompt from stdin...' "$stderr_file"; then
[[ "$rc" -ne 0 ]] || rc=2
fi
fi
# What this rep asked the proxy for. A refused CONNECT is a rep reaching for
# a destination the capture does not permit, so it degrades: `network-egress`.
local egress="" egress_json=""
if [[ "$SEAL_MODE" == "seatbelt" ]]; then
if ! egress="$(python3 "$FIXTURE_META_TOOL" proxy-egress \
--log "$PROXY_LOG" --rep "$arm-$rep")"; then
echo "probe-skill: could not read the $arm-$rep proxy log" >&2
[[ "$rc" -ne 0 ]] || rc=4
else
if [[ "$(summary_get "$egress" refused)" != "0" ]]; then
echo "probe-skill: rep DEGRADED (network-egress): $arm-$rep was refused $(summary_get "$egress" refused) connection(s): $(summary_json "$egress" detail)" >&2
[[ "$rc" -ne 0 ]] || rc=4
fi
egress_json="$(python3 -c '
import json, sys
record = json.loads(sys.argv[1])
print(json.dumps({key: record[key] for key in ("allowed", "refused", "log_sha256")},
sort_keys=True, separators=(",", ":")))
' "$egress")"
fi
# The generated config is immutable except for the one trust table codex
# writes for its own cwd. Anything else means the rep edited the file it
# was measured under: `config-mutated`.
local drift
if drift="$(python3 "$FIXTURE_META_TOOL" config-drift \
--path "$REP_CODEX_HOME/config.toml" \
--expected-file "$SEAL_CONFIG_SOURCE" \
--workspace "$workspace")"; then
if [[ "$(summary_json "$drift" findings)" != "[]" ]]; then
echo "probe-skill: rep DEGRADED (config-mutated): $arm-$rep $(summary_json "$drift" findings)" >&2
[[ "$rc" -ne 0 ]] || rc=5
fi
else
echo "probe-skill: could not check the $arm-$rep config for drift" >&2
[[ "$rc" -ne 0 ]] || rc=5
fi
fi
if [[ "$rc" -eq 0 ]]; then
local -a assemble_args=(
assemble-transcript
--runtime-file "$runtime_file" --prompt-file "$prompt_file"
--fixture-dir "$LIVE_STAGE" --probe "$PROBE"
--arm "$arm" --rep "$rep" --workspace "$workspace"
--workspace-reset
)
if [[ -n "$egress_json" ]]; then
assemble_args+=(--network-egress "$egress_json")
fi
python3 "$FIXTURE_META_TOOL" "${assemble_args[@]}" >&9 || rc=$?
fi
if [[ "$rc" -eq 0 ]]; then
receipt="$(python3 "$FIXTURE_META_TOOL" classify-open \
--fd 0 --path "$transcript" --fixture-dir "$LIVE_STAGE" \
--probe "$PROBE" --arm "$arm" --rep "$rep" <&9)" || rc=$?
fi
exec 9>&-
if [[ "$rc" -ne 0 || -z "$receipt" ]]; then
echo "probe-skill: $arm dispatch degraded (rc=$rc); no transcript accepted" >&2
printf -v "$receipt_name" '%s' ""
printf -v "$result_name" '%s' "$outcome"
return 1
fi
outcome="$(summary_get "$receipt" outcome)"
printf -v "$receipt_name" '%s' "$receipt"
printf -v "$result_name" '%s' "$outcome"
return 0
}
LIVE_STAGE=""
LIVE_ALL_DISPATCH_OK=1
# cleanup_capture — ONE guarded trap over everything a live capture creates: the
# run directory, the unpublished capture stage, and the network proxy. The stage
# is released (LIVE_STAGE emptied) only by a successful atomic publish, so a
# failure anywhere leaves no half-written fixture set behind.
cleanup_capture() {
local status=$?
stop_network_proxy
if [[ -n "$PROBE_RUN" && -d "$PROBE_RUN" ]]; then
rm -rf -- "$PROBE_RUN"
fi
if [[ -n "$LIVE_STAGE" && -d "$LIVE_STAGE" ]]; then
rm -rf -- "$LIVE_STAGE"
fi
return "$status"
}
publish_fixture_set() {
local stage="$1" target="$2"
python3 "$FIXTURE_META_TOOL" publish \
--stage-dir "$stage" \
--target-dir "$target" \
--probe-dir "$PROBE_DIR" \
--skills-dir "$SKILLS_DIR" \
--probe "$PROBE" >/dev/null || return 1
LIVE_STAGE=""
return 0
}
if [[ $REPLAY -eq 0 ]]; then
trap cleanup_capture EXIT
LIVE_STAGE="$(mktemp -d "$PROBE_DIR/.${FIXTURE_SET}.capture.XXXXXX")"
chmod 0700 "$LIVE_STAGE"
# One run directory for the whole capture: home/, ws/, tmp/ and the
# harness-private dispatch/ (prompt copies and raw producer streams). It
# lives OUTSIDE the checkout because node aborts at startup when its stdio
# files sit under a file-read* denied tree (it stats them), so dispatch/ is
# denied file-read-DATA and writes, never metadata. The EXIT trap takes the
# whole directory, so nothing from this run outlives it.
make_run_dir
if [[ "$SEAL_MODE" == "seatbelt" ]]; then
build_seal
fi
# The seal record lands in the capture stage BEFORE the snapshot binds the
# capture contract, so the contract's seal block is the record itself.
write_seal_record "$LIVE_STAGE/seal.json"
if [[ "$SEAL_MODE" == "seatbelt" ]]; then
echo "seal: seatbelt ($SEAL_SANDBOX_EXEC; ${#SEAL_DENIED_ROOTS[@]} denied read roots; network proxy-allowlist via 127.0.0.1:$PROXY_PORT for ${#NETWORK_HOST_LIST[@]} hosts)" >&2
fi
SNAPSHOT_ARGS=(
snapshot
--fixture-dir "$LIVE_STAGE"
--probe-dir "$PROBE_DIR"
--skills-dir "$SKILLS_DIR"
--probe "$PROBE"
)
if [[ -n "$MODEL" ]]; then SNAPSHOT_ARGS+=(--requested-model "$MODEL"); fi
if [[ -n "$EFFORT" ]]; then SNAPSHOT_ARGS+=(--requested-effort "$EFFORT"); fi
if [[ -n "${CODEX_EXEC_BIN:-}" ]]; then
SNAPSHOT_ARGS+=(--producer-override-bin "$CODEX_EXEC_BIN")
fi
if ! python3 "$FIXTURE_META_TOOL" "${SNAPSHOT_ARGS[@]}" >/dev/null; then
echo "error: live capture inputs changed while creating the pre-dispatch snapshot" >&2
exit 2
fi
if ! CURRENT_CONTRACT="$(python3 "$FIXTURE_META_TOOL" probe-contract \
--probe-dir "$PROBE_DIR" --skills-dir "$SKILLS_DIR" \
--probe "$PROBE")" || [[ "$CURRENT_CONTRACT" != "$PROBE_CONTRACT" ]]; then
echo "error: live capture inputs changed while creating the pre-dispatch snapshot" >&2
exit 2
fi
fi
C_PRESENT=0; C_USABLE=0; T_PRESENT=0; T_USABLE=0
PER_REP_JSON=""
declare -A LIVE_RESULTS=()
declare -A LIVE_DIGESTS=()
if [[ $REPLAY -eq 0 ]]; then
while IFS=$'\t' read -r arm rep; do
key="$arm-$rep"
transcript="$LIVE_STAGE/$key.txt"
dispatch_receipt=""
dispatch_result="DEGRADED"
if dispatch_live "$arm" "$rep" "$transcript" dispatch_receipt dispatch_result; then
model="$(summary_get "$dispatch_receipt" producer.model)"
effort="$(summary_get "$dispatch_receipt" producer.effort)"
PRODUCER_MODEL="$model"
PRODUCER_EFFORT="$effort"
LIVE_DIGESTS["$key"]="$(summary_get "$dispatch_receipt" sha256)"
else
LIVE_ALL_DISPATCH_OK=0
fi
LIVE_RESULTS["$key"]="$dispatch_result"
done < <(python3 -c '
import json, sys
for entry in json.loads(sys.argv[1])["schedule"]:
print(f"{entry['"'"'arm'"'"']}\t{entry['"'"'rep'"'"']}")
' "$PROBE_CONTRACT")
for ((n=1; n<=REPS; n++)); do
c_res="${LIVE_RESULTS[control-$n]:-DEGRADED}"
t_res="${LIVE_RESULTS[treatment-$n]:-DEGRADED}"
if [[ "$c_res" != "DEGRADED" ]]; then C_USABLE=$((C_USABLE + 1)); fi
if [[ "$c_res" == "PRESENT" ]]; then C_PRESENT=$((C_PRESENT + 1)); fi
if [[ "$t_res" != "DEGRADED" ]]; then T_USABLE=$((T_USABLE + 1)); fi
if [[ "$t_res" == "PRESENT" ]]; then T_PRESENT=$((T_PRESENT + 1)); fi
entry="$(printf '{"rep":%d,"control":"%s","treatment":"%s"}' "$n" "$c_res" "$t_res")"
PER_REP_JSON="${PER_REP_JSON:+$PER_REP_JSON,}$entry"
done
else
if ! SCORE_SUMMARY="$(python3 "$FIXTURE_META_TOOL" score \
--fixture-dir "$FIXDIR" --probe-dir "$PROBE_DIR" \
--skills-dir "$SKILLS_DIR" --probe "$PROBE")"; then
echo "error: replay scoring failed; no scorecard will be emitted" >&2
exit 2
fi
C_PRESENT="$(summary_get "$SCORE_SUMMARY" control.present)"
C_USABLE="$(summary_get "$SCORE_SUMMARY" control.usable)"
T_PRESENT="$(summary_get "$SCORE_SUMMARY" treatment.present)"
T_USABLE="$(summary_get "$SCORE_SUMMARY" treatment.usable)"
PER_REP_JSON="$(python3 -c '
import json, sys
print(",".join(json.dumps(item,separators=(",",":")) for item in json.loads(sys.argv[1])["per_rep"]))
' "$SCORE_SUMMARY")"
fi
if [[ $REPLAY -eq 0 && "$LIVE_ALL_DISPATCH_OK" -eq 1 ]]; then
if ! CURRENT_CONTRACT="$(python3 "$FIXTURE_META_TOOL" probe-contract \
--probe-dir "$PROBE_DIR" \
--skills-dir "$SKILLS_DIR" \
--probe "$PROBE")" || [[ "$CURRENT_CONTRACT" != "$PROBE_CONTRACT" ]]; then
echo "error: live capture inputs changed during dispatch; fixture set not published" >&2
exit 2
fi
# The proxy's decision log is published WITH the set: every rep binds its
# digest, and without the file there is nothing for those digests to be
# checked against once the run directory is gone.
# Stop the proxy first: a connection still being torn down would append to
# the log after the copy and leave the published file a moving target.
stop_network_proxy
if [[ -n "$PROXY_LOG" && -f "$PROXY_LOG" ]]; then
cp "$PROXY_LOG" "$LIVE_STAGE/network.log" \
|| { echo "error: could not publish the proxy log with the capture" >&2; exit 2; }
fi
CREATE_ARGS=(
create
--fixture-dir "$LIVE_STAGE"
--probe-dir "$PROBE_DIR"
--skills-dir "$SKILLS_DIR"
--harness "$HARNESS_PATH"
--preamble "$PREAMBLE_PATH"
--dispatch-helper "$DISPATCH_HELPER_PATH"
--probe "$PROBE"
--reps "$REPS"
)
if [[ -n "$MODEL" ]]; then CREATE_ARGS+=(--requested-model "$MODEL"); fi
if [[ -n "$EFFORT" ]]; then CREATE_ARGS+=(--requested-effort "$EFFORT"); fi
if ! FIXTURE_METADATA="$(python3 "$FIXTURE_META_TOOL" "${CREATE_ARGS[@]}")"; then
echo "error: live capture refused: structured transcripts or bound producer identity failed verification" >&2
exit 2
fi
FIXTURE_BINDING="$(summary_get "$FIXTURE_METADATA" binding_sha256)"
FIXTURE_SCHEMA="$(summary_get "$FIXTURE_METADATA" schema)"
CAPTURE_EVALUATOR="$(python3 -c 'import json,sys; print(json.dumps(json.loads(sys.argv[1])["capture_evaluator"],sort_keys=True,separators=(",",":")))' "$FIXTURE_METADATA")"
if [[ "$CAPTURE_EVALUATOR" != "$CURRENT_EVALUATOR" ]]; then
echo "error: capture evaluator changed during live dispatch; fixture set not published" >&2
exit 2
fi
TREATMENT_SOURCE="$(summary_get "$FIXTURE_METADATA" treatment_source)"
PRODUCER_MODEL="$(summary_get "$FIXTURE_METADATA" producer.model)"
PRODUCER_EFFORT="$(summary_get "$FIXTURE_METADATA" producer.effort)"
PRODUCER_JSON="$(summary_json "$FIXTURE_METADATA" producer)"
for ((n=1; n<=REPS; n++)); do
for arm in control treatment; do
key="$arm-$n"
bound_digest="$(python3 -c '
import json, sys
records=json.loads(sys.argv[1])["transcripts"]
print(next(item["sha256"] for item in records if item["path"] == sys.argv[2] + ".txt"))
' "$FIXTURE_METADATA" "$key")"
if [[ "$bound_digest" != "${LIVE_DIGESTS[$key]:-}" ]]; then
echo "error: live transcript identity changed after scoring: $key" >&2
exit 2
fi
done
done
if ! python3 "$FIXTURE_META_TOOL" verify \
--fixture-dir "$LIVE_STAGE" \
--probe-dir "$PROBE_DIR" \
--skills-dir "$SKILLS_DIR" \
--probe "$PROBE" >/dev/null; then
echo "error: staged fixture set failed post-capture verification" >&2
exit 2
fi
if ! CURRENT_CONTRACT="$(python3 "$FIXTURE_META_TOOL" probe-contract \
--probe-dir "$PROBE_DIR" \
--skills-dir "$SKILLS_DIR" \
--probe "$PROBE")" || [[ "$CURRENT_CONTRACT" != "$PROBE_CONTRACT" ]]; then
echo "error: live capture inputs changed before publish; fixture set not published" >&2
exit 2
fi
if ! SCORE_SUMMARY="$(python3 "$FIXTURE_META_TOOL" score \
--fixture-dir "$LIVE_STAGE" --probe-dir "$PROBE_DIR" \
--skills-dir "$SKILLS_DIR" --probe "$PROBE")"; then
echo "error: staged fixture set failed bound response-only scoring" >&2
exit 2
fi
if ! python3 -c '
import json, sys
raise SystemExit(0 if json.loads("[" + sys.argv[1] + "]") == json.loads(sys.argv[2])["per_rep"] else 1)
' "$PER_REP_JSON" "$SCORE_SUMMARY"; then
echo "error: bound scoring disagrees with live transcript receipts" >&2
exit 2
fi
C_PRESENT="$(summary_get "$SCORE_SUMMARY" control.present)"
C_USABLE="$(summary_get "$SCORE_SUMMARY" control.usable)"
T_PRESENT="$(summary_get "$SCORE_SUMMARY" treatment.present)"
T_USABLE="$(summary_get "$SCORE_SUMMARY" treatment.usable)"
if ! publish_fixture_set "$LIVE_STAGE" "$FIXDIR"; then
echo "error: failed to publish fixture set atomically: $FIXDIR" >&2
exit 1
fi
if ! PUBLISHED_METADATA="$(python3 "$FIXTURE_META_TOOL" verify \
--fixture-dir "$FIXDIR" \
--probe-dir "$PROBE_DIR" \
--skills-dir "$SKILLS_DIR" \
--probe "$PROBE")" || \
[[ "$(summary_get "$PUBLISHED_METADATA" binding_sha256)" != "$FIXTURE_BINDING" ]]; then
echo "error: published fixture target failed exact binding verification: $FIXDIR" >&2
exit 1
fi
elif [[ $REPLAY -eq 0 ]]; then
# An incomplete live dispatch is a FAILED run, not a null result: emitting an
# UNMEASURED scorecard for it let a caller read a partial capture as an
# honest measurement and exit zero.
echo "error: incomplete live run; fixture set not published and no scorecard written" >&2
exit 1
fi
# --- verdict ------------------------------------------------------------------
rate() { local n="$1" d="$2"; [[ "$d" -eq 0 ]] && { echo "null"; return; }; python3 -c "print(round($n/$d,4))"; }
C_RATE="$(rate "$C_PRESENT" "$C_USABLE")"
T_RATE="$(rate "$T_PRESENT" "$T_USABLE")"
if [[ "$C_USABLE" -eq 0 || "$T_USABLE" -eq 0 || ( $REPLAY -eq 0 && "$LIVE_ALL_DISPATCH_OK" -ne 1 ) ]]; then
VERDICT="UNMEASURED"
else
# Direction is part of the result; a lower treatment rate is not a null.
cmp_res="$(python3 -c "
tu=$T_USABLE; cu=$C_USABLE
tr=$T_PRESENT/tu
cr=$C_PRESENT/cu
print('BEHAVIORAL' if tr>cr else 'REGRESSIVE' if tr<cr else 'INERT')")"
VERDICT="$cmp_res"
fi
MODE="live"
if [[ $REPLAY -eq 1 ]]; then MODE="replay"; fi
GEN_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ -n "$FIXTURE_BINDING" ]]; then
BOUND_SCHEDULE="$(summary_json "$FIXTURE_METADATA" schedule)"
BOUND_SCORING="$(summary_json "$FIXTURE_METADATA" scoring)"
else
BOUND_SCHEDULE="$(summary_json "$PROBE_CONTRACT" schedule)"
BOUND_SCORING="$(summary_json "$PROBE_CONTRACT" scoring)"
fi
SCORECARD="$(python3 - \
"$PROBE" "$SKILL" "$MODE" "$GEN_AT" "$REPS" \
"$PRODUCER_MODEL" "$PRODUCER_EFFORT" "$PRODUCER_JSON" \
"$CAPTURE_REQUESTED_MODEL" "$CAPTURE_REQUESTED_EFFORT" \
"$FIXTURE_SET" "$FIXTURE_BINDING" "$FIXTURE_SCHEMA" "$TREATMENT_SOURCE" \
"$BOUND_SCHEDULE" "$BOUND_SCORING" \
"$C_PRESENT" "$C_USABLE" "$C_RATE" \
"$T_PRESENT" "$T_USABLE" "$T_RATE" \
"$VERDICT" "$PER_REP_JSON" \
"$CURRENT_EVALUATOR" "$CAPTURE_EVALUATOR" "$SEAL_JSON" <<'PY'
import json
import sys
(
probe, skill, mode, generated_at, reps,
producer_model, producer_effort, producer_json,
requested_model, requested_effort,
fixture_name, fixture_binding, fixture_schema, treatment_source,
schedule_json, scoring_json,
control_present, control_usable, control_rate,
treatment_present, treatment_usable, treatment_rate,
verdict, per_rep_json,
current_evaluator_json, capture_evaluator_json, seal_json,
) = sys.argv[1:]
current_evaluator = json.loads(current_evaluator_json)
capture_evaluator = json.loads(capture_evaluator_json) if capture_evaluator_json else None
producer = (
json.loads(producer_json)
if producer_json
else {
"adapter": "codex",
"model": producer_model or None,
"effort": producer_effort or None,
}
)
def nullable_rate(value):
return None if value == "null" else float(value)
scorecard = {
"schema": "agentops-skill-probe.v3",
"probe": probe,
"skill": skill,
"mode": mode,
"generated_at": generated_at,
"reps": int(reps),
"producer": producer,
"requested_producer": {
"model": requested_model or None,
"effort": requested_effort or None,
},
"fixture_set": {
"name": fixture_name,
"metadata": "fixture-set.json" if fixture_binding else None,
"binding_sha256": fixture_binding or None,
"schema": fixture_schema or None,
},
"treatment_source": treatment_source,
"seal": json.loads(seal_json) if seal_json else None,
"evaluator": current_evaluator,
"capture_evaluator": capture_evaluator,
"evaluator_matches_capture": (
current_evaluator == capture_evaluator if capture_evaluator is not None else None
),
"honesty": (
"UNMEASURED live attempt; no immutable fixture set was published"
if not fixture_binding
else "measures response-shape BEHAVIOR-CHANGE under the exact bound canonical "
"SKILL.md treatment, NOT quality-uplift; small N is directional (ADR-0011)"
if treatment_source == "canonical-skill"
else "measures response-shape BEHAVIOR-CHANGE under the exact hash-bound injected "
"prelude named by bound probe metadata; canonical SKILL.md is not bound; this is "
"NOT full-skill activation or quality-uplift; small N is directional "
"(ADR-0011)"
if fixture_schema == "agentops-skill-probe-fixture-set.v1"
else "measures response-shape BEHAVIOR-CHANGE under the exact bound injected "
"prelude associated with the bound canonical skill, NOT full-skill activation "
"or quality-uplift; small N is directional (ADR-0011)"
),
"schedule": json.loads(schedule_json),
"scoring": json.loads(scoring_json),
"control": {
"present": int(control_present),
"usable": int(control_usable),
"rate": nullable_rate(control_rate),
},
"treatment": {
"present": int(treatment_present),
"usable": int(treatment_usable),
"rate": nullable_rate(treatment_rate),
},
"verdict": verdict,
"per_rep": json.loads("[" + per_rep_json + "]"),
}
print(json.dumps(scorecard, ensure_ascii=False, indent=2, sort_keys=False))
PY
)" || { echo "error: failed to serialize scorecard JSON" >&2; exit 1; }
if [[ -n "$OUTPUT" ]]; then
if ! printf '%s\n' "$SCORECARD" | python3 "$FIXTURE_META_TOOL" \
write-output --path "$OUTPUT" >/dev/null; then
echo "error: refusing to overwrite immutable scorecard output: $OUTPUT" >&2
exit 2
fi
echo "scorecard written: $OUTPUT" >&2
else
printf '%s\n' "$SCORECARD"
fi
# In --capture we keep the fixtures (default for live). In pure --replay we never
# wrote them. Nothing else to do.
if [[ $CAPTURE -eq 1 && -n "$FIXTURE_BINDING" ]]; then
echo "fixtures captured under: $FIXDIR" >&2
fi
exit 0