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

357 lines
12 KiB
Python

#!/usr/bin/env python3
"""Harness-owned local CONNECT proxy for one sealed skill-probe capture.
WHY: the outer seatbelt profile is `(allow default)` and codex's own sandbox is
bypassed inside it (seatbelt does not nest), so before this proxy a sealed rep
could fetch the canonical SKILL.md straight off GitHub and the filesystem seal
proved nothing about what the rep read. The seal now denies `network*` except
outbound to this proxy, and the proxy allows CONNECT only to an explicit host
allowlist. Everything else is refused with 403 and logged; a refused CONNECT
degrades that rep (`network-egress`).
It speaks only the CONNECT half of HTTP proxying, which is what an HTTPS client
uses: an absolute-URI plain HTTP request is refused like any other destination.
Standard library only, no third-party dependency.
Usage:
python3 probe-connect-proxy.py --log FILE --port-file FILE
[--allow-host HOST]... [--allow-port PORT]... [--allow-any]
[--rep-file FILE] [--allow-private-upstream]
An `--allow-host` beginning with a dot is an explicit domain suffix. Only the
ports named by `--allow-port` are admitted (default 443). A destination whose
name resolves into loopback, link-local or private space is refused even when
the name is on the allowlist, because otherwise a rebinding answer turns an
allowed name into a local service. `--allow-private-upstream` lifts that check
and exists only so the test suite can stand up a local upstream; a capture
never passes it.
`--allow-any` is DISCOVERY mode: it permits and logs every destination so an
operator can learn which hosts the producer needs before pinning the allowlist.
It is never used for a capture; a capture whose proxy ran in discovery mode is
recorded with `network.mode: proxy-discovery`, which is not coverage-eligible.
"""
from __future__ import annotations
import argparse
import json
import os
import ipaddress
import select
import socket
import sys
import threading
import time
BUFFER_BYTES = 65536
CONNECT_TIMEOUT_SECONDS = 20
IDLE_TIMEOUT_SECONDS = 300
MAX_REQUEST_BYTES = 8192
class ProxyLog:
"""Append-only JSONL: an `attempt` record, then the decision that followed.
The rep is captured when the connection is ACCEPTED, not when the line is
written: a decision can land after the rep that opened the connection has
exited, and attributing it to whichever rep happened to be current then
would put one rep's egress on another rep's record.
"""
def __init__(self, path: str, rep_file: str | None) -> None:
self._path = path
self._rep_file = rep_file
self._lock = threading.Lock()
def current_rep(self) -> str | None:
"""The rep the harness says is running right now, read at accept time."""
if not self._rep_file:
return None
try:
with open(self._rep_file, encoding="utf-8") as handle:
return handle.read().strip() or None
except OSError:
return None
def write(
self, host: str, port: int, decision: str, detail: str = "", rep: str | None = None
) -> None:
record = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"rep": rep,
"host": host,
"port": port,
"decision": decision,
}
if detail:
record["detail"] = detail
line = json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n"
with self._lock:
with open(self._path, "a", encoding="utf-8") as handle:
handle.write(line)
handle.flush()
os.fsync(handle.fileno())
def host_allowed(host: str, allowed: frozenset[str]) -> bool:
"""Exact host match, or a leading-dot entry as an explicit domain suffix.
`chatgpt.com` matches only that host. `.oaiusercontent.com` matches any host
under that domain, which is needed because the OpenAI content hosts carry a
rotating region prefix (sdmntprsouthcentralus, sdmntprcentralus, ...) that
no fixed list survives. It is still an allowlist: one named vendor domain,
written where a reader can see it, not a wildcard.
"""
name = host.lower()
for entry in allowed:
if entry.startswith("."):
if name.endswith(entry) or name == entry[1:]:
return True
elif name == entry:
return True
return False
def pump(client: socket.socket, upstream: socket.socket) -> None:
"""Relay both directions until either side closes or the pair goes idle."""
sockets = [client, upstream]
try:
while True:
readable, _, errored = select.select(sockets, [], sockets, IDLE_TIMEOUT_SECONDS)
if errored or not readable:
return
for source in readable:
target = upstream if source is client else client
try:
chunk = source.recv(BUFFER_BYTES)
except OSError:
return
if not chunk:
return
try:
target.sendall(chunk)
except OSError:
return
finally:
for handle in sockets:
try:
handle.close()
except OSError:
pass
def read_request_line(client: socket.socket) -> tuple[str, bytes]:
"""The first line of the proxy request, with any already-buffered remainder."""
buffered = b""
while b"\r\n" not in buffered:
if len(buffered) > MAX_REQUEST_BYTES:
return "", buffered
try:
chunk = client.recv(BUFFER_BYTES)
except OSError:
return "", buffered
if not chunk:
return "", buffered
buffered += chunk
line, _, rest = buffered.partition(b"\r\n")
return line.decode("latin-1"), rest
def refuse(client: socket.socket, status: str, message: str) -> None:
body = message.encode("utf-8")
response = (
f"HTTP/1.1 {status}\r\n"
"Content-Type: text/plain\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n"
"\r\n"
).encode("latin-1") + body
try:
client.sendall(response)
except OSError:
pass
try:
client.close()
except OSError:
pass
def parse_authority(target: str) -> tuple[str, int] | None:
if target.startswith("["): # bracketed IPv6 literal
closing = target.find("]")
if closing < 0:
return None
host = target[1:closing]
remainder = target[closing + 1 :]
if not remainder.startswith(":"):
return None
port_text = remainder[1:]
else:
host, separator, port_text = target.partition(":")
if not separator:
return None
if not host or not port_text.isdigit():
return None
port = int(port_text)
if not 0 < port < 65536:
return None
return host, port
def local_address(text: str) -> bool:
"""True when an address sits in loopback, link-local or private space."""
try:
address = ipaddress.ip_address(text)
except ValueError:
return False
return (
address.is_loopback
or address.is_link_local
or address.is_private
or address.is_reserved
or address.is_multicast
or address.is_unspecified
)
def resolve_public(host: str, port: int) -> tuple[list[tuple], str]:
"""Resolve HOST, refusing any answer that points into local space.
An allowlisted NAME is not an allowlisted destination: a rebinding answer
for `chatgpt.com` would otherwise tunnel the rep into a local service.
"""
try:
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
except OSError as exc:
return [], f"could not resolve: {exc}"
for info in infos:
address = info[4][0]
if local_address(str(address)):
return [], f"resolves into local address space: {address}"
return infos, ""
def connect_upstream(infos: list[tuple]) -> socket.socket:
last: OSError | None = None
for family, kind, proto, _canonical, sockaddr in infos:
try:
upstream = socket.socket(family, kind, proto)
upstream.settimeout(CONNECT_TIMEOUT_SECONDS)
upstream.connect(sockaddr)
return upstream
except OSError as exc:
last = exc
raise last if last is not None else OSError("no address to connect to")
def serve_client(
client: socket.socket,
allowed: frozenset[str],
ports: frozenset[int],
allow_any: bool,
allow_private: bool,
log: ProxyLog,
rep: str | None,
) -> None:
client.settimeout(CONNECT_TIMEOUT_SECONDS)
request_line, _ = read_request_line(client)
parts = request_line.split()
if len(parts) != 3 or parts[0].upper() != "CONNECT":
log.write("", 0, "refused", "not a CONNECT request", rep=rep)
refuse(client, "405 Method Not Allowed", "probe proxy accepts CONNECT only\n")
return
authority = parse_authority(parts[1])
if authority is None:
log.write(parts[1], 0, "refused", "unparseable authority", rep=rep)
refuse(client, "400 Bad Request", "probe proxy could not parse the authority\n")
return
host, port = authority
# The attempt goes on the record BEFORE anything is resolved or dialed, so a
# connection that dies mid-flight still leaves a trace of what was asked for.
log.write(host, port, "attempt", rep=rep)
if not (allow_any or host_allowed(host, allowed)):
log.write(host, port, "refused", "host is not on the capture allowlist", rep=rep)
refuse(client, "403 Forbidden", "probe proxy: host is not on the capture allowlist\n")
return
if port not in ports:
log.write(host, port, "refused", "port is not on the capture allowlist", rep=rep)
refuse(client, "403 Forbidden", "probe proxy: port is not on the capture allowlist\n")
return
if allow_private:
infos, problem = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP), ""
else:
infos, problem = resolve_public(host, port)
if problem:
log.write(host, port, "refused", problem, rep=rep)
refuse(client, "403 Forbidden", "probe proxy: destination is not reachable policy\n")
return
try:
upstream = connect_upstream(infos)
except OSError as exc:
log.write(host, port, "failed", f"upstream connect failed: {exc}", rep=rep)
refuse(client, "502 Bad Gateway", "probe proxy could not reach the destination\n")
return
log.write(host, port, "allowed", rep=rep)
try:
client.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n")
except OSError:
upstream.close()
client.close()
return
client.settimeout(None)
upstream.settimeout(None)
pump(client, upstream)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--allow-host", action="append", default=[])
parser.add_argument("--allow-port", action="append", type=int, default=[])
parser.add_argument("--allow-any", action="store_true")
parser.add_argument("--allow-private-upstream", action="store_true")
parser.add_argument("--log", required=True)
parser.add_argument("--port-file", required=True)
parser.add_argument("--rep-file")
args = parser.parse_args()
allowed = frozenset(host.lower() for host in args.allow_host)
ports = frozenset(args.allow_port or [443])
if not allowed and not args.allow_any:
print("probe-connect-proxy: an allowlist or --allow-any is required", file=sys.stderr)
return 2
log = ProxyLog(args.log, args.rep_file)
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", 0))
listener.listen(64)
port = listener.getsockname()[1]
with open(args.port_file, "w", encoding="utf-8") as handle:
handle.write(f"{port}\n")
print(port, flush=True)
while True:
try:
client, _ = listener.accept()
except OSError:
return 0
worker = threading.Thread(
target=serve_client,
args=(
client,
allowed,
ports,
args.allow_any,
args.allow_private_upstream,
log,
log.current_rep(),
),
daemon=True,
)
worker.start()
if __name__ == "__main__":
raise SystemExit(main())