@sema-agent/core 7.17.4 → 7.17.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/core/memory-engine/delegation-settlement.d.ts +27 -9
  3. package/dist/core/memory-engine/delegation-settlement.js +155 -46
  4. package/dist/core/memory-engine/engine.js +12 -6
  5. package/dist/core/remote-env.d.ts +23 -1
  6. package/dist/core/remote-env.js +4 -1
  7. package/dist/core/runner/prepare-hands-readface.js +10 -8
  8. package/dist/core/tool-catalog-entries.js +1 -1
  9. package/dist/tools/fs/bash-readonly-classifier.d.ts +46 -5
  10. package/dist/tools/fs/bash-readonly-classifier.js +572 -80
  11. package/dist/tools/fs/fs-bash.d.ts +28 -0
  12. package/dist/tools/fs/fs-bash.js +135 -27
  13. package/dist/tools/fs/index.d.ts +13 -0
  14. package/dist/tools/fs/index.js +1 -0
  15. package/package.json +3 -2
  16. package/wire-changes/core@7.10.0.json +739 -0
  17. package/wire-changes/core@7.11.0.json +685 -0
  18. package/wire-changes/core@7.11.1.json +112 -0
  19. package/wire-changes/core@7.11.2.json +73 -0
  20. package/wire-changes/core@7.12.0.json +356 -0
  21. package/wire-changes/core@7.13.0.json +199 -0
  22. package/wire-changes/core@7.14.0.json +578 -0
  23. package/wire-changes/core@7.15.0.json +174 -0
  24. package/wire-changes/core@7.16.0.json +132 -0
  25. package/wire-changes/core@7.17.0.json +1621 -0
  26. package/wire-changes/core@7.17.1.json +139 -0
  27. package/wire-changes/core@7.17.2.json +65 -0
  28. package/wire-changes/core@7.17.3.json +44 -0
  29. package/wire-changes/core@7.17.4.json +52 -0
  30. package/wire-changes/core@7.17.5.json +67 -0
  31. package/wire-changes/core@7.5.0.json +1063 -0
  32. package/wire-changes/core@7.5.1.json +252 -0
  33. package/wire-changes/core@7.5.2.json +418 -0
  34. package/wire-changes/core@7.6.0.json +1243 -0
  35. package/wire-changes/core@7.6.1.json +1130 -0
  36. package/wire-changes/core@7.7.0.json +509 -0
  37. package/wire-changes/core@7.8.0.json +1293 -0
  38. package/wire-changes/core@7.9.0.json +691 -0
  39. package/wire-changes/core@7.9.1.json +518 -0
  40. package/wire-changes/core@7.9.2.json +46 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.17.6 — 2026-09-14
4
+
5
+ ### Narrowing — a verb whose ARGUMENT IS A PROGRAM: the sed script slot is read for the files it names and the commands it runs (#769; @server @cli @test)
6
+ - **Before.** `sed`'s script text can name files (`r`/`R` read one, `w`/`W` write one, `s///w` writes the replacement) and can run commands (`e`, `s///e`), and the read boundary judged that script as ONE OPERAND: `'1r /etc/hosts'` carries a slash, so it read as a path-shaped token, resolved under the working directory, landed inside the roots, and the file the script actually opens was never a candidate. The CC-anchored shape arm refused these spellings, but as a PLAIN reason carrying no refusal of the whole command — and under the doctrine that mounts the read-boundary seat alone, a verdict with no whole-command refusal is a vouch. Measured on the pure faces before the fix: `sed -n '1r /etc/hosts' f`, `sed '1r /etc/hosts' f` (no `-n`: the shape arm returns before it reads the script), `sed -n -e '1r /etc/hosts' f` (`-e` is outside that arm's flag whitelist, same early return), `sed -f evil.sed f` (the script is in a file), `sed 'w /tmp/out' f` (a write outside the roots) and `sed -n '1e cat /etc/hosts' f` / `sed 's/x/y/e' f` (execution) were all zero-ask.
7
+ - **After (the argument-as-program family gains its third member; one mint point, one sentence).** A shell re-entry program (`sh -c …`) is refused whole because its argument IS the command line; an argv re-entry program (`xargs`) is refused whole because the operands arrive on a stream; a SCRIPT SLOT is refused whole because a file name or a command line sits inside the script the verb runs. Same refusal, same template (`command "X" …, which this walk does not judge — not auto-allowed`), and the re-entry sentence is unchanged character for character. The answer is read off sed's OWN grammar, never a character blacklist: `s/foo/bar/` contains `r` and names nothing, `sed 'a r /etc/hosts'` appends TEXT, `sed '/r /p'` matches a regex. sed's command letters are a CLOSED set, so this is a small recognizer over a known grammar — address, optional negation, command letter, the argument that letter takes — not a second hand-written parser over an open one. `-f`/`--file` puts the script in a file the walk never opens and therefore answers the same way, and the flag whitelist is ORTHOGONAL: `sed -i 'w /x' f` is refused for its script while `sed -i 's/a/b/' f` is untouched.
8
+ - **Read under BOTH implementations' option grammars, because which ARGUMENT is the script follows from the options.** Three axes where the two widely deployed seds disagree, each of them a live miss before it was modelled: `-l` takes a line length in one and nothing at all in the other (so `sed '-lne1r <file>' <file>` hides a read behind a no-value letter inside a cluster); one stops recognizing options at the first operand while the other permutes (so `sed '<script>' <file> -e p` runs `<script>` in the first and `p` in the second); and the in-place option's BACKUP SUFFIX is the rest of the token or, when the token ends there, the next token. That last one is read narrowly and deliberately: a next token counts as the suffix only when it is SPELLED like one (empty, or a dotted extension), because reading any next token that way offered the FILE of every `sed -i '<script>' <file>` as a second script candidate — and a file name beginning with one of the file-naming letters (`README.md`, `routes.ts`, `env.ts`) then read as a file-naming command. The readings combine as: an affirmative `names` under ANY refuses; a script no reading could SEE (`-f`) refuses, because file-supplied program text may carry anything and one reading finding one settles it; a script no reading could FINISH refuses; and a reading that lands on a clean script clears the invocation, which is what keeps `sed -i 's/a/b/' README.md` at zero change.
9
+ - **The program the SHELL runs is what carries the slot.** The chain is unwrapped once and the slot judged on the program behind it, whether or not the deployment's table names that program and whichever face is asking — so `env sed …`, `timeout 5 sed …`, `env FOO=1 sed …` and `/bin/sed …` are the command they are, on the compound walk and on the face with no approval channel alike.
10
+ - **Where a command's ARGUMENT ends is part of the grammar, and was measured rather than assumed.** A label — and the version command's argument, read by the same rule — ends at a blank, a `;`, a `}` or the line, so `sed 'v;1r <file>' f` and `sed ':label r <file>' f` carry a real read after them. A blank BETWEEN substitution flags does not end the flag list, so `sed 's/a/b/ iw out' f` writes a file (measured against a real sed on the build host). A substitution half cannot span a line break, so a half that swallowed one consumed the command on the next line as replacement text.
11
+ - **Behaviour, for consumers.** Under BOTH doctrines a `sed` whose script can name a file or run a command now asks ONCE, mandated (a stored allow rule is disclosed as shadowed and does not clear it). **Under the default doctrine these ran with ZERO asks before**, so on a headless deployment — no approver wired — the card is a REFUSAL, not a prompt: every `r`/`R`/`w`/`W`/`e` spelling, every `s///w` and `s///e`, and every `-f`/`--file` invocation, in their separated, fused and `--`-terminated spellings, behind a launcher (`env sed …`, `timeout 5 sed …`) and under a path-prefixed name (`/bin/sed …`) alike. An invocation already refused for its out-of-root operand keeps its ask, but its verdict now carries the refusal sentence rather than `outOfRootPaths`, so no "allow reading from …" directory grant is offered for it. Unchanged and pinned: the print, range, step, regex-address, negated-address, block, label, hold-space, multiline, transliteration, comment, quit-code and substitution spellings, the in-place spelling and its suffix form, `-e` pieces that carry none of those commands, and the everyday flags (`-s`, `--posix`, `-l N`, `-u`, `-E`/`-r`).
12
+ - **Three more places where a spelling decided the answer, each one measured against a real sed on the build host before it was closed.** The ACTIVE delimiter is recognized before bracket syntax (`s[a[b[;1r <file>` chooses `[` as its delimiter — reading that `[` as a bracket expression both swallowed the command after the substitution and refused the plain `s[foo[bar[`); no bracket scan crosses a line break (an unterminated class swallowed the following lines whole while a later line closed the delimiter); and a launcher whose wrapped program cannot be read is refused WHOLE whether or not the deployment listed the launcher — the refusal is about what the launcher RUNS, so a table naming the launcher says nothing about it, and reaching that refusal only from the unlisted branch let a listed launcher (`env -- <program> …`) carry an unreadable chain past every arm. That last one is a narrowing beyond `sed`: a deployment that has put a launcher on its own reader table now gets the same whole refusal there that it already got without it.
13
+ - **The separated backup suffix belongs to ONE reading, and a control word's own option can never become the program.** Only the implementation whose bare in-place option takes the next token consumes one, and only when that token is SPELLED like a suffix (empty, or a dotted extension) — applying the consumption to both readings erased the reading in which the same token is the SCRIPT (`sed -i 'r <file>' 'a file'`) or an OPTION (`sed -i -f '<script file>' f`), and taking any next token refused eleven of thirteen everyday in-place lines and every file name carrying a blank. Separately, an OPTION can never become the program word: a control word with an option of its own (`time -p <program> …`) had the strip remove the control word and read `-p` as the program, leaving the real program — and everything it does — unjudged. The option now belongs to the word that takes it, DECLARED in a launcher option table rather than guessed (the shell builtin's `-p`, and the standalone program's format, output file and switches), and the program behind it gets the ordinary walk: measured against the base tree, `time -p ls`, `time -p npm test`, `time -p cat <file>`, `time -f '%e' ls` and every other everyday spelling keep the cell they had, while `time -p sed -n '1r <file>' <file>` becomes the mandated ask the bare `sed` spelling already was. An option that table does NOT carry (`time --unknown …`) leaves which program runs unreadable and is refused whole — the same answer a launcher chain has always given for that condition.
14
+ - **Three more places where something ends earlier than a reader assumes, each measured on the build host's own sed and shell.** A `#` comment does not run past the length an implementation compiles in one piece — that implementation splits an oversized line into chunks, which ends the comment in the middle of itself and compiles the rest as commands, so a script LINE at or beyond a conservative bound is not read here at all — measured in BYTES the way the implementation measures it (the multi-byte spelling of the same padding is a third of its byte count in JavaScript characters), and reported as a script this reading cannot ACCOUNT FOR, which another reading landing on a clean script must not clear. sed's blanks are the C library's five, not two: a vertical tab or a form feed before a command made the command unreadable here while sed ran it. And `coproc` backgrounds a command, which this classifier already refuses whole in its `&` spelling — it is the one block word a COMMAND follows, so reading it as a bare word that names no reader left everything behind it unjudged by every arm. That last one is a narrowing beyond `sed`: `coproc <anything>` is now refused whole, the same answer `<anything> &` already got.
15
+ - **Known gap, disclosed and pinned as a fact rather than assumed closed.** A separated backup suffix that is not spelled like one (`sed -i bak 'r <file>' f`) is not consumed, so the implementation that would take it has its script read one token early. That spelling errors outright under the other implementation, and the alternatives — consuming any token, or deciding by what the token behind it looks like — each cost more than they bought, both measured. The complement is stated the same way: the contents of a `-f` script file are never read (they are refused instead), a comment/regex/replacement/text payload is skipped by construction, and a verb outside the table's one row is not interpreted at all.
16
+ - **Stated over-fire, pinned rather than described.** A script this recognizer cannot finish reading answers the same way an affirmative one does — an address with no command (`sed -n 5 f`), a letter outside sed's set, an unterminated substitution, an unclosed block, a substitution flag outside the set, and an option outside sed's closed table (`sed --frobnicate …`, `sed -Y …`). Every one of those is a script sed itself rejects, so the cost is one approval on a command that was not going to run.
17
+ - **The CC-anchored shape arm is untouched.** "Is this script CC's pure-print grammar" is the classify seat's own question and keeps its own sentence, its own PLAIN refusal, and its existing treatment of `-e`, `-i` and any non-print piece. The arm added here answers the other question — "can this script name a file or run a command" — which is the read boundary's, and both seats read it.
18
+ - **Scope, honestly.** Only `sed` has a script-slot row; the table is keyed by verb so a second such verb is a row rather than a second branch. `awk`/`jq` are on NO reader table in this engine, so the walk makes no claim about their reads and the residual there belongs to the doctrine rather than to the boundary — measured on this tree, the three awk spellings that name files (`getline < …`, `system(…)`, `print > …`) are already mandated because their punctuation meets the segment grammar gate, while `awk -f prog.awk` and `jq -f prog.jq` sit in the same class as any other unlisted program (`python3 script.py`) and are vouched exactly as they were.
19
+ - Pins: `test/backlog769-sed-script-slot.test.ts` (121 cases — the real load path on both doctrines with an out-of-root canary and its reverse control, the mandated-ask lane under an exact covering rule, all four arms of the slot across twenty-three spellings, thirty-seven negative controls pinned on the recognizer's own answer plus a thirty-four-line everyday corpus, reachability at every face that answers a read-boundary question — the walk's listed and unlisted branches, behind a launcher, under a path-prefixed name, `classifySimpleCommandReadBoundary`, `refusedCommandMayRead`, and the containment gate's pinned silence — the fail-closed arm, the option-grammar arms, the argument-terminator arms and the family's shared sentence; TWENTY-THREE mutation directions measured in the header, no two of which red the same set). Measured beside the pins and reported rather than assumed: two differential passes run sed argv spellings against the real binary on the build host — 1 475 constructed spellings (353 of which actually read, wrote or spawned) and 4 000 randomly generated scripts (659 of which ran, 144 of them dangerous) — with zero missed by the script slot in either; a 60 000-script and 40 000-argv fuzz throws nothing, and the recognizer is linear — an 819 KB command of 32 768 `-e` pieces is read in 101 ms.
20
+
21
+ ### Narrowing — the execute-time canonical recheck now answers for the two operand shapes the lexical walk vouched for without seeing: a glob's expansion, and a `..` taken after a symlink (#775/B-109, #770; @server @cli @test)
22
+ - **#775 — before.** The full shell's execute-time recheck judged the literal candidates (`checkedPaths`) and handed the expander an EMPTY pattern list, on the stated ground that an unexpanded glob is an ask under every doctrine and the answer therefore belongs to whoever gave it. That ground was false. The demotion is the boundary PROBE's verdict alone, and it does not survive the gate: a listed reader's call is cleared by the read-only command lane, which judges the verb and never consults the boundary. Measured on the real load path (Runner prepare → NodeExecutionEnv → the gate → execute) with `<root>/vendor` → an outside directory, under the default doctrine (`shellGate` absent) AND `"classify"`: `cat vendor/*` was asks=0, isError=false and the outside bytes reached the model record, while the literal `cat vendor/secret` was refused on the same tree. A standing `Bash(cat:*)` allow rule and a bare zero-config Runner behaved identically.
23
+ - **#775 — after (one expander, no new rule).** The full face hands the recheck the verdict's `undecidedPaths` exactly as the `bash_readonly` face always has — same expander, same fail-closed `unverifiable` arm, same refusal helper — so a pattern is judged on what it EXPANDS to and never on its spelling. The ONE exclusion is what the walk itself placed outside the roots (`outOfRootPaths`): that is a mandated ask, a yes on `cat <outside>/*` still runs it, and re-refusing it here would make the leg a second adjudicator of a decision already made. An operand appears on BOTH fields for that shape, which is why the exclusion is taken on the pattern list too.
24
+ - **#770 — before.** The walk collapses `..` in the TEXT before anything is resolved, so `cat <link>/../x` was checked as `<root>/x` — a spelling that never mentions the link — while the kernel resolves the prefix FIRST and takes the parent step from where that lands. Measured on the same path, both doctrines: `cat vendor/../beside.txt` (a link out of the roots), `cat self/../above.txt` (a self-referential link, climbing above the task root) and `cat into_other/../beyond.txt` (a link into a second DECLARED read root, then above that root) were all asks=0, isError=false, with the canary in the record. The `bash_readonly` face carried the same hole.
25
+ - **#770 — after (the kernel's spelling, carried beside the fold).** `resolveOperandSpellings` is the one mint point for both spellings of an operand: `folded` (`..` collapsed) stays the containment answer — every root comparison, deny judgment and reason sentence in the classifier is built from it, and the file stays synchronous and zero-I/O — while the uncollapsed absolute path rides the verdict as `checkedSpellings` / `undecidedSpellings`, present only when an operand traverses `..`. The execute-time resolver walks that spelling the way the kernel does: one resolver call per run of ordinary segments, none per `..` (a parent step is a lexical fold on an ALREADY CANONICAL path, spelled through `normalizeAbsPathLexically` so the family rules stay in their one home), and byte-for-byte the single call it always made when no segment climbs. A `..` through a segment that does not exist still keeps the deepest-existing-ancestor rejoin, so the shell reports its own error rather than the boundary inventing one.
26
+ - **Behaviour, for consumers — both doctrines, and the headless lane first.** These spellings move from RUN to REFUSE, with no approval step to escalate to: any glob whose expansion resolves outside the declared read roots (`cat vendor/*`, `head vendor/*`, `ls *` in a workspace whose top level holds a link out), and any `..` taken after a link that leaves every declared root. **On a headless or auto-mode deployment — no approver wired — the card is a REFUSAL, not a prompt, and it always was for the literal spelling of the same read**; what changes is that the glob and `..` spellings now get the same answer the literal one got. The remedy is unchanged and is named in the refusal: declare the link's target as a read root.
27
+ - **Stated over-fire, measured on a real source tree rather than described.** (1) A leading-dot pattern expands `..` — the shell does too — and `<root>/..` is outside the roots by definition, so `cat .*` and `ls -d .*` are refused, the same direction the literal `cat ../x` is already asked in; `.??*`, `.[!.]*`, `.*rc` and `ls -a` cannot match a two-character `..` and are untouched. (2) An expansion the leg cannot finish enumerating (`MAX_GLOB_DIRS` 256 / `MAX_GLOB_MATCHES` 8192, or a segment the matcher cannot compile) is `unverifiable` and refuses: a containment that could not be verified is not a containment. Measured over this repository's own tree, the binding bound in practice is the DIRECTORY one, not the match one — `src/*/*.ts` (286 matches), `test/*.test.ts` (1274), `docs/*.md` (37), `node_modules/*/*.json` (190) and `ls *` all pass, while a three-level glob over a dependency tree (`node_modules/*/*/*.js`) exceeds 256 directories and refuses. (3) The walk is program-blind, so a segment whose program is not on the reader table is judged WHOLE under the synthetic scan head and every token is a candidate, patterns included — `printf %s .*` and `git add .*` are refused even though neither opens what the pattern matches. A program DECLARED to read no path operand (`echo`) offers nothing at all and is the line between the two.
28
+ - **Eleven more the adversarial passes found, each reproduced before it was closed.** (a) A pattern's frontier directory was handed to `listDir` in its WRITTEN spelling, and that call folds `..` out of a path before it reads — so `cat <link>/../*` enumerated the folded location: the wrong directory's names, with every entry's type stat'd at the wrong place, which dropped exactly the escaping links this leg exists to find. The directory is now resolved once and enumerated in its canonical form, with the entries keeping the written spelling for the judge. (b) The `bash_readonly` face's rescue arm — which exists so an ALIASED root spelling is not refused — judged the folded out-of-root paths alone, so `cat <alias>/<link>/../x` was rescued on a folded reading that lands back inside while the real read does not; the verdict gained `outOfRootSpellings` and the rescue now requires BOTH readings inside. (c) The out-of-root exemption on the pattern list was matched on the exact string, so the kernel spelling of an operand the approver had ALREADY released (`cat <outside>/a/../*`) slipped the exemption and was re-refused at execution; the lookup now folds first. (d) The engine's overflow-spool exemption — the grant that keeps `tail -c <area>/bash-output-*.log` working after a clipped result — judged the folded paths alone, and its fence is lexical: `<area>/<link>/../<mint-name>` keys as a minted file while naming something else. An out-of-root operand carrying `..` now withdraws that exemption; nothing the engine mints carries one. (e) Introduced by (a) and closed with it: the resolver rejoins a missing tail, and a later parent step erased it — so `cat <link>/missing/../../*`, which the kernel stops at with ENOENT, was walked back to a real directory and refused on what it found there. A parent step is no longer taken from a location that does not exist, and a frontier PROVED absent matches nothing instead of being enumerated. (f) Introduced by (e) and closed with it: "could not resolve" is not "proved absent", so a directory the environment answered with `permission_denied` was read as absence and its pattern skipped — the resolver now reports which of the two it saw, and only proved absence licenses the skip. (g) Also from (e): a walk that stops at an absent component now ENDS there instead of rejoining the remaining `..`s, which had handed the judge a spelling that folds to somewhere the command never reaches (`cat <self-link>/missing/../../x` was refused while reading nothing). (h) The spool exemption is decided on the RESOLVED target and over the same folded-plus-kernel union the rescue takes, which closes the mint spelling that is itself a link out of the temp area, the link standing in for a directory on the way to it, and the parent step after either — one rule where the previous cut had two. (i) Both of the read-only face's grants now require `unresolvedOperands` to be empty: a grant that covers the out-of-root objection was releasing the whole refusal, and with it an operand the walk could not resolve at all (`cat ~someone/private <spool-file>`). (j) The last of the family: `!exact` without `missing` is the UNVERIFIABLE state — the location was neither established nor ruled out — and the walk was ending there and handing back the prefix as if it were the answer, so a component that canonicalized with a permission error made `cat <link>/../x` read as in-root. Every consumer now answers that state fail-closed: the judge reports it as unverifiable, the enumeration refuses rather than listing the written spelling, and neither grant treats it as containment. (k) And the counterweight to (j), measured on this repository's own tree: `not_directory` is as CONCLUSIVE as `not_found` — a component that is not a directory cannot be traversed — and an ordinary two-level glob generates exactly that spelling for every FILE in its frontier (`src/*/x.ts` offers `src/index.ts/x.ts`), so reading it as an unanswered question refused everyday commands.
29
+ - **Contract, additive:** `CompoundReadonlyVerdict` gains three optional members — `checkedSpellings`, `undecidedSpellings`, `outOfRootSpellings`, one per path list it reports — and the classifier exports `resolveOperandSpellings` / `OperandSpellings` beside the existing `resolveOperandLexically` (which is now that function's folded half). All three are ABSENT unless an operand traverses `..`; a consumer resolving against a filesystem judges them BESIDE the folded list they companion, never instead of it. No existing member changed shape or meaning.
30
+ - **Known residuals, stated rather than implied.** A `..` operand is judged in BOTH spellings, the kernel's and the text's fold, so when the two differ the fold's reading can refuse a read the command never performs — pairing them per operand is not expressible in sparse companion lists, and dropping a folded entry on a fold collision would stop judging a different operand that shares it. The leg judges a directory operand's own canonical form and does not walk its subtree, so a link BELOW a recursive verb's traversal root is out of reach (measured: every such command is a mandated ask on the real load path). `**` is compiled as one ordinary segment, which is what the shell means with globstar disabled — a deployment that enables globstar would have the expander under-expand. `cd -P` is not modelled; the builtin's default is logical resolution, which is what the walk models. A parent step taken from a resolved NON-directory (`<dir>/file/../x`, ENOTDIR to the kernel) is walked rather than stopped, which can refuse a command that reads nothing — proving otherwise costs a stat per run. A quoted metacharacter inside an otherwise-globbing token (`cat "d[ab]"/*`), and a pattern in both a declared root's own spelling and its suffix (`cat /r[ab]/*`), are expanded at the wrong place; both need per-character quote provenance the verdict does not carry, and both predate this change. And the refusal's prose says "resolves through a symlink" for the dot-pattern and enumeration-bound arms, which need no symlink — pre-existing wording shared with the `bash_readonly` face.
31
+ - Pins: `test/backlog775-glob-canonical-recheck.test.ts` (38 pins on the real load path: the pattern leak on both doctrines across verbs, compounds, pipes, the backgrounded flag and the poll loop with the ask count asserted on every one; the three over-fires measured where they are claimed; the in-root and declared-additional-root negatives; the gate's own out-of-root pattern question left with its answerer; construction-independence under a bare Runner, a standing allow rule and the classify-tier probe; the three `..` shapes on both doctrines and on the `bash_readonly` face; the `..` negatives; the composition of the two arms, and the eleven the five adversarial passes found — seventeen mutation directions measured in the header). `test/backlog768-bash-canonical-recheck.test.ts` re-pins G2, G2-b, G10-a and G11-b: the first three recorded the two holes as deliberate or disclosed, and G11-b now records the program-blind over-fire as the mechanism it really is.
32
+ ### Fix — a plane file is staged BESIDE itself, never into the control plane: the displacement law worked only where the control plane shared the memory dir's filesystem (#778; @server @cli @test)
33
+ - **Before.** Displacing a file off the model-visible plane — the instruction HOLD's capture, its crash reconciliation, and the crash-residue containment — moved the file by renaming it INTO the control plane's `hold/` or `quarantine/` directory. `rename` is atomic only WITHIN one filesystem, and the control plane lives under the data root (`AGENT_DATA_DIR`, else `~/.ai-agent`) while the memory dir lives wherever the deployment puts it: on a container or cluster deployment those are routinely two mounts. The cross-device refusal was read as a REAL refusal and the intake fell closed to quarantine, so on a two-volume deployment a pending session's rule note came back as a `polluted` rejection ("hold removal failed (EXDEV): the plane file was not moved"), the hold row went straight to a terminal, and the settle-clean release / settle-dirty disposal / timeout arms had nothing left to act on. The two reconciliation sites swallowed the same error silently. **Measured**, one runway, two filesystems (memory dir on the container's own layer, data root on a host mount): 9 of 37 pins of the settlement/hold suite red; the same tree with both directories on one filesystem, 37 of 37 green. The platform was never the variable — whether the two directories share a filesystem is.
34
+ - **After (one coordinate law, one rule fewer).** The staged name is a SIBLING of the file being displaced (`stagingPathBeside`): a directory is on its entries' filesystem by construction, so the rename is atomic on every layout and there is no filesystem-shape branch to pick between. The cross-device special case is gone rather than given a fallback. Custody is unchanged — it still lives in the control plane, written and verified from the capture buffer BEFORE anything moves — and whatever the no-clobber restore leaves behind retires there (`retireStagingToCustody`), so withheld bytes never stay on the plane. The staged name is not `.md`-suffixed, so the entry scan reads it as a non-entry even in the window it exists.
35
+ - **What the writable seat earns, none of it a new rule — the module's existing obligations applied to the new leg.** The staged inode is CLAIMED by an atomic same-directory rename before it is read, copied or removed (the duplicate-drop path included) — a writable directory is one a replacement can land in between the read and the unlink, and after the claim what the rename moved is what every later call addresses (the single-winner rename the sidecar lock steal already uses, one directory down). The custody copy is `fsync`'d, file and directory entry, before the source is removed — a copy across volumes can otherwise persist the delete first and lose the only durable copy in a crash, a window the plain rename never had; a directory fsync that FAILS keeps the source, while a platform that will not open a directory as a descriptor at all is `unsupported` and proceeds (the two are told apart by which call throws, never by an errno table). And hold reconciliation SWEEPS displacement generations a crash left beside the seat: only names matching the grammar this module MINTS (a prefix alone would adopt an ordinary note for wearing the right spelling), only plain files (a link or a FIFO wearing that name is never read through), one `readdir` per seat directory, and a generation carrying the captured bytes is dropped ONLY against verified custody — after a crash the custody copy may be the half that did not survive, and the generation is then the only copy there is, so it goes back onto the seat no-clobber and the ordinary arm completes the capture from it.
36
+ - **Disclosure widened.** A retire that did not SETTLE the bytes — custody unwritable, custody not durable, or a source that could not be removed — is now disclosed exactly like a stranded restore, and the coordinate the warning and the announcement quote is where the bytes actually are. The two messages carry that coordinate instead of a hard-coded `hold/…` prefix; on the ordinary path the rendered sentence is unchanged.
37
+ - **Round 3 — recovery never puts a withheld instruction back on the plane, and one durable-capture primitive serves every capture.** The recovery arm that completed a missing custody copy did it by restoring the generation onto the `.md` seat; if the custody write then failed, the instruction sat on the model-visible plane for a later CLEAN session to admit under its own account — the exact containment the hold exists for. Recovery now completes custody FROM the generation and drops it, and falls back to filing it under its generation name; the `.md` seat is never written. Every capture — intake, reconciliation, retire — now goes through one `writeCustodyVerified` (exclusive create, `fsync`, read back and hash), because each of them deletes its source on the strength of that answer and a read-back alone proves the page cache, not the disk. The directory entry stays best-effort, the posture `atomicWriteFileSync` already states for every sidecar in the same module: refusing to remove a source wherever a platform will not flush a directory would leave withheld bytes on the plane there, which is the worse half of the trade. Every path that decides about a generation CLAIMS it before the decisive read, so the bytes hashed and the name removed are one inode; and a coordinate earned mid-call now rides the refusal text too, instead of being dropped when the final ledger flip fails.
38
+ - **Residuals, stated rather than implied.** A writer that enumerates the seat can still replace the CLAIM name in the window before it is read or unlinked — nothing Node's `fs` offers closes that on a writable directory (there is no unlink-by-descriptor), and the window is strictly narrower than the one it replaces. Generations are minted only while a hold row is `capturing`, which is what the sweep walks, so the hold family is covered; a generation the crash-residue containment left behind has no row to be found by and stays beside the seat as a non-entry file, listed by the next harvest's skip report (`report.quarantined`, the channel non-`.md` names already ride). Journaling generation ownership so recovery does not depend on a name — and taking exclusive custody through a descriptor rather than a name — is the design-level answer and is filed, not half-built here. Two hardenings are deliberately unpinned because no single-threaded observable exists for them: the intake's own claim (a race) and the durability of the intake capture (a power loss).
39
+ - **Behaviour, for consumers.** @server @cli: on any deployment where the memory dir and the data root are separate mounts — a container whose data root is a mounted volume, a cluster pod with its own claim — instruction holds, hold reconciliation and crash-residue containment now work instead of falling closed. What changes there is that a pending session's instruction-form note is HELD (off the plane, released when every delegation settles clean) where it previously came back as a `polluted` rejection with the file in quarantine; no wire key, no report field and no error code changes shape. A deployment with one filesystem sees no behaviour change at all. **Caveat, stated rather than implied:** a host that already accumulated quarantined instruction notes from this defect does not get them back automatically — they stay in `quarantine/` for host review, which is where the fail-closed path put them.
40
+ - **Also fixed (test-side, same root).** The scope-registry pin for an UNPROBEABLE volume seeded its fixture by CALLING `registerScope`, which creates a letter-bearing scope home in the memory dir — exactly what the case-fold probe's READ leg answers from, so the "unprobeable" arm it claimed to exercise never ran. On a case-folding volume the probe answered "fold" and the fold-equal refusal fired for the other reason; on a case-sensitive one it answered "no fold" and walked into a registration the unwritable directory refuses. The fixture now seeds the registry by writing it, so the memory dir is genuinely empty and the arm under test is the arm that runs — on either kind of volume.
41
+ - Pins: `test/backlog778-cross-filesystem-control-plane.test.ts` (the coordinate law and its negative half, the retire's custody move and its claim trace, the unwritable-custody arm that keeps the only copy and names it, the crash-generation sweep and its duplicate arm, and an end-to-end harvest with the control plane on a FOREIGN filesystem — measured by `st_dev`, mandatory wherever the host offers a second writable volume and asserting the single-volume fact where it does not); `test/backlog778-casefold-probe-preconditions.test.ts` (the probe's two legs held against an independent gauge on the same volume, so a fixture cannot claim an unprobeable volume while standing on a probeable one). Eight mutation directions measured: the staged coordinate leaving the file's own directory, the claim dropped, the sweep disabled, a captured-bytes generation dropped without verifying custody, a prefix match standing in for the minted grammar, recovery restoring a generation to the `.md` seat, and recovery reading a generation it has not claimed (each red on its own pin, on one filesystem); and the pre-fix control-plane coordinate restored — red on the two-filesystem runway (the original nine plus the cross-device pin), green on a single-filesystem host, which is the divergence itself.
42
+
43
+ ### Narrowing — the execute-time read boundary asks what an environment DECLARES, not what interface it implements (#782/B-115; @server @cli @test)
44
+ - **Before.** The filesystem-resolving pass behind both shell faces (`createCanonicalReadBoundary`) decided whether to resolve a candidate at all from the env's INTERFACE SHAPE — the five-member structural check `isRemoteExecutionEnv` — on the ground that a remote env's `canonicalPath` is an RPC into a filesystem this process does not host. An adapter that drives an ORDINARY MACHINE implements that same seam: a host lane or an SSH host has a workspace handle, a stream, a lifecycle, and declares `capabilities.isolation: false`. Every such deployment read as "the sandbox is the boundary", so the whole leg short-circuited — `escapesAfterResolution` reported nothing escaping, `canonicalOf` `undefined`, `allResolveInside` `false`. Measured on the real load path with the env wearing that interface over a real filesystem, under the default doctrine (`shellGate` absent) AND `"classify"`: with `<root>/vendor` → an outside directory, `cat vendor/secret`, `cat vendor/*` and `cat vendor/../beside.txt` were all asks=0, isError=false, and the outside bytes reached the model record — the three shapes 7.17.5/7.17.6 had just closed everywhere else. The engine's own pins only ever exercised the leg on an in-process env, which is not remote-shaped, so they were green and said nothing about that lane.
45
+ - **After (one question, asked once).** `boundaryIsTheSandbox(env)` (remote-env.ts) is the single predicate: containment is the environment's own job exactly where an isolated sandbox is DECLARED, and nowhere else. The boundary reads it, and so do BOTH disclosure sentences — the `bash_readonly` tool card's symlink clause and the shell-gate-off operator note's execute-leg clause — so a card can no longer promise a refusal the leg skips, or omit one it issues. Everything else resolves through that env's OWN `canonicalPath`, which on a host lane answers the very filesystem the command is about to read. The isolation flag is now read STRICTLY (`=== true`) at its one mint point (`isIsolated`): a value that is absent, or a non-boolean from an adapter the compiler never saw, is not a declaration and lands on the checking side. No new branch and no env-class special case — the same three call sites, a different question.
46
+ - **Behaviour, for consumers — the deployment that changes is the NON-ISOLATED one.** On an execution environment that implements the remote seam while declaring `capabilities.isolation: false` (or omitting it), reads whose target leaves the declared roots only after symlink resolution move from RUN to REFUSE: the literal spelling, a glob whose expansion lands outside, and a `..` taken after a link. That is the behaviour an in-process environment has had since 7.17.5, arriving on the lane that was silently exempt. **For a headless or auto-mode deployment on such a lane the card is a REFUSAL, not a prompt** — there is no approval step past execution — and the remedy is the one the refusal names: declare the link's target as a read root. The rescue half arrives with it: an aliased-root spelling that canonicalizes back inside is no longer refused there. An environment declaring `capabilities.isolation: true` is unchanged in every respect, including the two disclosure sentences, which stay silent about a refusal it does not issue.
47
+ - **Cost, stated rather than implied.** A non-isolated remote lane now pays its adapter's `canonicalPath` per judged candidate (0–2 calls per ordinary command, more for a pattern it must enumerate) where it previously paid none. That is the price of the boundary being enforced at all on that lane; an adapter whose resolution is expensive should say so by declaring isolation, which is also the statement that its sandbox carries the containment instead.
48
+ - **Stated non-goal.** An isolated sandbox still gets the lexical boundary: its containment is the sandbox, and the semantics of its `canonicalPath` — whose filesystem, whose namespace — are not something this engine has verified. Extending the pass there is a separate question, gated on that verification, not on this change.
49
+ - Pins: `test/backlog782-host-lane-canonical-recheck.test.ts` (13 pins on the real load path with an in-process env wearing the remote interface, so the filesystem under the boundary is real: the three escape shapes on both doctrines with the ask count asserted; the undeclared and non-boolean readings; the isolated lane's unchanged behaviour as the negative control; the in-process lane unchanged; the two disclosure sentences true together and false together on all three lanes; the rescue half answering on the non-isolated lane — five mutation directions measured in the header).
50
+
51
+ ## 7.17.5 — 2026-09-14
52
+
53
+ ### Narrowing — two reads the boundary walk vouched for without seeing: operands that arrive on a stream, and a directory change that did not happen (#766, #767; @server @cli @test)
54
+ - **#766 — before.** A program that turns its STANDARD INPUT into another program's OPERANDS (`xargs`, GNU `parallel`) sat on the launcher table, so the walk unwrapped it to the program it runs and judged the operands that program was WRITTEN with — while its real argv arrives on a stream no token of the walk ever sees. Measured on the real load path (Runner prepare → NodeExecutionEnv → the gate) under the default doctrine AND `"classify"`: `echo <outside> | xargs cat`, `xargs cat <<< <outside>`, `xargs cat <<'EOF'…<outside>…EOF` and `echo .ssh/id_rsa | xargs cat` were asks=0 and EXECUTED — an out-of-root read and a deny-row read with no card at all — while the same read spelled `cat <outside>` is one mandated ask and the off tier's own disclosure promises exactly that ("a deny-listed or out-of-root read asks; the ask is mandated").
55
+ - **#766 — after (the re-entry doctrine, one level down; no new rule).** A shell re-entry program (`sh -c …`, `eval …`) is refused whole because what it reads is decided by TEXT this walk never parses. An ARGV re-entry program is refused whole because what the program it runs reads is decided by a STREAM this walk never sees. Same class, same answer, one predicate (`argvReentryUnjudged`) read by both sites that can answer for it — the walk's launcher branch and the refused-command whitelist — so the two cannot say different things. The exemption is the walk's existing one rather than a second rule: a program DECLARED to read no path (`echo`, `true`, `basename`, …) does not open its words whatever stream they arrived on, so `ls | xargs echo` stays silent, and a bare `xargs` runs `echo` and is exempt by the same reading. The CHAIN is what is read, not the first word, and it is read to its END: `env xargs cat` and `timeout 5 xargs cat` are the same command as `xargs cat`, and a chain that ENDS on another launcher (`xargs env`, `xargs sudo`, `xargs timeout 5`) names no program at all — reading that as the default `echo` vouched for `echo cat <outside> | xargs env`, where `env` runs whatever the stream names, so the default-program exemption holds only when nothing follows the re-entry.
56
+ - **#766 — behaviour, for consumers.** Under BOTH doctrines a command that feeds another program's argv from a stream now asks ONCE, mandated (a stored allow rule is disclosed as shadowed and does not clear it), unless the program it runs is one declared to read no path. **Under the default doctrine these ran with ZERO asks before**, so on a headless deployment — no approver wired — the card is a REFUSAL, not a prompt: `ls | xargs cat`, `git ls-files | xargs grep -l TODO`, `cat list.txt | xargs cat`, `ls | parallel cat`, `xargs cat < list.txt`, `env xargs cat`, `ls | xargs env`, `ls | xargs nice`. **Stated over-fire, pinned rather than described:** a stream-fed program reads MORE files than the ones written beside it, so `xargs cat <in-root file>` and `find . -name x | xargs wc -l` are mandated asks even though every path in the text is inside the roots — telling "stdin is empty here" from "stdin carries a path" is exactly the fact the text does not contain. The floor is unchanged and pinned: `ls | xargs echo`, `echo a | xargs echo`, `ls | xargs true`, and every command with no stream re-entry. The literal-operand form `xargs cat <outside>` was already one mandated ask and still is — its verdict now carries the refusal sentence rather than `outOfRootPaths`, so no "allow reading from …" directory grant is offered for it.
57
+ - **#767 — before.** The walk threaded a `cd` as if it had SUCCEEDED. It is lexical and holds no filesystem, so it cannot know the directory exists; bash, when `cd` fails, leaves the process standing where it was and runs the rest of the command from there. Measured on the same path, both doctrines: `cd <missing>; cat ../secret` and `cd <missing> || cat ../secret` were asks=0 and EXECUTED — the walk placed `../secret` under `<root>/<missing>`, read it as the in-root `<root>/secret`, and vouched, while bash resolves it to `/secret`, outside every root. The `||` spelling runs the read ONLY when the change failed, i.e. against the one base the walk could not be using.
58
+ - **#767 — after (a value becomes a set; no new rule, and no I/O).** The base a segment's relative operands resolve against is every directory the shell could be standing in, and any one of them outside the roots or on the deny table is the answer — the same fail-closed direction the walk already takes for an operand it cannot resolve at all. **`&&` is the one connector that closes the gap and it closes it exactly:** bash runs the right-hand side only after the left-hand side exited zero, so a segment reached from a `cd` through an unbroken run of `&&` is the shell's own proof the change happened, and the pre-move base is not a base that segment can be in. The verification is per CHANGE, not per command (`cd a && cd b; cat ../x` has its first change proven and its second not), and a run broken by anything else — `;`, `||`, `|`, a segment the walk dropped — gives the proof back. Connector information that is missing entirely is read as broken, which is the fail-closed side: the candidate set can only widen, never narrow. A candidate set that outgrows its cap is answered as an unresolvable directory change — the reading the walk already had for a `cd` it cannot track — so the seats keep one meaning of "the boundary did not judge this".
59
+ - **#766 — what looks like a member and is not.** GNU `parallel` does not exec the program beside it with the stream's words appended: it BUILDS a command line and hands it to a shell, and given no command word at all it runs each input LINE as a command. Both halves of this class's exemption fail there — measured, `echo <program> <path> | parallel` runs the line and `parallel echo "…; <reader> <path>; echo"` runs the `;` pieces — so `parallel` left the launcher table for the SHELL re-entry class, where it is refused whole with no exemption and nothing looks through it. @server @cli: `parallel …` in any spelling is a mandated ask on both tiers.
60
+ - **#767 — behaviour, for consumers.** Under BOTH doctrines a relative read placed after a directory change the shell did not prove now asks ONCE, mandated, when either reading leaves the roots or lands on the deny table: `cd <dir>; cat ../secret`, `cd <dir> || cat ../secret`, `cd <dir> && ls; cat ../secret`, `cd <dir> | cat ../x`. **Measured against a 55-command everyday corpus, no other cell moves** — `cd sub && npm test`, `cd pkg && cat package.json`, `cd sub && cat ../x`, `cd packages/foo && cat ../bar/package.json`, `cd sub && cd .. && ls`, `cd sub; ls` all keep their zero-ask verdict, because either both readings land inside or the `&&` run proves the change. A change whose target the walk cannot resolve at all (`cd -`, bare `cd`) is unchanged: the plain ask it always was.
61
+ - **What the `&&` proof does NOT prove, each measured as a live vouch before it was closed.** `&&` and `||` associate leftward with equal precedence, so `x || cd <dir> && <read>` is `(x || cd <dir>) && <read>`: the `&&` proves the status of the whole left LIST, which when `x` succeeds is `x`'s status — the change never ran. A run that BEGINS after a `||` therefore proves nothing about its first segment, while a change at any later position of the same run is still proven. Every element of a PIPELINE runs in a subshell, so `echo hi | cd sub && <read>` leaves the parent standing exactly where it started however well the change went. A control word in front of the change hides or inverts the status the run reads (`! cd <dir>` exits zero precisely when the change FAILED). And an EXTERNAL `cd` — a path-prefixed spelling, or one behind a launcher — runs a program whose zero exit says nothing about this shell's directory at all, so the proof requires that the written word WAS the bare builtin. All three are now unprovable by construction, and the connector row the boundary reads carries the three facts (reached through `&&`, the crossing carried a `||`, the change can move the parent's base at all) rather than one boolean.
62
+ - **Cost, measured back to back on one machine and bounded.** The candidate set makes the walk's work candidates × operands, so the two shapes that carry many of both got slower and are stated rather than left to be discovered: a 120-beat poll-loop body with one directory change goes from 1.8 ms to 25 ms, and eight unverifiable changes followed by 128 reads from 0.6 ms to 56 ms. Both are the WORKED versions — the first naive cut of the same code measured 1.4 s and 13.4 s respectively, and two changes bound it: the finding accumulators dedupe through sets instead of linear scans, and a per-(segment words, base) memo collapses the poll-loop face's replicated body. An ordinary command is unmeasurable either way (0.0 ms before and after), and a candidate set that outgrows its cap stops the walk instead of growing.
63
+ - **Known residual, NOT closed here and filed separately:** a `sed` whose script is not the pure print form still carries its refusal as a SHAPE reason rather than a whole refusal, so under the default doctrine `sed -n '1r /etc/hosts' <file>` — and the `-e`, `-f` and no-`-n` spellings of the same script indirection — read a file outside the roots with zero asks. Closing it needs a decision about how much of the sed script grammar this walk admits, which is a design question rather than a site fix. **Also open, and stated rather than implied:** a spelling of the stdin-to-argv class this file has no name for (an aliased or renamed `xargs`) is judged as any other unlisted program — the name-table floor the shell re-entry class already records for itself; `CDPATH`, a `cd` through a symlink, and a directory change made by a shell function or a `DEBUG` trap are not modelled, all pre-existing and none touched here.
64
+ - Pins: `test/backlog766-argv-reentry.test.ts` (the real load path, two doctrines × three feeds × three wrapped programs including a synthetic name on no table, the deny half, the mandated-ask lane under an exact covering rule and under an armed-but-not-matching lane, the off-tier disclosure held against the same run, `parallel` as the class's second member, the launcher chain to its end including an absolute-path re-entry inside one, GNU `parallel` as the near-member that is refused as shell re-entry instead, the negative controls and the stated over-fire; six mutation directions measured in the header), `test/backlog767-cd-unverifiable-base.test.ts` (both connectors that leave a change unproven, the deny regression, the mandated-ask lane, the `&&` negative controls including the two everyday monorepo spellings, the broken-run cases, per-change verification, the three shapes the `&&` proof does not cover, and the unresolvable-target regression; five mutation directions measured in the header, one of them the fail-closed check that a missing connector row costs only negative controls).
65
+
66
+ ### Narrowing — the full shell's read boundary is re-judged on the FILESYSTEM before the command runs (#768; test [7178] ③; @server @cli @test)
67
+ - **Before.** The shell read boundary was LEXICAL containment: it judges the paths a command's TEXT may name, resolved against the declared roots, home and cwd. A symlink inside a root that points out of it therefore read as in-root — with `<root>/vendor` → an outside directory, `cat vendor/secret` was an in-root spelling, the boundary seat vouched it, ZERO asks were raised under every doctrine, and the outside bytes reached the model record. Measured on the real load path (Runner prepare → NodeExecutionEnv → the gate). The filesystem-resolving second pass that closes exactly this gap (`createCanonicalReadBoundary`) already existed and was wired on the `bash_readonly` face ALONE; the full face had no recheck at all, and no ticket tracked the asymmetry.
68
+ - **After (a leg reused, not a rule added).** The full `bash` face runs the SAME pass on its own execute path — after the gate allowed the call and before anything spawns, ahead of the `run_in_background` branch (the flag changes WHEN a command runs, never WHERE it reads). **Its scope is the whole design: it re-judges only what the boundary SILENTLY vouched** — the literal locations the walk resolved and placed INSIDE the roots (`checkedPaths`), which raise no question at any tier and are therefore the one class where nobody was ever asked. A target that canonicalizes outside the roots is REFUSED, never asked: execution is past the approval step, so there is nothing left to escalate to. A call ABORTED before the spawn starts nothing — `spawnBackground` takes no signal of its own, so the cancellation is checked once, last, immediately before it.
69
+ - **What the boundary ASKED about belongs to whoever answered it, and this leg does not revisit it.** A path the walk placed outside the roots (`outOfRootPaths`) is a mandated ask, and a person's yes on `cat <outside>/x` still runs. An unexpanded GLOB (`undecidedPaths`) is not judged here at all: it is an ask under every doctrine, so someone looked at "a pattern nobody could resolve" and said yes — expanding it afterwards and refusing what it matched would override that answer. Two consequences, both deliberate: the leg gives up the glob lane (`cat <link>/*`, and the same read piped anywhere, are NOT refused — disclosed, pinned, and the literal spelling of the identical read still is), and it loses the only refusals it had that needed no symlink at all (a dot pattern reaching `..`, an expansion past 256 directories / 8192 names, and a bracket construct the matcher cannot compile). The `bash_readonly` face keeps every one of those — its containment is load-bearing and it has no approval channel to have asked with.
70
+ - **A refusal earned by one segment does not disable the check on another.** Within its scope the leg does not read the verdict's sentence, and it takes BOTH read faces (the compound walk and the bounded read-only poll loop): `cat <link>/secret | jq .` and `cat <link>/secret ; :` are refused compounds whose `checkedPaths` still carry the reader's real operand. Reading the sentence first — "the face refused, so it vouched nothing" — left the everyday shape (a read piped into any program the reader table does not list) outside the check entirely, and made the whole leg one appended `; :` away from silence.
71
+ - **A path that does not EXIST is not a refusal.** The resolver takes the deepest existing ancestor and rejoins the missing tail, so `cat missing.txt` still gets the shell's own "No such file"; a chain that cannot be answered at all keeps the lexical verdict rather than manufacturing one.
72
+ - **Behaviour, for consumers.** On the full shell, a command whose operands the read boundary resolved inside the roots now RETURNS AN ERROR instead of running when one of them resolves through a symlink to a directory outside the session's read roots. The result carries the read-only face's own card (`details.type` / `code`: `readonly_out_of_root`, `paths` = the canonical targets), now declared on the full shell's catalog row: ONE code for both faces, because the finding is one finding and a second word would make a consumer's count depend on which shell was mounted. Unchanged and pinned: an in-root symlink, a plain in-root read, a link into a declared additional READ root, a command with no path operand, every glob, the OPEN read face (containment is structurally satisfied there, so an out-of-roots operand rides `checkedPaths` as an ACCEPTED one and re-judging it would make "open" stricter than "roots"), a REMOTE execution env (no local `realpath`; the same locality split the read-only face's tool card already discloses), and every library-direct mount that declares no boundary. The `bash_readonly` face is byte-unchanged in verdict and wording — it already ran this leg.
73
+ - **The over-fire, one shape and one premise.** The walk is program-blind, so a refused compound's `checkedPaths` include the argv WORDS of its non-reader segments (`make build` offers `<root>/build`, `npm test` offers `<root>/test`) and an option's separated value rides beside the operands (`grep -e vendor pkg.json` offers `<root>/vendor`); a read OF a link also counts as a read of its target (`readlink vendor`, `ls -d vendor`). Such a command is refused only where the workspace really holds an entry of that name that is a symlink OUT of the roots — **the condition is the point**: that same link already refuses every genuine read through it, so the deployment has to answer for it either way, and the answer is the same, DECLARE the target (`TaskSpec.additionalReadDirectories`), which the leg honours (measured: with the target declared, `make build` runs again). Telling an operand from an argv word needs a per-segment provenance fact the verdict does not carry, and inventing one here means a second hand-written parser beside the classifier's — the thing #753 exists to stop this file from growing. The refusal says the command NAMES the path, never that it reads it.
74
+ - **Also fixed, found by exposing it.** The resolver stripped a trailing separator before rejoining the missing tail, which turns a canonical FILESYSTEM ROOT into the empty string — inside no root. A deployment declaring `/` among its read roots therefore had `ls -d /` refused, with an empty path in the refusal's own text. With nothing to rejoin, the canonical answer is now returned unchanged. Pre-existing and equally live on `bash_readonly`; it only became reachable in practice when the full face started calling the same resolver.
75
+ - **Blind spots, disclosed and pinned as facts rather than assumed closed.** From the LEXICAL resolution that produces the candidates, all equally live on the read-only face: a `..` is collapsed in the TEXT before any link is resolved (`<link>/../x` is checked as `<root>/x`); a working-directory move is modelled as always succeeding (`cd nope || cat <link>/x` is checked under `<root>/nope/`); a command the GRAMMAR gate refuses whole yields no candidates at all. One is structural: a glob's ask can be retired by a stored allow rule or an auto tier, and this leg cannot tell that from a person's yes — `ToolExecuteContext` carries no record of how this call's gate settled, so both are left to the answerer. One is the ENVIRONMENT's: `ExecutionEnv.canonicalPath` takes a signal it is free to ignore (the built-in node env does), so a STALLED filesystem holds the recheck's await open and a cancellation cannot reach the spawn guard until it returns — a property of every caller of that resolver, filed against the adapter rather than raced around here.
76
+ - **API (additive, optional).** `HandsToolkitOptions.shellReadBoundary` and `createBashTool`'s `taskOpts.readBoundary` take the deployment's read boundary — the SAME declaration the gate's two shell probe seats read, so one closure now feeds three consumers and the two answers about one path cannot disagree. ABSENT ⇒ the leg does not run and costs no I/O (no boundary declared, no "outside" to be outside of — the reading the seats already take), and it deliberately does NOT fall back to the write side's `additionalRoots`, which would stand up a second root table that excludes the read roots and refuse reads the deployment sanctions.
77
+ - **Contract.** What this seat is and is not is now stated once, as `shell.read_boundary.friction_floor` on `bashReadBoundaryProbe`: the seat answers a question about TEXT; the containment for what a shell may reach is the `ExecutionEnv` the command runs in; the execute leg is the floor's last cell. Every other site links to it instead of restating it.
78
+ - Pins: `test/backlog768-bash-canonical-recheck.test.ts` (the real load path on both doctrines: the leak itself across reading verbs, compounds, the pipe-into-an-unlisted-program and `; :` shapes, the bounded poll loop and the background leg, with the wire card asserted on the frame; the read-root, in-root-symlink, plain-read, everyday-lower-bound, open-face and no-boundary negatives; the glob arm's deliberate silence beside the literal spelling that still refuses; the answered-question arm (an approved out-of-root read runs); the stated over-fire WITH its declare-the-target control; the blind spots as facts; two cancellation windows with a positive control on the spy; the `bash_readonly` face's own wording; the operator note; and the single-construction-site pins — eight mutation directions measured in the header).
79
+
3
80
  ## 7.17.4 — 2026-09-14
4
81
 
5
82
  ### Narrowing — an option's ATTACHED payload is a word like any other: the boundary walk judges it (#763; the residual filed at 7.17.3; @server @cli @test)
@@ -262,6 +262,23 @@ export interface HoldRow {
262
262
  resolvedAt?: number;
263
263
  }
264
264
  export declare function readHolds(controlDir: string): HoldRow[];
265
+ /** Where a plane file is staged while it is displaced: a SIBLING in the file's OWN directory.
266
+ * `rename` is atomic only WITHIN one filesystem, and the control plane is routinely a different mount
267
+ * from the memory dir — staging there surrenders the atomicity the rename-first law exists for, and a
268
+ * cross-device refusal is the ordinary shape of a two-volume deployment, not an exotic failure. A
269
+ * sibling is on the file's filesystem by construction, so one path serves both layouts. The staged
270
+ * name is never `.md`-suffixed; custody stays in the control plane ({@link retireStagingToCustody}). */
271
+ export declare function stagingPathBeside(absPath: string, stagingName: string): string;
272
+ /** Move whatever still sits at a staged generation into control-plane custody and report WHERE the
273
+ * bytes ended up. Withheld bytes belong in the control plane, never on the model-visible plane — but
274
+ * a copy that cannot be made there must leave the only copy where it is and SAY so, so the coordinate
275
+ * is the host's recovery address either way and `settled` answers one question: in custody, with
276
+ * nothing left beside the seat? Takes an ALREADY-CLAIMED path (read, decision and removal address
277
+ * one inode) and removes it only after {@link writeCustodyVerified} has proven the copy. */
278
+ export declare function retireStagingToCustody(claimedPath: string, custodyDir: string, stagingName: string): {
279
+ settled: boolean;
280
+ at: string;
281
+ };
265
282
  /** ATOMIC no-replace restore of a staged file onto a plane path (adversarial round 2: an
266
283
  * existsSync-then-rename pair is a TOCTOU — POSIX rename REPLACES a destination created between
267
284
  * the check and the call, clobbering a third writer's newer bytes). `link` refuses EEXIST
@@ -273,17 +290,18 @@ export declare function noReplaceRestore(stagingPath: string, destPath: string):
273
290
  * ① append row `status:"capturing"` (deterministic captureName + contentDigest minted from the
274
291
  * one read buffer) — the account exists before any byte moves;
275
292
  * ② write the custody file FROM THE SAME BUFFER, read it back, verify the digest;
276
- * ③ remove the plane file RENAME-FIRST (atomic move into `hold/<staging>`), then hash what
277
- * actually moved: equal ⇒ the staging duplicate is dropped (custody already holds verified
278
- * bytes); unequal ⇒ a third writer replaced the file between read and rename — NO-CLOBBER
279
- * rollback (the newer bytes go back ONLY if the plane path is still absent; a re-created path
280
- * keeps both copies, staging stays in custody and the caller hears "conflict" — never an
281
- * overwrite of newer legal bytes);
293
+ * ③ remove the plane file RENAME-FIRST (atomic move to a staged sibling in the file's OWN
294
+ * directory — {@link stagingPathBeside}), then hash what actually moved: equal ⇒ the staged
295
+ * duplicate is dropped (custody already holds verified bytes); unequal ⇒ a third writer replaced
296
+ * the file between read and rename — NO-CLOBBER rollback (the newer bytes go back ONLY if the
297
+ * plane path is still absent; a re-created path keeps both copies, the staged bytes retire into
298
+ * control-plane custody and the caller hears "conflict" — never an overwrite of newer legal bytes);
282
299
  * ④ flip the row to `"held"`.
283
300
  * Every failure path returns a structured outcome (never throws past the row append): the caller
284
301
  * fail-closes the file through the ordinary quarantine containment instead — an instruction file
285
- * must never stay on the plane because its hold failed. EXDEV on the rename is one such refusal
286
- * (cross-filesystem staging gives up rename atomicity — refused, not degraded to copy+delete).
302
+ * must never stay on the plane because its hold failed. A directory that refuses the move (read-only
303
+ * plane, revoked permission) is one such refusal: the file would stay on the plane, the one outcome
304
+ * a hold may not produce.
287
305
  */
288
306
  export declare function openInstructionHold(controlDir: string, input: {
289
307
  sessionId: string;
@@ -301,7 +319,7 @@ export declare function openInstructionHold(controlDir: string, input: {
301
319
  }): {
302
320
  ok: true;
303
321
  holdId: string;
304
- thirdWriterStranded?: string;
322
+ thirdWriterStrandedAt?: string;
305
323
  } | {
306
324
  ok: false;
307
325
  holdId?: string;
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, linkSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { closeSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
4
  import { QUARANTINE_DIR, appendChallengeEvents, enqueueMemoryAnnouncement, ensureDirExists, v1MemoryPathKeyResolver, lineageContributionsOfSession, lockedStrictUpdate, markSessionPolluted, readProjectionDebts, readSessionPollution, readStrictSidecar, ControlPlaneCorruptError } from "./layout.js";
5
5
  export const DELEGATION_SETTLEMENT_FILE = "delegation-settlement.json";
6
6
  function reqStr(v) {
@@ -457,6 +457,66 @@ function uniqueStagingName(holdId, now) {
457
457
  stagingSeq += 1;
458
458
  return `${holdId}.staging-${now()}-${process.pid}-${stagingSeq}`;
459
459
  }
460
+ function stagingGenerationPattern(holdId) {
461
+ return new RegExp(`^${holdId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.staging-\\d+-\\d+-\\d+(?:\\.claim-\\d+-\\d+)*$`);
462
+ }
463
+ function claimStaging(stagingPath) {
464
+ stagingSeq += 1;
465
+ const claimed = `${stagingPath}.claim-${process.pid}-${stagingSeq}`;
466
+ try {
467
+ renameSync(stagingPath, claimed);
468
+ return claimed;
469
+ }
470
+ catch {
471
+ return stagingPath;
472
+ }
473
+ }
474
+ function dropStaging(claimedPath) {
475
+ try {
476
+ unlinkSync(claimedPath);
477
+ }
478
+ catch {
479
+ }
480
+ }
481
+ export function stagingPathBeside(absPath, stagingName) {
482
+ return join(dirname(absPath), stagingName);
483
+ }
484
+ function writeCustodyVerified(dest, bytes) {
485
+ try {
486
+ const fd = openSync(dest, "wx", 0o600);
487
+ try {
488
+ writeFileSync(fd, bytes);
489
+ fsyncSync(fd);
490
+ }
491
+ finally {
492
+ closeSync(fd);
493
+ }
494
+ return sha256(readFileSync(dest)) === sha256(bytes);
495
+ }
496
+ catch {
497
+ return false;
498
+ }
499
+ }
500
+ export function retireStagingToCustody(claimedPath, custodyDir, stagingName) {
501
+ const unsettled = (why) => ({ settled: false, at: `${claimedPath} (${why})` });
502
+ let bytes;
503
+ try {
504
+ bytes = readFileSync(claimedPath);
505
+ ensureDirExists(custodyDir);
506
+ }
507
+ catch {
508
+ return unsettled("the staged bytes could not be read into the control plane");
509
+ }
510
+ if (!writeCustodyVerified(join(custodyDir, stagingName), bytes))
511
+ return unsettled("the control-plane custody copy could not be written");
512
+ try {
513
+ unlinkSync(claimedPath);
514
+ }
515
+ catch {
516
+ return unsettled("a copy reached the control plane; this one could not be removed");
517
+ }
518
+ return { settled: true, at: `${basename(custodyDir)}/${stagingName} in the memory control plane` };
519
+ }
460
520
  export function noReplaceRestore(stagingPath, destPath) {
461
521
  try {
462
522
  linkSync(stagingPath, destPath);
@@ -491,7 +551,9 @@ export function openInstructionHold(controlDir, input) {
491
551
  const digest = sha256(buf);
492
552
  const captureName = `${holdId}.md`;
493
553
  const holdDir = join(controlDir, HOLD_DIR);
494
- const refuse = (reason, terminal) => {
554
+ let thirdWriterStrandedAt;
555
+ const refuse = (rawReason, terminal) => {
556
+ const reason = thirdWriterStrandedAt === undefined ? rawReason : `${rawReason}; a concurrent writer's bytes are preserved at ${thirdWriterStrandedAt}`;
495
557
  try {
496
558
  disposeHold(controlDir, { holdId, terminal, now: input.now });
497
559
  }
@@ -526,17 +588,15 @@ export function openInstructionHold(controlDir, input) {
526
588
  }
527
589
  try {
528
590
  ensureDirExists(holdDir);
529
- writeFileSync(join(holdDir, captureName), buf, { flag: "wx" });
530
- const back = readFileSync(join(holdDir, captureName));
531
- if (sha256(back) !== digest)
532
- return refuse("hold capture verification failed (written bytes do not hash to the captured digest)", "capture_lost");
533
591
  }
534
592
  catch (err) {
535
593
  return refuse(`hold capture failed: ${err instanceof Error ? err.message : String(err)}`, "capture_lost");
536
594
  }
595
+ if (!writeCustodyVerified(join(holdDir, captureName), buf)) {
596
+ return refuse("hold capture verification failed (the custody copy could not be written, made durable, or hashed back to the captured digest)", "capture_lost");
597
+ }
537
598
  const stagingName = uniqueStagingName(holdId, input.now);
538
- const stagingPath = join(holdDir, stagingName);
539
- let thirdWriterStranded;
599
+ const stagingPath = stagingPathBeside(input.absPath, stagingName);
540
600
  try {
541
601
  renameSync(input.absPath, stagingPath);
542
602
  }
@@ -548,18 +608,18 @@ export function openInstructionHold(controlDir, input) {
548
608
  return refuse(`hold removal failed (${code ?? "rename error"}): the plane file was not moved`, "discarded");
549
609
  }
550
610
  }
551
- if (existsSync(stagingPath)) {
552
- const moved = readFileSync(stagingPath);
611
+ const claimedPath = existsSync(stagingPath) ? claimStaging(stagingPath) : stagingPath;
612
+ if (existsSync(claimedPath)) {
613
+ const moved = readFileSync(claimedPath);
553
614
  if (sha256(moved) === digest) {
554
- try {
555
- unlinkSync(stagingPath);
556
- }
557
- catch {
558
- }
615
+ dropStaging(claimedPath);
559
616
  }
560
617
  else {
561
- if (noReplaceRestore(stagingPath, input.absPath) === "stranded") {
562
- thirdWriterStranded = stagingName;
618
+ const restore = noReplaceRestore(claimedPath, input.absPath);
619
+ if (restore !== "restored") {
620
+ const retired = retireStagingToCustody(claimedPath, holdDir, stagingName);
621
+ if (restore === "stranded" || !retired.settled)
622
+ thirdWriterStrandedAt = retired.at;
563
623
  }
564
624
  }
565
625
  }
@@ -575,28 +635,96 @@ export function openInstructionHold(controlDir, input) {
575
635
  catch (err) {
576
636
  return refuse(`hold ledger flip refused: ${err instanceof Error ? err.message : String(err)}`, "discarded");
577
637
  }
578
- return { ok: true, holdId, ...(thirdWriterStranded !== undefined ? { thirdWriterStranded } : {}) };
638
+ return { ok: true, holdId, ...(thirdWriterStrandedAt !== undefined ? { thirdWriterStrandedAt } : {}) };
579
639
  }
580
640
  export function reconcileHolds(controlDir, input) {
581
641
  const holdDir = join(controlDir, HOLD_DIR);
582
- const discloseStranded = (holdId, rel, stagingName) => {
642
+ const discloseStranded = (holdId, rel, at) => {
583
643
  try {
584
644
  enqueueMemoryAnnouncement(controlDir, {
585
645
  kind: "gate",
586
646
  at: input.now(),
587
- items: [`instruction hold ${holdId}: a concurrent writer's bytes for ${JSON.stringify(rel)} could not be restored onto the plane — they are preserved at hold/${stagingName} in the memory control plane for host recovery`],
647
+ items: [`instruction hold ${holdId}: a concurrent writer's bytes for ${JSON.stringify(rel)} could not be restored onto the plane — they are preserved at ${at} for host recovery`],
588
648
  });
589
649
  }
590
650
  catch {
591
- console.warn(`[sema] instruction hold ${holdId}: concurrent-writer bytes preserved at hold/${stagingName} could not be restored or announced`);
651
+ console.warn(`[sema] instruction hold ${holdId}: concurrent-writer bytes preserved at ${at} could not be restored or announced`);
652
+ }
653
+ };
654
+ const finishRemoval = (row, plane) => {
655
+ const stagingName = uniqueStagingName(row.holdId, input.now);
656
+ renameSync(plane, stagingPathBeside(plane, stagingName));
657
+ const staging = claimStaging(stagingPathBeside(plane, stagingName));
658
+ if (sha256(readFileSync(staging)) === row.contentDigest) {
659
+ dropStaging(staging);
660
+ return;
661
+ }
662
+ const restore = noReplaceRestore(staging, plane);
663
+ if (restore === "restored")
664
+ return;
665
+ const retired = retireStagingToCustody(staging, holdDir, stagingName);
666
+ if (restore === "stranded" || !retired.settled)
667
+ discloseStranded(row.holdId, row.rel, retired.at);
668
+ };
669
+ const seatListing = new Map();
670
+ const listSeat = (seatDir) => {
671
+ const cached = seatListing.get(seatDir);
672
+ if (cached !== undefined)
673
+ return cached;
674
+ let names;
675
+ try {
676
+ names = readdirSync(seatDir);
677
+ }
678
+ catch {
679
+ names = [];
680
+ }
681
+ seatListing.set(seatDir, names);
682
+ return names;
683
+ };
684
+ const sweepStagingGenerations = (row, plane, custody) => {
685
+ const seatDir = dirname(plane);
686
+ const pattern = stagingGenerationPattern(row.holdId);
687
+ for (const name of listSeat(seatDir)) {
688
+ if (!pattern.test(name))
689
+ continue;
690
+ const staged = claimStaging(join(seatDir, name));
691
+ let bytes;
692
+ try {
693
+ if (!lstatSync(staged).isFile())
694
+ continue;
695
+ bytes = readFileSync(staged);
696
+ }
697
+ catch {
698
+ continue;
699
+ }
700
+ if (sha256(bytes) === row.contentDigest) {
701
+ if (custody === "holds" || (custody === "absent" && writeCustodyVerified(join(holdDir, row.captureName), bytes))) {
702
+ dropStaging(staged);
703
+ continue;
704
+ }
705
+ }
706
+ discloseStranded(row.holdId, row.rel, retireStagingToCustody(staged, holdDir, name).at);
592
707
  }
593
708
  };
594
709
  const rows = readHolds(controlDir).filter((r) => r.status === "capturing");
595
710
  for (const row of rows) {
596
711
  const custody = join(holdDir, row.captureName);
712
+ let custodyBytes;
713
+ try {
714
+ custodyBytes = readFileSync(custody);
715
+ }
716
+ catch {
717
+ custodyBytes = undefined;
718
+ }
719
+ const custodyState = custodyBytes === undefined ? "absent" : sha256(custodyBytes) === row.contentDigest ? "holds" : "mismatch";
720
+ try {
721
+ sweepStagingGenerations(row, join(input.homeOf(row.scope), row.rel), custodyState);
722
+ }
723
+ catch {
724
+ }
597
725
  let terminalLost = false;
598
726
  if (existsSync(custody)) {
599
- const bytes = readFileSync(custody);
727
+ const bytes = custodyBytes ?? readFileSync(custody);
600
728
  if (sha256(bytes) !== row.contentDigest) {
601
729
  terminalLost = true;
602
730
  }
@@ -604,15 +732,7 @@ export function reconcileHolds(controlDir, input) {
604
732
  const plane = join(input.homeOf(row.scope), row.rel);
605
733
  if (existsSync(plane)) {
606
734
  try {
607
- const stagingName = uniqueStagingName(row.holdId, input.now);
608
- const staging = join(holdDir, stagingName);
609
- renameSync(plane, staging);
610
- if (sha256(readFileSync(staging)) === row.contentDigest) {
611
- unlinkSync(staging);
612
- }
613
- else if (noReplaceRestore(staging, plane) === "stranded") {
614
- discloseStranded(row.holdId, row.rel, stagingName);
615
- }
735
+ finishRemoval(row, plane);
616
736
  }
617
737
  catch {
618
738
  }
@@ -626,20 +746,9 @@ export function reconcileHolds(controlDir, input) {
626
746
  if (sha256(bytes) === row.contentDigest) {
627
747
  try {
628
748
  ensureDirExists(holdDir);
629
- writeFileSync(custody, bytes, { flag: "wx" });
630
- const stagingName = uniqueStagingName(row.holdId, input.now);
631
- const staging = join(holdDir, stagingName);
632
- renameSync(plane, staging);
633
- if (sha256(readFileSync(staging)) === row.contentDigest) {
634
- try {
635
- unlinkSync(staging);
636
- }
637
- catch {
638
- }
639
- }
640
- else if (noReplaceRestore(staging, plane) === "stranded") {
641
- discloseStranded(row.holdId, row.rel, stagingName);
642
- }
749
+ if (!writeCustodyVerified(custody, bytes))
750
+ throw new Error("the custody copy could not be written or made durable");
751
+ finishRemoval(row, plane);
643
752
  }
644
753
  catch {
645
754
  terminalLost = true;
@@ -11,7 +11,7 @@ import { MEMORY_PROVENANCE_RECALL_SENTENCE, memoryExposureIndexRow, parseMemoryE
11
11
  import { isInstructionEntry } from "./header-hints.js";
12
12
  import { ConsolidationRefusedError, CONSOLIDATION_FORCE_THROTTLE_FLOOR_MS, CONSOLIDATION_SEAT_FREEZE_GRACE_MS, consolidationTypeEligible, deriveProductSlug, deriveSupersededSet, isValidConsolidationPlanId, listConsolidationPlanIds, productAddPatch, quarantineCorruptPlan, readConsolidationGateFile, readConsolidationPlan, readIntentCredentials, recordActiveSetBaseline, recordConsolidationSession, recordIntentCredentials, scanRefusalCeiling, screenConsolidationOptions, supersessionFuseCeiling, updateConsolidationGate, writeConsolidationPlan, } from "./consolidation.js";
13
13
  import { classifySessionSettlements, clearHoldResolution, closeSessionAccount, disposeHold, effectiveSettlements, expireOverdueSettlements, foreignDanglingSessionAccounts, markHoldReleased, openInstructionHold, openSessionAccount, readHoldCustody, readHolds, reconcileHolds, replayExternalSettlementEffects, resolveHoldRecord, resolveSessionAccountRecord, resolveSettlementRecord, sessionSettlements, sessionUnattributedSet, emptyUnattributedSet, convertV1SidecarKeys, } from "./delegation-settlement.js";
14
- import { noReplaceRestore } from "./delegation-settlement.js";
14
+ import { noReplaceRestore, retireStagingToCustody, stagingPathBeside } from "./delegation-settlement.js";
15
15
  import { markOriginClearanceTombstoned, openOriginClearance, readOriginClearances, settleOriginClearance } from "./origin-clearance.js";
16
16
  import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, canonicalJsonStringify, captureErasureInput, erasureRequestInvalid, erasureSelectHash, scanEntryFiles, } from "./file-backend.js";
17
17
  import { assembleMemoryExportBundle, computeMemoryBundleHash, memoryBundleInvalid, } from "./export-bundle.js";
@@ -1226,7 +1226,8 @@ export class MemoryEngine {
1226
1226
  }
1227
1227
  let restoredCommitted = false;
1228
1228
  if (captured) {
1229
- const stagingPath = `${capturePath}.staging`;
1229
+ const stagingName = `${captureName}.staging`;
1230
+ const stagingPath = stagingPathBeside(canonical, stagingName);
1230
1231
  try {
1231
1232
  renameSync(canonical, stagingPath);
1232
1233
  if (readFileSync(stagingPath, "utf8") === text) {
@@ -1238,8 +1239,13 @@ export class MemoryEngine {
1238
1239
  catch {
1239
1240
  }
1240
1241
  }
1241
- else if (noReplaceRestore(stagingPath, canonical) === "stranded") {
1242
- this.discloseSessionAccountIncident(`a concurrent write to ${rel} landed during residue containment and could not be restored — its bytes are preserved at quarantine/${captureName}.staging`, undefined);
1242
+ else {
1243
+ const restore = noReplaceRestore(stagingPath, canonical);
1244
+ if (restore !== "restored") {
1245
+ const retired = retireStagingToCustody(stagingPath, join(this.controlDir, QUARANTINE_DIR), stagingName);
1246
+ if (restore === "stranded" || !retired.settled)
1247
+ this.discloseSessionAccountIncident(`a concurrent write to ${rel} landed during residue containment and could not be restored — its bytes are preserved at ${retired.at}`, undefined);
1248
+ }
1243
1249
  }
1244
1250
  }
1245
1251
  catch {
@@ -2082,8 +2088,8 @@ export class MemoryEngine {
2082
2088
  });
2083
2089
  if (held.ok) {
2084
2090
  report.warnings.push(`${rel}: instruction-form entry HELD — this session's delegation outcome is still pending (attested-only evidence standard); the file was captured off the model-visible plane and commits automatically when every delegation settles clean, disposes to quarantine on a dirty or expired settlement (resolveHold is the host valve)`);
2085
- if (held.thirdWriterStranded !== undefined) {
2086
- report.warnings.push(`${rel}: a concurrent writer's bytes could not be restored onto the plane while the hold was captured — they are preserved at hold/${held.thirdWriterStranded} in the memory control plane for host recovery`);
2091
+ if (held.thirdWriterStrandedAt !== undefined) {
2092
+ report.warnings.push(`${rel}: a concurrent writer's bytes could not be restored onto the plane while the hold was captured — they are preserved at ${held.thirdWriterStrandedAt} for host recovery`);
2087
2093
  }
2088
2094
  signalContainment((c) => pushDistinct(c.heldInstruction, rel));
2089
2095
  if (committedIdHere !== undefined) {
@@ -524,6 +524,28 @@ export declare function isSuspendable(env: ExecutionEnv): env is RemoteExecution
524
524
  * Is this env an isolated sandbox (design/61 §9 A, design/53)? `false` for a real SSH host / ADB device AND
525
525
  * for any in-process env (the host process is not isolated). The orchestrator tightens autonomy + the
526
526
  * design/37 policy gate when this is `false` (zero-trust on an un-isolated target). The safe default is
527
- * `false` — isolation must be explicitly declared, never assumed.
527
+ * `false` — isolation must be explicitly declared, never assumed, and that is why the flag is read
528
+ * STRICTLY: every consumer of a `true` here RELAXES something (autonomy, a policy tier, a containment
529
+ * leg that the sandbox is then answerable for), so a value that is not the boolean `true` — absent,
530
+ * a string, a truthy object from a JS adapter the compiler never saw — is not a declaration and lands
531
+ * on the checking side. This is the reading every consumer of the containment question comes through,
532
+ * and the only one that also NARROWS; a caller already holding a `RemoteExecutionEnv` may spell the
533
+ * same strict `!== true` test itself (the worktree-isolation refusal in the runner's prepare does).
534
+ * What no caller may do is infer isolation from the remote INTERFACE — implementing the seam is not
535
+ * declaring containment, and a host-lane adapter drives the operator's own machine through it.
528
536
  */
529
537
  export declare function isIsolated(env: ExecutionEnv): boolean;
538
+ /**
539
+ * Is CONTAINMENT this env's own job rather than the engine's — i.e. may a containment leg stop at the
540
+ * written spelling and leave the rest to the target? True exactly for a DECLARED isolated sandbox: the
541
+ * sandbox is the security boundary there, and its filesystem is not the one this process can resolve
542
+ * (`canonicalPath` is an RPC into somebody else's namespace).
543
+ *
544
+ * The one question the `bash`/`bash_readonly` read boundary asks before deciding whether to re-resolve
545
+ * a candidate through the filesystem, and the one both faces' DISCLOSURE reads, so a card can never
546
+ * promise a refusal the leg does not issue (or omit one it does). Deliberately NOT the remote INTERFACE:
547
+ * a non-isolated adapter (host lane, SSH host — see {@link RemoteExecutionEnv.destroy}'s contract note)
548
+ * implements the whole seam while driving an ordinary machine whose paths its own `canonicalPath`
549
+ * answers exactly, and reading the shape as containment made that deployment's boundary lexical-only.
550
+ */
551
+ export declare function boundaryIsTheSandbox(env: ExecutionEnv): boolean;