@sema-agent/core 7.17.5 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
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
+
3
51
  ## 7.17.5 — 2026-09-14
4
52
 
5
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)
@@ -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;
@@ -40,5 +40,8 @@ export function isSuspendable(env) {
40
40
  return isRemoteExecutionEnv(env) && env.capabilities.suspendable && missingRestoreSurface(env).length === 0;
41
41
  }
42
42
  export function isIsolated(env) {
43
- return isRemoteExecutionEnv(env) ? env.capabilities.isolation : false;
43
+ return isRemoteExecutionEnv(env) ? env.capabilities.isolation === true : false;
44
+ }
45
+ export function boundaryIsTheSandbox(env) {
46
+ return isIsolated(env);
44
47
  }
@@ -1,7 +1,7 @@
1
1
  import { emitTrace } from "../trace.js";
2
2
  import { StubExecutionEnv } from "../stub-env.js";
3
3
  import { hasBackgroundShell } from "../background-shell.js";
4
- import { isRemoteExecutionEnv } from "../remote-env.js";
4
+ import { boundaryIsTheSandbox } from "../remote-env.js";
5
5
  import { defaultTaskRegistry } from "../task-registry.js";
6
6
  import { bashReadBoundaryProbe, bashReversibilityProbe, compileReadDeny, createHandsToolkit, deploymentReadFaceClampNotice, pdfModelCapabilitiesOf, resolveReadDenyBuiltins, inheritSessionReadFileState, resolveReadFace, seedReadFileStateFromContext, seedReadFileStateFromTranscript, } from "../../tools/fs/index.js";
7
7
  import { resolveKey, sha256 } from "../../tools/fs/safety.js";
@@ -355,7 +355,7 @@ export async function prepareHandsMount(input) {
355
355
  if (!(executionEnv instanceof StubExecutionEnv) && spec.handsReadOnly !== true) {
356
356
  if (shellGate === "off") {
357
357
  deps.onError?.(new Error(`shell gate doctrine is "off" while a real writable shell (Bash) is mounted — the read boundary is ` +
358
- `still judged on every command (a deny-listed or out-of-root read asks; the ask is mandated${isRemoteExecutionEnv(executionEnv) ? "" : "; a read that only leaves the roots after symlink resolution is refused before it runs"}), but no ` +
358
+ `still judged on every command (a deny-listed or out-of-root read asks; the ask is mandated${boundaryIsTheSandbox(executionEnv) ? "" : "; a read that only leaves the roots after symlink resolution is refused before it runs"}), but no ` +
359
359
  `residual shell-risk classifier is installed: writes, egress and unknown commands are adjudicated by ` +
360
360
  `the ordinary policy/hook chain only. Set spec.shellGate to "classify" or "always" if this deployment ` +
361
361
  `expects doctrine-gated shell behavior.`), { phase: "config", sessionId, classification: "shell-gate-off" });
@@ -327,6 +327,20 @@ export interface CompoundReadonlyVerdict {
327
327
  * and miss the other.
328
328
  */
329
329
  undecidedPaths?: readonly string[];
330
+ /** The KERNEL spellings of the {@link checkedPaths} / {@link undecidedPaths} entries whose operand
331
+ * traverses `..`: the same absolute path, `..` UNCOLLAPSED ({@link OperandSpellings.spelled}). A fold
332
+ * names a different file whenever a parent step follows a symlink — `<root>/link/../x` is
333
+ * `dirname(realpath(link))/x` to the kernel, `<root>/x` to the text. Absent when nothing traverses
334
+ * `..`, and never a replacement: a resolving caller judges these BESIDE the folded entries, list for
335
+ * list. Containment, deny judgment and every `reason` here stay decided on the FOLDED form — this file
336
+ * is zero-I/O, and only a caller holding the filesystem can tell the two apart. */
337
+ checkedSpellings?: readonly string[];
338
+ /** The kernel spellings of {@link undecidedPaths} — see {@link checkedSpellings}. */
339
+ undecidedSpellings?: readonly string[];
340
+ /** The kernel spellings of {@link outOfRootPaths} — see {@link checkedSpellings}. A caller GRANTING
341
+ * anything off a resolved out-of-root path judges this beside it: the two can disagree about whether
342
+ * the target is back inside. */
343
+ outOfRootSpellings?: readonly string[];
330
344
  /**
331
345
  * Operands (written spellings) the walk could NOT resolve at all — a `~user` form, a `~` under no declared
332
346
  * home, an argument the shell rewrites (brace/variable/command expansion), a `cd` target it cannot follow
@@ -401,6 +415,20 @@ export declare function formatOutOfRootReadApprovalOption(directory: string): st
401
415
  * has no filesystem and stays lexical, and a REMOTE env keeps the lexical behaviour (its
402
416
  * `canonicalPath` is an RPC per candidate). */
403
417
  export declare function resolveOperandLexically(base: string | undefined, operand: string, homeDir: string | undefined): string | undefined;
418
+ /** One operand's two absolute spellings. `folded` has its `..` collapsed in the TEXT and is the
419
+ * containment answer — every root comparison, deny judgment and verdict field here is built from it.
420
+ * `spelled` keeps those segments, the walk the KERNEL performs: each prefix resolves (symlinks included)
421
+ * BEFORE a parent step applies, so `<root>/link/../x` names `dirname(realpath(link))/x`. Present ONLY
422
+ * when the operand traverses `..`; a filesystem-resolving caller judges it BESIDE the folded form. */
423
+ export interface OperandSpellings {
424
+ /** The `..`-collapsed absolute path — what containment is decided on. */
425
+ folded: string;
426
+ /** The uncollapsed absolute path, when the operand traverses `..`. */
427
+ spelled?: string;
428
+ }
429
+ /** Resolve one operand into {@link OperandSpellings}, or `undefined` on the same unresolvable operands
430
+ * {@link resolveOperandLexically} reports (it is this function's folded half). */
431
+ export declare function resolveOperandSpellings(base: string | undefined, operand: string, homeDir: string | undefined): OperandSpellings | undefined;
404
432
  /** Whether a command the grammar gate REFUSED WHOLE may READ a file the boundary never judged. The answer is the
405
433
  * complement of a WHITELIST: the command is exempt only when every part of it is a shape the scan fully understands
406
434
  * and none of those shapes reads a file outside the walk's reach — everything else is read evidence, so a token the