Repository navigation
v1.12.0 — False PASSes closed, mounts and work dirs guarded
Changed
- The CI quickstart and the MCP container pin v1.11.0 (
docs/ci-quickstart.md,docs/ci-quickstart/release/install-sideeye.sh,.github/workflows/quickstart-release.yml,docs/mcp-container/Dockerfile). The release lane installs the published v1.11.0 asset, and the container fetches it, with the two Linux digests GitHub recorded for it. The pins move after the release, as on v1.10.0, because both download the asset and it exists only once the release is published;spike/check-mcp-container.pyholds the Dockerfile's version to the quickstart's, so the two move together. With the container on v1.11.0,sideeye_preflightandsideeye_evidencereach an operator who builds it, asdocs/mcp.mddescribes.
Fixed
-
A file left holding bytes is accounted for only by a recorded operation that can have put them there (#814, ADR 0121;
src/engine/snapshot.zig,src/refuse.zig,docs/report-schema.md,DESIGN.md,docs/contract-freeze.md). The per-path reconciliation counted any record naming a changed path, anopenincluded, so GNUcponto an existing file on btrfs or XFS —open(O_TRUNC), recorded, thenioctl(FICLONE), which neither witness counts as a write — and an io_uring write after the sameopenpassed withoracle_verified: true, measured PASS 4/4, while the world holding the truncated file was never explored; the samecpon overlayfs FAILs. Such a file now needs a recorded write, the destination of a rename or a link, the source of a link whose other name was written after it, or a truncate that only cut or zero-filled it, and the runs above are refusedstate_changed_unaccounted(under--oraclea reflink clone is refused before that, asunsupported_syscall_observed— the next entry), whose sentence now says "accounts for", counts the paths only a non-writing record named, and names the stdio flushes the default mode leaves unrecorded (and, on Linux, the mode that counts them). A file anopencreated or truncated and left empty, and a path declaredscratch, are accounted for as before (ADR 0032 §2). Not reached: such a call on a file another accounting record also names — a recorded write, a rename or link that put it there, a truncate whose result the bytes happen to match — or on a scratch path a checker reads. A saved FAIL case whose recording now reaches this refusal no longer replays. Acceptance: a libcopen(O_TRUNC)refilled by a raw write refuses, the libc-write control is accepted. -
Under
--oracle, a reflink clone into a state file is refused (#814, ADR 0121;src/oracle.zig,docs/report-schema.md,DESIGN.md).FICLONEandFICLONERANGEwere read as read-only becauseioctlis on that list, so a file half-written with a recordedpwriteand half-cloned in passed withoracle_verified: true— and withoracle_verified_subject_onlywhen a shell ran the tool — while the torn file was never explored (measured on btrfs). Such a clone into a state file, from the subject or a child (a child the v15 rule does not admit is refused first, as that), is nowunsupported_syscall_observedwith the messageioctl(FICLONE); a whole-file clone that failed before remapping anything is still read as a read, so GNUcpon overlayfs still falls back tocopy_file_rangeand FAILs, while any other failed clone, which can have remapped part of the file, is refused, and a call split across two lines is judged on the half with its result. Anioctlon a state file whose request this reader cannot split — a file name holding a"or an unclosed bracket — is refused too. io_uring is not refused: Node 22.23.3 sets up rings it writes no file through, and refusing them would refuse every Node 22 target on a host that allows io_uring. -
Under
--oracle-fs-usage, a target that writes its standard output, or moves a file onto a standard descriptor and writes through it, is judged (#796, ADR 0122;src/main.zig,src/fsusage.zig,src/posix.zig,docs/cli.md,spike/fsusage/acceptance-local.sh). Measured on GitHub's macOS 26 runner, every such run was refusedoracle_saw_nothingon its firstwrite F=1— a toy that printsdone, a toy moving its state file onto 1 withdup2, and GNUdd of=alike — because the engine moves its capture onto 1 before theexec, on a thread the capture does not tie to the subject, 2 is the engine's own stderr, and-f filesysprints nodup2. The engine now tells the reader where 0, 1 and 2 point, fs_usage runs without a class filter so thatdupanddup2print with both numbers, the reader follows them and takes sockets as outside the state, and abuffer overrunon fs_usage's stderr, now kept and quoted when fs_usage fails to start, refuses the run. The probe'sddrewrite FAILs with a verified oracle (run 38116040180). Still refused: a pipe the target makes itself, asocketpairwritten before any socket call names it, and adup2from a number never seen opened. -
--json <path>removes what is at its path only when it is a report Sideeye wrote or an empty file; anything else is refused and left as it was (#818, ADR 0123;src/report.zig,src/cli.zig,src/mcp.zig,docs/cli.md,docs/report-schema.md,spike/acceptance.sh). The flag removed whatever was at its path while the arguments were parsed, before anything was written, soreplay case.json --json case.jsondeleted the case and left the refusal's report in its place,explore --config sideeye.toml --json sideeye.tomldid the same to the define, and as root--json /dev/stdoutreplaced the device link with a regular file. Now a file holding one JSON document whoseschemaissideeye/reportorsideeye/preflight, however formatted, or an empty file, is removed as before; anything else is refused as SETUP_ERRORdefine_invalid, naming the path and its kind, with no report — at that path or an earlier--jsonin the same command — and nojson sha256=line. What used to run and now refuses, so a CI step gating on the exit code sees 3: a symbolic link (one to a report included, which used to be replaced), a directory, a FIFO, a device —/dev/stdout, which never received the report (as root it went into a file at that name, as anyone else nowhere), and for a user other than root/dev/null, which used to run to its verdict withsha256=none— a non-empty file Sideeye did not write — a log of reports appended one per line among them — a report that cannot be read, and a name whose kind cannot be read.docs/report-schema.mdno longer says a report at the path always describes this run: a run refused before--jsonwas read never removed it, and a refused path stays as it was; the absentjson sha256=line says so. The report is written through a temporary file this run creates new, rather than through<path>.tmp, and the MCP server reads its child's report only when the report's verdict, itsexit_codeand the code the child exited with agree. Measured in a Linux container against v1.11.0: a FIFO, a link, a device, a file that is not a report, the case and the define were each replaced by the refusal's report, and a directory was left as it was but refused without a word about the path; with this change all seven, and a log of reports, are refused naming the path and left byte for byte, and the legs that write — over nothing, over an earlier report, over onejq -ccompacted, over an empty file — still write. -
sideeye mcpanswers a request line longer than its 256 KiB buffer, under the request's ownid, instead of dropping it in silence (#820;src/mcp.zig,src/fuzz.zig,docs/mcp.md,spike/mcp-acceptance.sh). A line that filled the buffer was discarded with the rest of it up to the next newline and nothing was written back, so the client waited on thatiduntil its own timeout — sixty seconds by default in the TypeScript SDK, which writesidafterparams, so a reply underid: nullwould not have stopped the wait either. The over-long line is now read through a streaming JSON scanner that keeps only its top-levelid, and answered-32600naming the limit (262143 bytes before the newline) under thatid; a notification or a line of nothing but spaces gets no answer, and a line that is not JSON or whose top level is not an object gets what a short one gets. The differences from a short line are listed indocs/mcp.md. The MCP fuzz entry compares the scanner's reading of each over-long line with a whole-line parse, and the reply with the short line's where neither can carry an id, outside those differences, and gains seeds long enough to reach it. Measured: on v1.11.0, of four 300 KiB lines and an ordinary request the server answered only the ordinary one; with this change it answers the two requests under ids 41 and 42 (the second with itsidlast), the line that is not JSON with-32700, nothing for the notification, then the ordinary request, in that order, and answers an over-long line while the client still holds its stdin open. -
A run stopped by SIGINT, SIGTERM, SIGHUP or SIGPIPE stops the child it is waiting on before it ends, and then ends by that signal (#819, ADR 0124;
src/posix.zig,src/refuse.zig,src/main.zig,src/mcp.zig,src/supervise_linux.zig,docs/cli.md,spike/interrupt-run.py,spike/acceptance.sh,spike/mcp-acceptance.sh, the macOS job inci.yml). Every child leaves the engine's process group, so Ctrl-C reached the engine alone, and the engine had no handler: it died and left the setup, the operation, strace and the run's cgroup running, a hung operation with nothing left to stop it.explore,replay,preflightandmcpnow stop the child the way a world over--world-timeoutis stopped —SIGKILLto its group, and its cgroup where there is one — stopfs_usage, write one line on stderr and end by the signal they received, with no verdict and no report; the MCP server hands the signal to its engine, which does the same. A child thatSIGKILLcannot end is named and left after 5 seconds (the MCP server gives its engine 20), and a second signal ends the run at once, after sendingfs_usagethe SIGINT that stops it. A signal ignored at startup stays ignored, and children get the engine's starting mask and signal dispositions back; the engine sets an inheritedSIGCHLDofSIG_IGNback to its default for itself alone, so it waits for its children as any run does.contract_versiondoes not move: the exit codes are promised for a run with a verdict. Measured in a Linux container, with and without cgroups: on v1.11.0 a SIGTERM during a setup, a SIGINT during a recording under strace, a SIGHUP during a world under--world-timeoutand a SIGTERM during a supervised recording each left the child (and strace) running, and a SIGTERM tosideeye mcpmid-call left its engine and the engine's setup; with this change each run ends by its signal with nothing of it left, and a run started with SIGHUP ignored is not ended by one. -
Under
--oracle-fs-usage, Spotlight checking how it opened a state file no longer makes a run UNKNOWN (src/fsusage.zig).fs_usagereports every process's calls, and a state directory under$HOMEis one Spotlight indexes: itsmdworker_sharedopens a state file and asks, withfcntl(F_CHECK_OPENEVT), whether that descriptor is for event notification only. The command was not on the list offcntlcommands that change nothing, so a run endedunsupported_syscall_observedwhenever the indexer reached a state file while the run was recorded; on a CI runner that happened twice in a row, in two different checks ofspike/fsusage/acceptance-local.sh. The command is added to that list.F_CHECK_OPENEVTis private to XNU and its header describes it as a check; that description is what this rests on. AnF_FULLFSYNCon the same descriptor is still refused. -
A state directory with anything mounted inside it is refused, naming the mount point, before anything is removed; until now the first restore emptied the mounted tree (#817, ADR 0116;
src/engine/state_fs.zig,src/refuse.zig,src/report.zig,spike/acceptance.shcheck 25,.github/workflows/ci.yml,SECURITY.md,docs/cli.md). Every world empties the state directory and rebuilds it, and the removal descended into a bind mount, a container volume or a disk image mounted inside it: on v1.11.0 and earlier (measured ate814b84d, in v1.11.0), a directory bind-mounted atstate/sublost every file beneath it at the first restore, which then failedEBUSYand reportedstate_rewrite_failedwith "the define itself is unchanged". The snapshot and the three entry points that remove or write inside the state now refuse first — a SETUP ERROR when the mount is there before the recording, an UNKNOWN when one appears later — naming the mount point; on Linux the mount table (/proc/self/mountinfo) is read for it, since a bind mount of the same filesystem keeps its device number, and a table that cannot be read refuses the same way; on both Linux and macOS the state is walked for a directory on another device before anything is removed, which also names a btrfs subvolume the table does not list; the snapshot walk and the removal stop at one too. A mount point that is the state directory itself is fine. Every snapshot and restore asks, and on Linux the table is read again only when apollon it reports a change: reading it every time took an exploration of 25 worlds in a container with 2,014 mounts from 128 ms to 342 ms, and with the watch it took 131 ms against 133 ms before the change. Acceptance check 25 bind-mounts a directory and a file inside a state, and replays a saved case with--fresh-stateover one, and requires each refused by name with everything beneath the mount byte for byte as it was, beside a control with nothing mounted and a state that is itself a mount point, and a checker that bind-mounts a directory inside the state during its falsification has the next restore refuse it by name; it needs root or passwordless sudo, andSIDEEYE_EXPECT_MOUNT=1in CI makes a host without either red. The macOS job attaches a disk image inside a state, for an exploration and for a replay's--fresh-state. SECURITY.md lists the protection and what it leaves open; a btrfs subvolume inside the state is refused although it is not a mount. Nounknown_reason,setup_error_reasonor NextStep is added. -
Two runs never use one work directory at the same time: the second is refused, naming the first, before it writes there; each MCP server works in a directory of its own (#816, ADR 0117;
src/main.zig,src/posix.zig,src/mcp.zig,spike/acceptance.shcheck 26,spike/mcp-acceptance.shmcp 6b,docs/cli.md,docs/faq.md,docs/mcp.md). A run kept its traces, captures and reports in--workunder fixed names, and the default--workis one directory: two explorations at once, or two CI jobs on one runner, read each other's files, and twosideeye mcpservers on one host both read backreport-1.json. A run now locks<work>/run.lockbefore a replay's--fresh-stateempties anything and before its setup, and holds it until it exits; a run that cannot is a SETUP ERROR naming the holder's pid, before it writes anything or empties a state.run.lockhas to be a regular file with one link — the runner's, unless this run just made it — since the run writes its pid into it. It is not waited for, and a filesystem without locks goes on as before. The MCP server makesserver-XXXXXXunderSIDEEYE_MCP_WORKat its first call, and again if it has gone, and hands it to its children, so two servers share neither a name nor a lock; a server's cases, evidence, traces and reports are saved there and stay. Check 26 starts a second run while the first one's setup holds the lock, and again once the first one's recording is done and its checker runs, and requires it refused naming the first, its own setup never run and the first's FAIL unchanged, beside a run on another work directory going ahead; a second replay of one case with--fresh-stateis refused before it empties the state the first replay is setting up, a file left there surviving it; a hard link and a FIFO atrun.lockare refused, the linked file left as it was; mcp 6b asks two servers on oneSIDEEYE_MCP_WORKat once and requires each to answer about its own config. The MCP acceptance legs that namedcases/andreport-1.jsondirectly under the work directory now find them under the server's directory. The lock adds no reason, setup-error class or next step to the contract. -
The recording run starts from the state Sideeye rebuilt from its snapshot, as every world does; until now it alone ran on the setup's own copy (#815, ADR 0118;
src/main.zig,spike/acceptance.shchecks 16 and 27 and the rewrite legs of #363,spike/toys/toy_listing.c,DESIGN.md,docs/cli.md,docs/mcp.md,docs/contract-freeze.md,src/cli.zig,src/mcp.zig,--help). The crash points are numbered by the recording, and each world starts from a rebuild that writes the entries back in name order with their bytes, link targets and permission bits. What the rebuild does not carry — the order tmpfs, btrfs and XFS list a rebuilt directory in, measured to differ from the setup's, and owners, hard links, set-id bits and timestamps — could differ between the recording and every world, so a target that lists its state could be judged against operations no world performed. Explore, replay and preflight now rebuild before the recording run too, so preflight answers from where explore's recording starts and both of--twice's runs start alike. Preflight therefore rewrites the state directory, takes--state-underwithout--twice, and over MCP everysideeye_preflightcall is confined toSIDEEYE_MCP_STATE_ROOT, where one withouttwiceused to run on any state (owner ruling; ADR 0102 amended). Two shapes stop earlier, before the operation runs, as a SETUP ERROR: a setup that replaced the state directory with a symlink, which used to be recorded writing through the link outside the state and then refusedstate_changed_without_ops, and a state holding an entry the engine cannot remove, which used to be recorded and then refusedstate_rewrite_failed. Exit codes move for these inputs: those two 2 → 3; a target that lists its state on tmpfs, btrfs or XFS, refusedbaseline_violates_invariant(2), can now reach a verdict. The rebuild comes after the refusals of a mistake that need none — the define's,--oracle's, the observation mode's, the depth--oracle-fs-usagecan scope — so a run refused for one of those leaves the state as the setup left it; the macOS observer's own start (itssudocredentials, its handshake) comes after the rebuild. Measured before and after with the same defines: the acceptance suite as root and as a non-root user, andchdman,hunspell,libdeflate-gzip, #678's mode-bit probe, upx and argocd gave the same verdicts, counts and earliest crash points. Check 27 mounts a tmpfs and runs a target that writes its state's listing: explore PASSes,--twicefinds the two runs equal, and a plain preflight's listing is the rebuilt order explore's recording sees; without the rebuild all three fail. The rebuild adds no reason, setup-error class or next step to the contract. -
A world that reached its crash point through other operations than the recording numbered is refused, not judged; until now one whose order differed in names alone was judged as if it had died at the recording's operation (#815, ADR 0119;
src/prefix.zig,src/main.zig,src/contract.zig,spike/acceptance.shcheck 28,spike/toys/toy_order.c,spike/dogfood/2026-10-11-order-815/,DESIGN.md,docs/report-schema.md,docs/contract-freeze.md). A crash point is a number in the recording's count. Every crash world is now held, for each operation before its crash point, to the recording's: the same class, and the same path wherever the world names a path the recording or its two snapshots spell or the recording named one the state started with, a rename's or a link's two paths each; a world that is not is refusedkill_did_not_land(stepnot_repeating), its message naming the first operation out of place in both runs, and the step's sentence now saying to pin what orders the target — a hash seed such asPYTHONHASHSEED, a random choice, a thread, the clock — in the define's environment as apparatus. Not compared: the bytes, operations that are not crash points,fsync, amkdiron a directory there from the start before anything in the run was removed or renamed, and names the recording never spelled where the recording's own was not there from the start — so a scratch name carrying a pid or a random suffix keeps its verdict. Until now only a run with an admitted child was compared, and by class alone, which two writes to two names swapped do not change: #815's shape, two files written in a Python set's order, PASSed in 7 of 30 explorations, 6 of them with a compared world writing the other order and 4 having recorded alpha first, the order the checker fails in. Now 12 of 120 reach a verdict — 4 PASS where the recording wrote beta first, 8 FAIL where it wrote alpha first, every compared world in the recording's order — and 108 are refused. Exit codes move for these inputs: a target whose order of state-directory calls varies between runs, which reached PASS (0) or FAIL (1), is UNKNOWN (2) unless every crash world drew the recording's order. Of the targets judged before, stylelint (UV_THREADPOOL_SIZE=1) and doctl (GOMAXPROCS=1) keep their verdicts in 5 runs of 5, and virtualenv, which writes pip's launchers in hash order, is refused at world 1360 untilPYTHONHASHSEEDis pinned (docs/target-classes.md's row says so). Check 28 flips a toy's order in the worlds only — two direct writes, two fixed scratch names, a direct write against a random scratch name, amkdirafter itsrmdir— and requires each refused with the names it swapped, beside three controls that keep their verdicts: the same writes unflipped, random scratch names, andmkdiron directories already there in either order. The refusal and its step are ones the contract already had.