Repository navigation
v3.0.0 — the guard was walkable, and the toolkit got a live channel to the running game
Three things make this a major version: the Bash guard was rebuilt around a single parse pass, the toolkit gained a live channel to the running game, and it now ships a game extension of its own. The first changes how every command you run is checked; the other two change what the toolkit can see.
⚠ protect-bash.sh now requires Python. Without it the guard asks rather than silently allowing.
The guard was walkable, and the thing built to prove otherwise could not reach it
case $string in *) rm -rf "<game>";; esac was ALLOW, while the bare rm -rf "<game>" was correctly denied. One line was doing a substring search where it meant token membership, so the cut landed inside the word string; the segment's verb came out as ary, and every verb-keyed rule missed at once — all three hard blocks included.
That was live in both hook copies while 996 fuzz mutants, 82 mutation probes, 23 predicate checks and 388 unit tests were green.
The reason is the part worth keeping. fuzz-guard.py states one invariant in its own docstring — the dangerous operand is byte-identical in every mutant. It had a second, unstated one: every compound-command mutator was a fixed string, always a one-character word and a one-character label. The word was unreachable by construction, exactly as the operand is. An instrument's stated invariant is its blind spot, and so is the one it doesn't state.
11 parameter-varied mutators later: 1,140 mutants, 80 bypasses on the first run, 0 now. Three heredoc markers and two more case-arm spellings were found by those mutators and by nothing else.
Rule precedence was decided by line number
ask exited, so every deny written below it was unreachable and severity was decided by position in the file. Three findings that looked unrelated were this one defect: 300 trivial command substitutions turned the game-delete hard block into an ask; rm <file> && git add -A asked where git add -A alone denies; and the save-directory warning was unreachable in the default layout.
Precedence is now by value: deny > ask > advise. Re-priced per item over 13,503 real commands with the prediction written first — 8 changed, all tightening, 0 loosening, including two deploy commands running a recursive delete inside the game install that had been silent allows.
Also: one git -c key=value silenced the entire destructive-git rule, in the rule whose own docstring records that git ignores the read-only attribute — so nothing else was watching.
The effective tree was discarding 99 base entries
The additive directories (libraries/, index/, t/) are paths inside the extension that owns a document. Two of three call sites pass a vpath that still carries extensions/<owner>/, and extensions/ego_dlc_boron/libraries/rooms.xml does not start with libraries/ — so the union branch was skipped and the base document was thrown away.
| vpath | base entries | effective, before |
|---|---|---|
ego_dlc_boron/libraries/rooms.xml |
19 | 2 |
ego_dlc_boron/libraries/roomgroups.xml |
19 | 2 |
ego_dlc_split/libraries/rooms.xml |
18 | 2 |
This is a user-facing data defect, not a reporting one: anything asking "what rooms exist" got the wrong answer.
Your files got a layer that does not depend on anyone being careful
A mod registry — 258 hand-triaged entries — was emptied during a review round, the fourth irreplaceable file lost in twelve days. Forensics across every transcript found that none of the four was a decision: each was a command that ran, resolved to a live path, and met nothing in the way.
x4lockmarks the irreplaceable files read-only. Measured over 14 write primitives, each paired with a control: 11 blocked, including all four real incidents. The three it does not block are documented rather than glossed.x4canaryrefuses on an emptied, deleted or halved file, and exits 2 when it cannot check. Wired to run on session start, so it does not depend on anyone remembering.- The registry save is now atomic and refuses to shrink.
⚠ An ACL-based first attempt locked the machine out of two of its own files and was withdrawn entirely — icacls W includes SYNCHRONIZE, so a write-deny denies reading, and the deny includes the permission needed to remove itself. The read-only attribute cannot do either.
Installing over an existing installation now requires DIRECTION, not approval
Not a prompt you can click through: an explicit --over-existing. The refusal names what it found and what would be replaced.
Instruments that could not report what they were built to find
A long tail, all one shape — a step that narrows the data and reports success anyway:
- the oracle enforced half its contract, and the half it dropped was the narrowing half
- unparseable documents counted as verbatim while the same screen said they were not
- a NUL byte removed a file from the control-byte sweep — and NUL is in the class that sweep hunts
- a zero-coverage baseline stayed green forever once recorded
- the lockstep gate read its population from the working tree its own docstring promises it never reads
- the reference freshness survey was blind below depth 2
- three tests reported PASSED without asserting, and one guard was satisfied by the comment that mentioned the flag
Also
The mod's reply cap bounded the answer rather than the work — 40,492 engine calls in one frame to show 489 rows. blockinput=true froze all player input for 12 seconds on every game load. A mixed-case Index/ made all 8 DLC layers REPLACE instead of union, taking 4,733 entries down to 17. A scalar sel= crashed, or reported the length of a string as a node count.
Verification for this release: suite 1382 passed / 14 skipped · 138 hook probes · 393 parser unit tests · 1,140 fuzz mutants with 0 bypasses and a control proving the fuzzer can still fail · engine oracle 241/241 = 100%, 0 false OK · both installers dry-run from a cold git archive extract writing nothing · cold-clone suite green with every X4_* cleared and the roots asserted unresolved.
Full detail, with the measurements and the denominators, is in CHANGELOG.md.