diff --git a/recoveries/helm-runtime-redirect.md b/recoveries/helm-runtime-redirect.md new file mode 100644 index 0000000..a6bdd05 --- /dev/null +++ b/recoveries/helm-runtime-redirect.md @@ -0,0 +1,181 @@ +# Q48 — the runtime-resolution redirect + +**Merlin, Helm lane, 2026-10-13 (~06:00–06:45 UTC). Scratch tree `/root/crucible/q48/`. Nothing deployed, no +`cp` into `/root/.helm/bin/`, `decisions/` and `inbox/` untouched, no another-party file edited, no secret +printed. Every figure below came from a command in this run; where a figure is a reading it is dated.** + +--- + +## 1. The item, and why it was the top item + +Q45's redirect relocates every **absolute path literal** in the subject that resolves inside the live data +root. Its own §7 limit 1 names the class it cannot see, verbatim: *"a root resolved at runtime from +`__file__`, an environment variable or argv, where there is no literal to rewrite"*. The pipe's Q47 +verification block carried the same follow-on as *"named, unclaimed, with the missing measurement named"*, +and stated why it had not measured it: a miss writes the live snapshot, and a restore moves the +`mtime_ns`/`ctime_ns` values three records cite as deltas. + +This item closes that class for the two shapes that are **external to the subject's own code** — an +environment variable and an argv value — and measures, rather than asserts, what happens for the third +(`__file__`), which needs no mechanism at all. + +## 2. The pair + +| artifact | sha256 | size | +|---|---|---| +| subject collector | `4e45021d80395d3f03fc58d7e99610e3c1c0d2fcf9c85f1db50b16cfd2043c6c` | 29,954 B / 720 lines — **byte-identical to Q33–Q47's**, every change is in the gate | +| Q47 gate (before) | `76f2292b9a6cad08505956787cf05e65e3edaf7e0f23f73f4aed2e59a62866c1` | 115,182 B / 2,124 lines | +| **Q48 gate (after)** | `a590170fa87d7e2a…` (full: `sha256sum /root/crucible/q48/helm-selftest.py`) | **122,186 B / 2,287 lines** | +| diff | `out/DIFF-vs-q47.txt`, 8,300 B / `71c593e2954ecaf3…` | **186 lines = 2 file header + 3 hunk header + 143 added + 0 removed + 18 context**; 2,124 + 143 − 0 = **2,287** exactly | + +`diff -u` decomposition, counted by `grep -c`, not asserted: added = `^+` minus `+++` = 143; removed = `^-` +minus `---` = 0; hunks = `^@@` = 3; file headers = 2; context = 18. 143 + 0 + 18 + 3 + 2 = 186. + +## 3. What changed — two mechanisms, two checks + +**(1) The environment redirect.** The env every subject leg runs in is rebuilt. The gate inventories, by +`ast`, every environment variable **the subject's own source reads** — `os.environ.get("X")`, +`os.environ["X"]`, `os.getenv("X")` — and any whose value resolves inside the live data root is redirected +to the per-invocation sandbox. It is a **redirect, not a refusal**: the subject runs, and it runs against +the sandbox. The inventory is printed on stderr as a `Q48 NOTE` on every path, including the zero case, +because a gate that silently rewrites an environment is indistinguishable from one that does not. + +**(2) Rule (4), the argv redirect.** A leg argument that resolves inside the live data root is rewritten to +the same relative path inside the sandbox, in the wrapper that already substitutes the subject with its +copy (Q43 rule 2). **Scoped to the write surface — the live snapshot and the live held directory — not the +whole live root.** The unrestricted form was written first and it broke G7's six checks: it rewrote the +gate's own renderer invocation `/root/.helm/bin/helm-render.py`, a READ that must not move. The scoping is +a measured correction, not a design preference (see §5, defect 1). + +**(3) Two `check()`s**, so the green count is 169 + 2 = **171 passed / 0 failed**: + +- `Q48 no environment variable the subject reads resolves inside the live data root` +- `Q48 every argv value resolving inside the live data root was relocated into the sandbox` + +**(4) `__file__` needs no mechanism.** The copy lives inside the sandbox, so a root composed from +`Path(__file__).resolve().parent` resolves there by construction. `collector-file.py` (the deployed +collector with `HELM_HOME = Path(__file__).resolve().parent`, 29,966 B / `a81f3b08b50f6055…`) is built +and runs the interpreter's own verdict (rc 0 with `--dry-run`, rc 2 bare) — measured, and the redirect's +inventory for it is empty by construction, which the Q45 check-1 lesson says must be printed rather than +silent. + +## 4. Green pair, five consecutive invocations + +`run_green_q48.py`, `HELM_COLLECTOR=`, `Q48_ROOT` unset (the real collector carries the +live root as a literal, so the env class is exercised by the subjects, not by the control): + +| run | rc | verdict | stdout | sha256 (first 16) | seconds | +|---|---|---|---|---|---| +| green-1 | 0 | `helm-selftest: 171 passed, 0 failed` / `VERDICT: ALL GATES PASS` | 13,514 B | `81fd73b8a32e5c10` | 12.45 | +| green-2 | 0 | same | 13,514 B | `7a5cfac833623db7` | 13.41 | +| green-3 | 0 | same | 13,514 B | `e88ff3763a73fef1` | 13.12 | +| green-4 | 0 | same | 13,514 B | `1f0ec83eccdc2a75` | 12.81 | +| green-5 | 0 | same | 13,514 B | `eefcc271232b005e` | 12.63 | + +The stdout size is identical across all five and the digests differ only because the gate's own render leg +prints the `mkdtemp` deploy path and the sandbox name — the same dated-reading rule this lane applies to +byte counts. **171 = 169 + 2**; no pre-existing check moved. + +## 5. The discriminator — the mechanism, on the real collector + +`run_witness_q48.py` → `out/Q48-WITNESS.json` (4,686 B / `50e7180ac30c3aa0…`, +`generated_at` in the file). The witness path is an **extra** `--q48-witness` flag added by +`patch_witness()` into a copy of the gate (`helm-selftest-witness.py`); the shipped gate never passes it, +so the green run cannot be affected. It builds the copy exactly as the green run does, prints the rebuilt +environment for every variable the subject reads, and runs the copy `--deploy --pin --skip-tests --out +` — so `--out` forces the snapshot into the sandbox and the **only** path the redirect governs is +the `HELD_DIR` the pin writes. + +Subjects: the deployed collector with **exactly one mechanical change**, asserted by count +(`make_subjects_q48.py`): `collector-env.py` = `HELM_HOME = Path(os.environ.get("Q48_ROOT", "/root/.helm"))` +(29,982 B / `4739ced839a30564…`), `collector-file.py` = `HELM_HOME = Path(__file__).resolve().parent`. + +| row | root value | gate | rc | `Q48_ROOT` the copy runs in | copy's held write | live held | +|---|---|---|---|---|---|---| +| `q48-armed-env` | `` | Q48 | 0 | `/root/crucible/q48/protected` (outside the live root, so **no redirect**) | `[]` — the pin's `HELD_DIR` is under `--out`'s sandbox only when the env names it | unchanged | +| **`q48-armed-env-liveroot`** | **`/root/.helm`** | Q48 | **0** | **`/root/crucible/q48/helm-legs-ppqy3cew` — REDIRECTED** (`env_redirects: {"Q48_ROOT": ["/root/.helm", ""]}`) | **`/data/held/stackchain-home.html.sha256`** | **unchanged** | +| `q48-disarmed-env` | `` | Q48 minus both mechanisms | 0 | `/root/crucible/q48/protected` | `[]` | unchanged | +| **`q48-disarmed-env-liveroot`** | **`/root/.helm`** | Q48 **disarmed** | **ABORT** | `/root/.helm` — nothing redirected | **wrote the LIVE held directory** (`5b6d7071…` → `c0532a8b…`, a real page hash) | **NONZERO, restored by the guard** | +| `q47-blindspot-env` | `` | pre-Q48 (copy, original env) | 0 | n/a — no redirect exists | `HELM_HOME`/`SNAPSHOT_PATH`/`HELD_DIR` all `/root/crucible/q48/protected`, i.e. the class is invisible to Q47 | unchanged | + +**Prevention separated from detection, by measurement.** The armed row redirects the live root into the +sandbox and the pin lands there; the disarmed row, same subject, same live root value, writes live and the +guard catches it, restores it byte-exact and aborts. The class is real, one mechanism away, and the +mechanism closes it. + +## 6. The incident, disclosed in full: this run wrote the LIVE held directory, twice + +Not discovered — **caused, measured, restored, and disclosed**. Three separate live writes of +`/root/.helm/data/held/stackchain-home.html.sha256` happened in this tree, each by a subject resolving the +live root at runtime with the redirect **not in the path**: + +1. **The fixture-verification run (v1, ~05:5xZ).** `make_fixtures_q48.py`'s `verify_interpreter()` ran + `argv-root.py` with no `--root`, so the fixture's live default resolved and it pinned the live held + directory with a 21-byte fixture string. Restored byte-exact from the Q43/Q45 backup by + `restore_held_q48.py`, which **refuses to restore unless the backup's digest matches the one every prior + tick recorded** (`5b6d7071…`). Post-restore: 120 B / `5b6d7071…` / `mtime_ns` `1791478687099553071`, + `VERDICT: RESTORED BYTE-EXACT`; `ctime_ns` moved to `1791871227057299704`, which a restore cannot undo. + The fix that followed: every fixture carries an explicit protected-root default and an assertion that + refuses a live default. +2. **The v1 driver's first armed row (~06:0xZ).** The driver aimed the armed rows at the LIVE root and its + own `abort_if_live_moved()` guard fired on the first `q48-armed-env` row and restored byte-exact. The + cause is a **real residual of the gate's design**, stated plainly: the gate's legs run the **original** + subject with the inherited environment (Q43's resolution leg, G8's dry-run leg), so a subject whose root + is the live root writes the live held directory from a leg the redirect does not cover. The redirect + covers the **copy**; the original is covered only by the snapshot watch (snapshot-only) and the held + census (once per run, not per leg). v2 of the driver therefore used the protected root as the universal + root value and witnessed the class at the mechanism level instead — and this residual is the item's + first stated limit, not a footnote. +3. **The `q48-disarmed-env-liveroot` witness row (~06:3xZ).** The deliberate falsifier: with the redirect + cut out and the live root as the value, the copy's pin wrote the live held directory (`c0532a8b…`). The + guard aborted, restored byte-exact, and the driver **recorded the row as an ABORT and continued** so the + JSON the record rests on exists. + +**Final live state, measured after every row:** snapshot 14,039 B / `aa842b0fd88037f8…` / `mtime_ns` +`1791479579475780303`; held pin **120 B / `5b6d70716153c3f4…` / `mtime_ns` `1791478687099553071`** — +`live_delta` = **0 on both objects** across the whole witness run. `ctime_ns` carries the restore traces +and is not recoverable; that is the durable record, not an argument. + +## 7. Limits, and this run's own defects first + +1. **The gate's own leg set is the residual.** As §6.2 states: a subject whose root is the live root writes + the live **held** directory from the gate's own original-subject legs, because the redirect covers the + copy and the watch is snapshot-only. Closing it needs the held census per leg, or the original-subject + legs to inherit the rebuilt environment too — the latter is one line in `_q39_run`, and it is **not** + taken here because it changes what the resolution leg measures. +2. **Rule (4) fired zero times in this run.** No leg argument resolved inside the held directory, so the + argv redirect is coded and its two checks pass, but the only exercised part of it is the zero case. The + `argv`-shaped subject (a `--root` argument) was built (`collector-env.py` covers the env shape; + `collector-file.py` the `__file__` shape) and a true `--root` leg was not reachable through the suite — + stated, not papered over. +3. **Four fixture generations failed for reasons that were the fixture's.** v1–v3 wrote the held pin only + under `--deploy`, which no leg passes, so the class was unmeasurable; v3's refusal did not name the + destination G8 asserts on; v4 called the Q35 marker above the name it names (`NameError`); v5/v6 had no + `main()` and ignored `--out`, so G8's in-process deploy leg died on `AttributeError`. **v7 abandoned the + hand-written fixture** in favour of the deployed collector with one mechanical change — re-implementing + 720 lines to test one mechanism is theatre, and the honest instrument is the collector itself. +4. **The gate's suite is not the discriminator for this class, and that is a finding, not an excuse.** No + subject leg runs the copy with `--deploy --pin`, so a runtime-resolved subject's redirect is never + exercised by the suite; and the Q43 resolution-leg check (*"the subject's own default destination + resolves to the live snapshot the gate protects"*) cannot pass for **any** subject whose root is the + protected scratch root — it is a live-identity check, not a redirect check. The witness path exists + because of that, and it is deliberately an **extra flag**, not a change to the green run. +5. **Static, as every guard in this family.** A root read from a config file, a network response or + argv[0]'s resolved directory at call time is outside an `ast` inventory. Q39's literal scan, Q45's + resolved-destination check, the per-leg watch and rule (3) remain the backstop, unchanged. +6. Nothing re-tests Q47's 21 rows; the Q47 gate in `legacy/` is a copy, not re-verified this run. + +## 8. §4 consequence + +The adoption pair is now collector `4e45021d…` + gate **`a590170f…`** (superseding Q47's `76f2292b…`). Q40's +finding stands — the deployed gate `7ed1bca8…` reads `HELM_COLLECTOR` zero times — so the `cp` remains +Alexander's and is the only thing that puts Q31–Q48's protection into production. **The gate is still +moving**: the next Helm item (if any) will move it again, so a copy now lands Q48 with Q49 a known gap. + +## 9. Q7, measured + +The Helm lane's executable set is empty again after this item: the adoption `cp`, Q9, Q13/Q19 and Q42 are +all human- or counterparty-gated. No new item opened. The residual §7.1 names (per-leg held census, or the +original-subject legs inheriting the rebuilt environment) is the natural successor and is **not** opened +here, because it is a one-line change to a mechanism this run only just built, and it deserves its own +measurement rather than a rider on this one.