Skip to content

v1.12.0 — False PASSes closed, mounts and work dirs guarded

Choose a tag to compare

@yottayoshida yottayoshida released this 11 Oct 10:47
· 105 commits to main since this release
1b1e16a

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.py holds the Dockerfile's version to the quickstart's, so the two move together. With the container on v1.11.0, sideeye_preflight and sideeye_evidence reach an operator who builds it, as docs/mcp.md describes.

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, an open included, so GNU cp onto an existing file on btrfs or XFS — open(O_TRUNC), recorded, then ioctl(FICLONE), which neither witness counts as a write — and an io_uring write after the same open passed with oracle_verified: true, measured PASS 4/4, while the world holding the truncated file was never explored; the same cp on 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 refused state_changed_unaccounted (under --oracle a reflink clone is refused before that, as unsupported_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 an open created or truncated and left empty, and a path declared scratch, 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 libc open(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). FICLONE and FICLONERANGE were read as read-only because ioctl is on that list, so a file half-written with a recorded pwrite and half-cloned in passed with oracle_verified: true — and with oracle_verified_subject_only when 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 now unsupported_syscall_observed with the message ioctl(FICLONE); a whole-file clone that failed before remapping anything is still read as a read, so GNU cp on overlayfs still falls back to copy_file_range and 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. An ioctl on 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 refused oracle_saw_nothing on its first write F=1 — a toy that prints done, a toy moving its state file onto 1 with dup2, and GNU dd of= alike — because the engine moves its capture onto 1 before the exec, on a thread the capture does not tie to the subject, 2 is the engine's own stderr, and -f filesys prints no dup2. The engine now tells the reader where 0, 1 and 2 point, fs_usage runs without a class filter so that dup and dup2 print with both numbers, the reader follows them and takes sockets as outside the state, and a buffer overrun on fs_usage's stderr, now kept and quoted when fs_usage fails to start, refuses the run. The probe's dd rewrite FAILs with a verified oracle (run 38116040180). Still refused: a pipe the target makes itself, a socketpair written before any socket call names it, and a dup2 from 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, so replay case.json --json case.json deleted the case and left the refusal's report in its place, explore --config sideeye.toml --json sideeye.toml did the same to the define, and as root --json /dev/stdout replaced the device link with a regular file. Now a file holding one JSON document whose schema is sideeye/report or sideeye/preflight, however formatted, or an empty file, is removed as before; anything else is refused as SETUP_ERROR define_invalid, naming the path and its kind, with no report — at that path or an earlier --json in the same command — and no json 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 with sha256=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.md no longer says a report at the path always describes this run: a run refused before --json was read never removed it, and a refused path stays as it was; the absent json 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, its exit_code and 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 one jq -c compacted, over an empty file — still write.

  • sideeye mcp answers a request line longer than its 256 KiB buffer, under the request's own id, 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 that id until its own timeout — sixty seconds by default in the TypeScript SDK, which writes id after params, so a reply under id: null would not have stopped the wait either. The over-long line is now read through a streaming JSON scanner that keeps only its top-level id, and answered -32600 naming the limit (262143 bytes before the newline) under that id; 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 in docs/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 its id last), 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 in ci.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, preflight and mcp now stop the child the way a world over --world-timeout is stopped — SIGKILL to its group, and its cgroup where there is one — stop fs_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 that SIGKILL cannot 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 sending fs_usage the 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 inherited SIGCHLD of SIG_IGN back to its default for itself alone, so it waits for its children as any run does. contract_version does 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-timeout and a SIGTERM during a supervised recording each left the child (and strace) running, and a SIGTERM to sideeye mcp mid-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_usage reports every process's calls, and a state directory under $HOME is one Spotlight indexes: its mdworker_shared opens a state file and asks, with fcntl(F_CHECK_OPENEVT), whether that descriptor is for event notification only. The command was not on the list of fcntl commands that change nothing, so a run ended unsupported_syscall_observed whenever 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 of spike/fsusage/acceptance-local.sh. The command is added to that list. F_CHECK_OPENEVT is private to XNU and its header describes it as a check; that description is what this rests on. An F_FULLFSYNC on 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.sh check 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 at e814b84d, in v1.11.0), a directory bind-mounted at state/sub lost every file beneath it at the first restore, which then failed EBUSY and reported state_rewrite_failed with "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 a poll on 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-state over 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, and SIDEEYE_EXPECT_MOUNT=1 in 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. No unknown_reason, setup_error_reason or 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.sh check 26, spike/mcp-acceptance.sh mcp 6b, docs/cli.md, docs/faq.md, docs/mcp.md). A run kept its traces, captures and reports in --work under fixed names, and the default --work is one directory: two explorations at once, or two CI jobs on one runner, read each other's files, and two sideeye mcp servers on one host both read back report-1.json. A run now locks <work>/run.lock before a replay's --fresh-state empties 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.lock has 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 makes server-XXXXXX under SIDEEYE_MCP_WORK at 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-state is refused before it empties the state the first replay is setting up, a file left there surviving it; a hard link and a FIFO at run.lock are refused, the linked file left as it was; mcp 6b asks two servers on one SIDEEYE_MCP_WORK at once and requires each to answer about its own config. The MCP acceptance legs that named cases/ and report-1.json directly 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.sh checks 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-under without --twice, and over MCP every sideeye_preflight call is confined to SIDEEYE_MCP_STATE_ROOT, where one without twice used 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 refused state_changed_without_ops, and a state holding an entry the engine cannot remove, which used to be recorded and then refused state_rewrite_failed. Exit codes move for these inputs: those two 2 → 3; a target that lists its state on tmpfs, btrfs or XFS, refused baseline_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-usage can scope — so a run refused for one of those leaves the state as the setup left it; the macOS observer's own start (its sudo credentials, 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, and chdman, 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, --twice finds 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.sh check 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 refused kill_did_not_land (step not_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 as PYTHONHASHSEED, 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, a mkdir on 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 until PYTHONHASHSEED is 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, a mkdir after its rmdir — and requires each refused with the names it swapped, beside three controls that keep their verdicts: the same writes unflipped, random scratch names, and mkdir on directories already there in either order. The refusal and its step are ones the contract already had.