@sema-agent/core 7.17.5 → 7.17.7
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 +73 -0
- package/dist/core/memory-engine/delegation-settlement.d.ts +27 -9
- package/dist/core/memory-engine/delegation-settlement.js +155 -46
- package/dist/core/memory-engine/engine.js +12 -6
- package/dist/core/remote-env.d.ts +23 -1
- package/dist/core/remote-env.js +4 -1
- package/dist/core/runner/prepare-hands-readface.js +2 -2
- package/dist/tools/fs/bash-readonly-classifier.d.ts +28 -0
- package/dist/tools/fs/bash-readonly-classifier.js +678 -29
- package/dist/tools/fs/fs-bash.js +87 -21
- package/package.json +1 -1
- package/wire-changes/core@7.17.5.json +2 -2
- package/wire-changes/core@7.17.6.json +81 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.17.7 — 2026-09-15
|
|
4
|
+
|
|
5
|
+
### Narrowing — an INTERPRETER whose program is argument text: the argument-as-program family gains its fourth member (#791; @server @cli @test)
|
|
6
|
+
- **Before.** Three shapes were modelled: shell re-entry (`sh -c …` — the argument IS the command line), argv re-entry (`xargs cat` — the operands arrive on a stream) and a verb's script slot (`sed -n '1r <file>'`). The fourth was not. An interpreter's program can arrive as an option's value (`perl -e '…'`, `python3 -c '…'`, `node --eval '…'`), as the first operand (the awk family, whose program is mandatory unless `-f` names a file), or on STANDARD INPUT when the command line names none — and in each case the reads it performs are decided by text nobody parsed. An unparenthesized program is ONE folded token that is not path-shaped, so it fell through the ordinary operand scan and any absolute path inside it was never a candidate. Measured on the real load path under the default doctrine, each at zero asks and EXECUTED: `perl -e '…'`, `perl -pe '…' <file>`, `perl -ne '…' <file>`, `ruby -e '…'`, `php -r '…'`, `node -p '…'`, `node -pe '…'`, `<program> | tclsh`, `awk '{print}' <file>`, `awk 'NR==1' <file>`, `gawk '{print}' <file>`. Spellings carrying `(`/`)`/`$` (`python3 -c 'print(open("x").read())'`, `node -e '…readFileSync…'`) were already refused by the generic punctuation gate, which is why the defect looked narrower than it was.
|
|
7
|
+
- **After (one table, one mint point, the family's existing sentence).** A row per interpreter declares how it receives its program: the options whose value is program TEXT (`-e`, `-c`, `--eval`, `--run`, single-dash `-cmd`), the short letters that introduce it inside a bundle (`-pe`, `-lne`, `-uc`, `-0777e`), the options whose value NAMES the program (a script file, a module), the letters whose value is the rest of the token (so a bundle scan stops there rather than reading an `-e` out of a directory name), the letters that make THAT interpreter print and exit, which OPERAND holds the program and whether it is text or a file, and whether an invocation naming no program reads one from standard input. An invocation carrying program text is refused WHOLE with the family's template (`command "X" runs its argument as an interpreted program, which this walk does not judge — not auto-allowed`); one that names no program at all is refused with the second half of the same answer (`… reads the program it runs from its standard input …`). Twenty-six rows today — perl, ruby, python, node/nodejs, php, lua/luajit, tclsh, wish, expect, awk/gawk/mawk/nawk, jq, bc, dc, sqlite3, psql, osascript, Rscript, julia, ghci, groovy, bun — and a versioned spelling (`python3`, `python3.11`, `perl5.36`, `tclsh8.6`) is the same row.
|
|
8
|
+
- **Every doubt resolves in the REFUSING direction, and each way it can arise is declared.** A letter this file has no row for does NOT end a bundle scan (`perl -fe '…'` carries an `-e`), a DIGIT never ends one either (it is the numeric payload of the letter in front of it — `perl -0777e '…'`, `ruby -0e '…'`), a letter whose value can only be ATTACHED takes nothing from the next token (`perl -C -e '…'` runs the `-e`; reading `-e` as `-C`'s value swallowed it), a letter whose value IS the next token consumes it so it cannot be mistaken for an operand or for `--` (`perl -I -- -e '…'`; `python3 -W ignore` names no program and reads one from stdin), a lone `-` is the stdin marker wherever it stands including past `--` — except on a row whose operand is program text, where it is a DATA stream (`jq -f <filter> -`) — and a program-file option whose VALUE is `-` (`awk -f -`) is stdin rather than a file. An INITIALIZATION file is supplemental and does not name the program (`sqlite3 -init <file> <db> '<sql>'` still carries SQL, and without it still reads SQL from its input), an INTERACTIVE switch outranks both a banner and a named script (`lua -v -i`, `lua -i <script>`), and an interpreter whose operands are MODULES it loads never has one name the program (`ghci <module>` still reads commands from its input, and `ghci <module> -e '…'` still carries the `-e`).
|
|
9
|
+
- **Four rows the intake sweep filled in, each measured as a false refusal first.** A long option whose VALUE the row declares no longer turns the script behind it into a word of unknown arity (`node --import tsx app.ts`, `--loader`, `--experimental-loader`, `--require`, `--env-file`); a MODE that names what runs takes no value and reads no program from its input (`node --test`, and `--check` as a plain switch); a LINT switch leaves the file operand naming the program (`php -l <file>`); and the C shell family joins the shell re-entry spellings, since `csh -c '…'` and `tcsh -c '…'` run their argument exactly as `sh -c` does.
|
|
10
|
+
- **Where the interpreter stops reading its OWN options is declared too, in both directions.** Past the word that NAMES the program every remaining word belongs to that program — `node app.js -p 3000`, `python3 script.py -c config.json` and `python3 -m unittest -c` keep their old verdict — but only while every option behind it was one the row EXPLAINS. After an option of unknown arity the following word may be that option's value rather than the script, so the search for program text runs to the end instead: `ruby --encoding UTF-8 -e '…'` is the mandated ask it should be, while `node --inspect app.js` and `python3 -u script.py -c x` are untouched.
|
|
11
|
+
- **What is judged is PRESENCE, never CONTENT.** A quoted program is one word to this file's lexer and its contents are never parsed, so the only answerable question is "is there a program this walk did not read". `node -e 'console.log(1)'` therefore refuses with the rest: telling it apart from a program that opens a file needs a parser per language, which is the thing a lexical floor must not grow. This is the same doctrine that keeps the sed recognizer on sed's own closed grammar rather than on a character blacklist, and it is pinned in both directions.
|
|
12
|
+
- **Not this member, and pinned as such.** A program named as a FILE operand (`perl x.pl`, `python3 app.py`, `node app.js`, `ruby app.rb`, `tclsh script.tcl`, `awk -f prog.awk <file>`, `php -f page.php`) is a path the ordinary operand walk already judges and is untouched. So is a module (`python3 -m http.server`), an invocation that prints a banner and exits (`node --version`, `node -v`, `ruby -v`, `perl -V`, `python3 --version`, `php -v`, `lua -v`, `--help`), a fused option VALUE that merely contains a text letter (`perl -Ivendor script.pl`, `perl -i.bak script.pl`), and a text letter standing past POSIX end-of-options, where it is a FILE NAME (`perl -- -e`, `python3 -- -c`). A bundle is read left to right and STOPS at the first letter no row explains, because an option this file has no row for may consume the rest of the token.
|
|
13
|
+
- **Behaviour, for consumers, and the headless lane first.** Under BOTH doctrines an interpreter carrying an inline program now asks ONCE, mandated — a stored allow rule naming the whole command line is disclosed as shadowed and does not clear it. **Under the default doctrine these ran with ZERO asks before, so on a headless or auto-mode deployment — no approver wired — the card is a REFUSAL, not a prompt.** Under `"classify"` the same invocations already asked (no interpreter is on any reader table); what changes there is that the plain ask becomes a mandated one. The affected spellings are every one listed above plus the bare interpreter (`perl`, `ruby`, `python3`, `node`, `php`, `lua`, `tclsh`, `wish`, `expect`), an explicit stdin operand (`python3 -`), and an option-only invocation that is not a banner (`python3 -u`, `perl -w`).
|
|
14
|
+
- **Stated over-fire, measured rather than described — the awk family is where it lands.** Over a twenty-line corpus of everyday awk one-liners, SEVEN move from zero asks to one mandated ask: `awk '{print}' <file>`, `awk -F, '{print}' <file>`, `awk 'NR==1' <file>`, `awk 'NF' <file>`, `awk 'END{print NR}' <file>`, `gawk '{print}' <file>`, `mawk '{print}' <file>`. The other thirteen — every spelling whose program text carries `$`, `/` or a pipe, which is the overwhelming majority of real one-liners — were ALREADY mandated on both doctrines and are unchanged by this arm. awk is in the family because its program can read (`getline < "<file>"`), write (`print > "<file>"`) and spawn (`system(…)`), which is the hazard the family names; `awk -f prog.awk <file>` — the same work with the program in a file — is the unchanged spelling. Beyond awk the over-fire is the harmless inline program (`node -e 'console.log(1)'`), one approval each.
|
|
15
|
+
- **A third pass, the same root, and the point where it stops — stated rather than buried.** Three adversarial passes each found more spellings of one question ("where does this interpreter stop reading its own options, and what names its program"), and each was answered by declaring more per-row data: an attached-only value letter, an interactive switch, a supplemental initialization file, a module selector that ends option processing, a banner that owns its payload, a declared plain long option. The third pass answered the structural question directly, and the answer is a boundary rather than a bug: past the word that NAMES the program the remaining words are that program's own and are exempt — and a named module or script can define program-text slots of its own (`python3 -m timeit 0` compiles and runs `0`). No amount of per-interpreter option data reaches that class. Closing it means refusing every unmodelled argument tail, which would refuse `node app.js --port 3000` and `python3 script.py --flag` along with it — an adjudication about everyday interpreter invocations, not a fix, and it is left as one.
|
|
16
|
+
- **A second everyday line leaves the zero-ask floor with the jq row, and it is named rather than implied.** `jq -r .name pkg.json` and `jq . f` are mandated asks now: a jq filter is program text like any other, which is the family's rule read consistently. Measured and stated honestly in the other direction: unlike the rest of the table, jq's filter language has no general file-open and no command-execution builtin, so this row is doctrine consistency rather than a closed hazard. `jq -f <filter file> <data>` is the unchanged spelling.
|
|
17
|
+
- **Scope, honestly.** The table names the interpreters this file has a row for; one it does not — `deno` (its inline program is a SUBCOMMAND, `deno eval`, a shape this option/operand model does not express), `mono`, `dotnet`, `scala`, and anything else — is judged as any other unlisted program, exactly as before. Where a row's option grammar is incomplete the cost is one ask, not a silence, because an unknown option never ends a scan: `sqlite3 -init <file> <db>` counts the option's value as an operand and therefore refuses, and `perl -Mstrict <script>` refuses because perl's module argument can carry code. `psql`'s operands are connection arguments, so it is the one row where no operand ever names the program.
|
|
18
|
+
|
|
19
|
+
### Narrowing — ONE launcher option grammar, read by every reader that has to find the program word (#792; @server @cli @test)
|
|
20
|
+
- **Before.** `time -p xargs cat <<< <path>` was vouched under the default doctrine while `xargs cat <<< <path>` was mandated. The launcher option grammar — the one table that declares which tokens belong to a control word rather than naming the program behind it — was read by the boundary walk's control-word strip and by the single-command boundary face, but NOT by the refused-command whitelist's own head scan. There `-p` was read as the program word, the segment was exempted as an ordinary unlisted program, and the argv re-entry standing behind it was never asked about. The same silence covered `time -f %e xargs cat <<< <path>` and `time -o <file> xargs cat <<< <path>`.
|
|
21
|
+
- **After (a reuse point, not a second table).** The whitelist's head scan reads the same declared grammar, so all three program-word readers give one answer, and beneath it a floor the walk has always had: an option-shaped word is never read as a program name. A spelling the grammar deliberately does not carry — a FUSED long option (`time --format=%e …`), or an option of a launcher with no row at all (`nice -n 5 …`, `env -u X …`) — therefore leaves which program runs unreadable and is refused, which is the answer the boundary walk already gave for that condition. The readers that resolve a launcher CHAIN (`env time -p …`) deliberately still refuse rather than resolve: resolving there would turn a whole refusal into an ordinary walked segment, which is the widening direction, while every reader that does read the table only ever finds MORE program to judge.
|
|
22
|
+
- **Behaviour, for consumers.** `time -p xargs cat <<< <path>` and its valued spellings are now one mandated ask on both doctrines (a refusal on a headless deployment), as the bare `xargs` spelling already was; `time -p cd <dir>` is recognized as the directory change it is; and `time --format=%e cat <file>` — a spelling the boundary walk already called unreadable — now gets that answer from the whitelist too instead of being quietly exempted. Unchanged and pinned: `time -p ls`, `time -p cat <file>`, `time -f %e cat <file>`, `time -p echo hi`, `time -o <file> ls`.
|
|
23
|
+
### Narrowing — an APPLET DISPATCHER is a launcher, and the shell re-entry member is spelled by the shell (#791, the complement pass; @server @cli @test)
|
|
24
|
+
- **Before.** A multi-call binary runs the applet named in its first argument, and it was on no launcher table — so nobody unwrapped it, and the program text behind it folds into words that all resolve in root. Measured at ZERO asks under the default doctrine: `busybox sh -c "cat /etc/passwd"` and `busybox ash -c "…"`, while the bare `sh -c "cat /etc/passwd"` was mandated; `busybox awk '{print}' <file>` slipped the interpreter table the same way. Separately, three spellings of the shell itself were outside the shell re-entry pattern and vouched on their own: `ash -c "…"`, `mksh -c "…"`, `yash -c "…"`.
|
|
25
|
+
- **After.** The dispatcher joins the launcher table — it is already on the rule lane's own curated interpreter table, which is the list this file's launcher set is drawn from, so the pinned subset relation is unchanged — and the shell re-entry pattern spells the Almquist family and its descendants. `busybox cat <file>`, `busybox ls`, `busybox ls -la` and a bare `busybox` keep the verdict they had, and `busybox cat <outside>` is gated exactly as before. Stated over-fire: `busybox --help` puts an option where the applet name would be, which leaves what runs unreadable and refuses — one ask.
|
|
26
|
+
- Pins: `test/backlog791-interpreter-inline-program.test.ts` (174 cases — the real load path on both doctrines with a planted marker and its reverse control on all four delivery shapes, the mandated-ask lane under an exact covering rule, fifty-five inline-program spellings and twenty-five stdin ones pinned on the arm's OWN sentence, forty-one negative controls plus a twenty-nine-line everyday corpus, reachability at all three faces that judge a program's own words with the redirection words dropped from the argument list, the interpreter behind a control word / a launcher / a path prefix / an applet dispatcher, and the stated over-fire pinned rather than described; SIXTEEN mutation directions measured in the header, no two of which red the same set) and four new cases in `test/backlog766-argv-reentry.test.ts` (the control word's own option against the chain, and the everyday cells the grammar keeps).
|
|
27
|
+
|
|
28
|
+
## 7.17.6 — 2026-09-14
|
|
29
|
+
|
|
30
|
+
### 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)
|
|
31
|
+
- **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.
|
|
32
|
+
- **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.
|
|
33
|
+
- **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.
|
|
34
|
+
- **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.
|
|
35
|
+
- **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.
|
|
36
|
+
- **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`).
|
|
37
|
+
- **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.
|
|
38
|
+
- **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.
|
|
39
|
+
- **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.
|
|
40
|
+
- **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.
|
|
41
|
+
- **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.
|
|
42
|
+
- **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.
|
|
43
|
+
- **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.
|
|
44
|
+
- 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.
|
|
45
|
+
|
|
46
|
+
### 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)
|
|
47
|
+
- **#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.
|
|
48
|
+
- **#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.
|
|
49
|
+
- **#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.
|
|
50
|
+
- **#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.
|
|
51
|
+
- **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.
|
|
52
|
+
- **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.
|
|
53
|
+
- **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.
|
|
54
|
+
- **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.
|
|
55
|
+
- **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.
|
|
56
|
+
- 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.
|
|
57
|
+
### 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)
|
|
58
|
+
- **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.
|
|
59
|
+
- **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.
|
|
60
|
+
- **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.
|
|
61
|
+
- **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.
|
|
62
|
+
- **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.
|
|
63
|
+
- **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).
|
|
64
|
+
- **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.
|
|
65
|
+
- **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.
|
|
66
|
+
- 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.
|
|
67
|
+
|
|
68
|
+
### Narrowing — the execute-time read boundary asks what an environment DECLARES, not what interface it implements (#782/B-115; @server @cli @test)
|
|
69
|
+
- **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.
|
|
70
|
+
- **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.
|
|
71
|
+
- **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.
|
|
72
|
+
- **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.
|
|
73
|
+
- **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.
|
|
74
|
+
- 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).
|
|
75
|
+
|
|
3
76
|
## 7.17.5 — 2026-09-14
|
|
4
77
|
|
|
5
78
|
### 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
|
|
277
|
-
*
|
|
278
|
-
* bytes); unequal ⇒ a third writer replaced
|
|
279
|
-
* rollback (the newer bytes go back ONLY if the
|
|
280
|
-
*
|
|
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.
|
|
286
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
552
|
-
|
|
611
|
+
const claimedPath = existsSync(stagingPath) ? claimStaging(stagingPath) : stagingPath;
|
|
612
|
+
if (existsSync(claimedPath)) {
|
|
613
|
+
const moved = readFileSync(claimedPath);
|
|
553
614
|
if (sha256(moved) === digest) {
|
|
554
|
-
|
|
555
|
-
unlinkSync(stagingPath);
|
|
556
|
-
}
|
|
557
|
-
catch {
|
|
558
|
-
}
|
|
615
|
+
dropStaging(claimedPath);
|
|
559
616
|
}
|
|
560
617
|
else {
|
|
561
|
-
|
|
562
|
-
|
|
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, ...(
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
|
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
|
|
1242
|
-
|
|
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.
|
|
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
|
|
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;
|
package/dist/core/remote-env.js
CHANGED
|
@@ -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
|
}
|