mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
1cebfee94d
## 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.
357 lines
12 KiB
Python
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())
|