@sema-agent/core 7.17.3 → 7.17.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/core/runner/prepare-hands-readface.js +10 -8
  3. package/dist/core/tool-catalog-entries.js +1 -1
  4. package/dist/tools/fs/bash-readonly-classifier.d.ts +18 -5
  5. package/dist/tools/fs/bash-readonly-classifier.js +194 -73
  6. package/dist/tools/fs/fs-bash.d.ts +28 -0
  7. package/dist/tools/fs/fs-bash.js +52 -10
  8. package/dist/tools/fs/index.d.ts +13 -0
  9. package/dist/tools/fs/index.js +1 -0
  10. package/package.json +3 -2
  11. package/wire-changes/core@7.10.0.json +739 -0
  12. package/wire-changes/core@7.11.0.json +685 -0
  13. package/wire-changes/core@7.11.1.json +112 -0
  14. package/wire-changes/core@7.11.2.json +73 -0
  15. package/wire-changes/core@7.12.0.json +356 -0
  16. package/wire-changes/core@7.13.0.json +199 -0
  17. package/wire-changes/core@7.14.0.json +578 -0
  18. package/wire-changes/core@7.15.0.json +174 -0
  19. package/wire-changes/core@7.16.0.json +132 -0
  20. package/wire-changes/core@7.17.0.json +1621 -0
  21. package/wire-changes/core@7.17.1.json +139 -0
  22. package/wire-changes/core@7.17.2.json +65 -0
  23. package/wire-changes/core@7.17.3.json +44 -0
  24. package/wire-changes/core@7.17.4.json +52 -0
  25. package/wire-changes/core@7.17.5.json +67 -0
  26. package/wire-changes/core@7.5.0.json +1063 -0
  27. package/wire-changes/core@7.5.1.json +252 -0
  28. package/wire-changes/core@7.5.2.json +418 -0
  29. package/wire-changes/core@7.6.0.json +1243 -0
  30. package/wire-changes/core@7.6.1.json +1130 -0
  31. package/wire-changes/core@7.7.0.json +509 -0
  32. package/wire-changes/core@7.8.0.json +1293 -0
  33. package/wire-changes/core@7.9.0.json +691 -0
  34. package/wire-changes/core@7.9.1.json +518 -0
  35. package/wire-changes/core@7.9.2.json +46 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.17.5 — 2026-09-14
4
+
5
+ ### 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)
6
+ - **#766 — before.** A program that turns its STANDARD INPUT into another program's OPERANDS (`xargs`, GNU `parallel`) sat on the launcher table, so the walk unwrapped it to the program it runs and judged the operands that program was WRITTEN with — while its real argv arrives on a stream no token of the walk ever sees. Measured on the real load path (Runner prepare → NodeExecutionEnv → the gate) under the default doctrine AND `"classify"`: `echo <outside> | xargs cat`, `xargs cat <<< <outside>`, `xargs cat <<'EOF'…<outside>…EOF` and `echo .ssh/id_rsa | xargs cat` were asks=0 and EXECUTED — an out-of-root read and a deny-row read with no card at all — while the same read spelled `cat <outside>` is one mandated ask and the off tier's own disclosure promises exactly that ("a deny-listed or out-of-root read asks; the ask is mandated").
7
+ - **#766 — after (the re-entry doctrine, one level down; no new rule).** A shell re-entry program (`sh -c …`, `eval …`) is refused whole because what it reads is decided by TEXT this walk never parses. An ARGV re-entry program is refused whole because what the program it runs reads is decided by a STREAM this walk never sees. Same class, same answer, one predicate (`argvReentryUnjudged`) read by both sites that can answer for it — the walk's launcher branch and the refused-command whitelist — so the two cannot say different things. The exemption is the walk's existing one rather than a second rule: a program DECLARED to read no path (`echo`, `true`, `basename`, …) does not open its words whatever stream they arrived on, so `ls | xargs echo` stays silent, and a bare `xargs` runs `echo` and is exempt by the same reading. The CHAIN is what is read, not the first word, and it is read to its END: `env xargs cat` and `timeout 5 xargs cat` are the same command as `xargs cat`, and a chain that ENDS on another launcher (`xargs env`, `xargs sudo`, `xargs timeout 5`) names no program at all — reading that as the default `echo` vouched for `echo cat <outside> | xargs env`, where `env` runs whatever the stream names, so the default-program exemption holds only when nothing follows the re-entry.
8
+ - **#766 — behaviour, for consumers.** Under BOTH doctrines a command that feeds another program's argv from a stream now asks ONCE, mandated (a stored allow rule is disclosed as shadowed and does not clear it), unless the program it runs is one declared to read no path. **Under the default doctrine these ran with ZERO asks before**, so on a headless deployment — no approver wired — the card is a REFUSAL, not a prompt: `ls | xargs cat`, `git ls-files | xargs grep -l TODO`, `cat list.txt | xargs cat`, `ls | parallel cat`, `xargs cat < list.txt`, `env xargs cat`, `ls | xargs env`, `ls | xargs nice`. **Stated over-fire, pinned rather than described:** a stream-fed program reads MORE files than the ones written beside it, so `xargs cat <in-root file>` and `find . -name x | xargs wc -l` are mandated asks even though every path in the text is inside the roots — telling "stdin is empty here" from "stdin carries a path" is exactly the fact the text does not contain. The floor is unchanged and pinned: `ls | xargs echo`, `echo a | xargs echo`, `ls | xargs true`, and every command with no stream re-entry. The literal-operand form `xargs cat <outside>` was already one mandated ask and still is — its verdict now carries the refusal sentence rather than `outOfRootPaths`, so no "allow reading from …" directory grant is offered for it.
9
+ - **#767 — before.** The walk threaded a `cd` as if it had SUCCEEDED. It is lexical and holds no filesystem, so it cannot know the directory exists; bash, when `cd` fails, leaves the process standing where it was and runs the rest of the command from there. Measured on the same path, both doctrines: `cd <missing>; cat ../secret` and `cd <missing> || cat ../secret` were asks=0 and EXECUTED — the walk placed `../secret` under `<root>/<missing>`, read it as the in-root `<root>/secret`, and vouched, while bash resolves it to `/secret`, outside every root. The `||` spelling runs the read ONLY when the change failed, i.e. against the one base the walk could not be using.
10
+ - **#767 — after (a value becomes a set; no new rule, and no I/O).** The base a segment's relative operands resolve against is every directory the shell could be standing in, and any one of them outside the roots or on the deny table is the answer — the same fail-closed direction the walk already takes for an operand it cannot resolve at all. **`&&` is the one connector that closes the gap and it closes it exactly:** bash runs the right-hand side only after the left-hand side exited zero, so a segment reached from a `cd` through an unbroken run of `&&` is the shell's own proof the change happened, and the pre-move base is not a base that segment can be in. The verification is per CHANGE, not per command (`cd a && cd b; cat ../x` has its first change proven and its second not), and a run broken by anything else — `;`, `||`, `|`, a segment the walk dropped — gives the proof back. Connector information that is missing entirely is read as broken, which is the fail-closed side: the candidate set can only widen, never narrow. A candidate set that outgrows its cap is answered as an unresolvable directory change — the reading the walk already had for a `cd` it cannot track — so the seats keep one meaning of "the boundary did not judge this".
11
+ - **#766 — what looks like a member and is not.** GNU `parallel` does not exec the program beside it with the stream's words appended: it BUILDS a command line and hands it to a shell, and given no command word at all it runs each input LINE as a command. Both halves of this class's exemption fail there — measured, `echo <program> <path> | parallel` runs the line and `parallel echo "…; <reader> <path>; echo"` runs the `;` pieces — so `parallel` left the launcher table for the SHELL re-entry class, where it is refused whole with no exemption and nothing looks through it. @server @cli: `parallel …` in any spelling is a mandated ask on both tiers.
12
+ - **#767 — behaviour, for consumers.** Under BOTH doctrines a relative read placed after a directory change the shell did not prove now asks ONCE, mandated, when either reading leaves the roots or lands on the deny table: `cd <dir>; cat ../secret`, `cd <dir> || cat ../secret`, `cd <dir> && ls; cat ../secret`, `cd <dir> | cat ../x`. **Measured against a 55-command everyday corpus, no other cell moves** — `cd sub && npm test`, `cd pkg && cat package.json`, `cd sub && cat ../x`, `cd packages/foo && cat ../bar/package.json`, `cd sub && cd .. && ls`, `cd sub; ls` all keep their zero-ask verdict, because either both readings land inside or the `&&` run proves the change. A change whose target the walk cannot resolve at all (`cd -`, bare `cd`) is unchanged: the plain ask it always was.
13
+ - **What the `&&` proof does NOT prove, each measured as a live vouch before it was closed.** `&&` and `||` associate leftward with equal precedence, so `x || cd <dir> && <read>` is `(x || cd <dir>) && <read>`: the `&&` proves the status of the whole left LIST, which when `x` succeeds is `x`'s status — the change never ran. A run that BEGINS after a `||` therefore proves nothing about its first segment, while a change at any later position of the same run is still proven. Every element of a PIPELINE runs in a subshell, so `echo hi | cd sub && <read>` leaves the parent standing exactly where it started however well the change went. A control word in front of the change hides or inverts the status the run reads (`! cd <dir>` exits zero precisely when the change FAILED). And an EXTERNAL `cd` — a path-prefixed spelling, or one behind a launcher — runs a program whose zero exit says nothing about this shell's directory at all, so the proof requires that the written word WAS the bare builtin. All three are now unprovable by construction, and the connector row the boundary reads carries the three facts (reached through `&&`, the crossing carried a `||`, the change can move the parent's base at all) rather than one boolean.
14
+ - **Cost, measured back to back on one machine and bounded.** The candidate set makes the walk's work candidates × operands, so the two shapes that carry many of both got slower and are stated rather than left to be discovered: a 120-beat poll-loop body with one directory change goes from 1.8 ms to 25 ms, and eight unverifiable changes followed by 128 reads from 0.6 ms to 56 ms. Both are the WORKED versions — the first naive cut of the same code measured 1.4 s and 13.4 s respectively, and two changes bound it: the finding accumulators dedupe through sets instead of linear scans, and a per-(segment words, base) memo collapses the poll-loop face's replicated body. An ordinary command is unmeasurable either way (0.0 ms before and after), and a candidate set that outgrows its cap stops the walk instead of growing.
15
+ - **Known residual, NOT closed here and filed separately:** a `sed` whose script is not the pure print form still carries its refusal as a SHAPE reason rather than a whole refusal, so under the default doctrine `sed -n '1r /etc/hosts' <file>` — and the `-e`, `-f` and no-`-n` spellings of the same script indirection — read a file outside the roots with zero asks. Closing it needs a decision about how much of the sed script grammar this walk admits, which is a design question rather than a site fix. **Also open, and stated rather than implied:** a spelling of the stdin-to-argv class this file has no name for (an aliased or renamed `xargs`) is judged as any other unlisted program — the name-table floor the shell re-entry class already records for itself; `CDPATH`, a `cd` through a symlink, and a directory change made by a shell function or a `DEBUG` trap are not modelled, all pre-existing and none touched here.
16
+ - Pins: `test/backlog766-argv-reentry.test.ts` (the real load path, two doctrines × three feeds × three wrapped programs including a synthetic name on no table, the deny half, the mandated-ask lane under an exact covering rule and under an armed-but-not-matching lane, the off-tier disclosure held against the same run, `parallel` as the class's second member, the launcher chain to its end including an absolute-path re-entry inside one, GNU `parallel` as the near-member that is refused as shell re-entry instead, the negative controls and the stated over-fire; six mutation directions measured in the header), `test/backlog767-cd-unverifiable-base.test.ts` (both connectors that leave a change unproven, the deny regression, the mandated-ask lane, the `&&` negative controls including the two everyday monorepo spellings, the broken-run cases, per-change verification, the three shapes the `&&` proof does not cover, and the unresolvable-target regression; five mutation directions measured in the header, one of them the fail-closed check that a missing connector row costs only negative controls).
17
+
18
+ ### Narrowing — the full shell's read boundary is re-judged on the FILESYSTEM before the command runs (#768; test [7178] ③; @server @cli @test)
19
+ - **Before.** The shell read boundary was LEXICAL containment: it judges the paths a command's TEXT may name, resolved against the declared roots, home and cwd. A symlink inside a root that points out of it therefore read as in-root — with `<root>/vendor` → an outside directory, `cat vendor/secret` was an in-root spelling, the boundary seat vouched it, ZERO asks were raised under every doctrine, and the outside bytes reached the model record. Measured on the real load path (Runner prepare → NodeExecutionEnv → the gate). The filesystem-resolving second pass that closes exactly this gap (`createCanonicalReadBoundary`) already existed and was wired on the `bash_readonly` face ALONE; the full face had no recheck at all, and no ticket tracked the asymmetry.
20
+ - **After (a leg reused, not a rule added).** The full `bash` face runs the SAME pass on its own execute path — after the gate allowed the call and before anything spawns, ahead of the `run_in_background` branch (the flag changes WHEN a command runs, never WHERE it reads). **Its scope is the whole design: it re-judges only what the boundary SILENTLY vouched** — the literal locations the walk resolved and placed INSIDE the roots (`checkedPaths`), which raise no question at any tier and are therefore the one class where nobody was ever asked. A target that canonicalizes outside the roots is REFUSED, never asked: execution is past the approval step, so there is nothing left to escalate to. A call ABORTED before the spawn starts nothing — `spawnBackground` takes no signal of its own, so the cancellation is checked once, last, immediately before it.
21
+ - **What the boundary ASKED about belongs to whoever answered it, and this leg does not revisit it.** A path the walk placed outside the roots (`outOfRootPaths`) is a mandated ask, and a person's yes on `cat <outside>/x` still runs. An unexpanded GLOB (`undecidedPaths`) is not judged here at all: it is an ask under every doctrine, so someone looked at "a pattern nobody could resolve" and said yes — expanding it afterwards and refusing what it matched would override that answer. Two consequences, both deliberate: the leg gives up the glob lane (`cat <link>/*`, and the same read piped anywhere, are NOT refused — disclosed, pinned, and the literal spelling of the identical read still is), and it loses the only refusals it had that needed no symlink at all (a dot pattern reaching `..`, an expansion past 256 directories / 8192 names, and a bracket construct the matcher cannot compile). The `bash_readonly` face keeps every one of those — its containment is load-bearing and it has no approval channel to have asked with.
22
+ - **A refusal earned by one segment does not disable the check on another.** Within its scope the leg does not read the verdict's sentence, and it takes BOTH read faces (the compound walk and the bounded read-only poll loop): `cat <link>/secret | jq .` and `cat <link>/secret ; :` are refused compounds whose `checkedPaths` still carry the reader's real operand. Reading the sentence first — "the face refused, so it vouched nothing" — left the everyday shape (a read piped into any program the reader table does not list) outside the check entirely, and made the whole leg one appended `; :` away from silence.
23
+ - **A path that does not EXIST is not a refusal.** The resolver takes the deepest existing ancestor and rejoins the missing tail, so `cat missing.txt` still gets the shell's own "No such file"; a chain that cannot be answered at all keeps the lexical verdict rather than manufacturing one.
24
+ - **Behaviour, for consumers.** On the full shell, a command whose operands the read boundary resolved inside the roots now RETURNS AN ERROR instead of running when one of them resolves through a symlink to a directory outside the session's read roots. The result carries the read-only face's own card (`details.type` / `code`: `readonly_out_of_root`, `paths` = the canonical targets), now declared on the full shell's catalog row: ONE code for both faces, because the finding is one finding and a second word would make a consumer's count depend on which shell was mounted. Unchanged and pinned: an in-root symlink, a plain in-root read, a link into a declared additional READ root, a command with no path operand, every glob, the OPEN read face (containment is structurally satisfied there, so an out-of-roots operand rides `checkedPaths` as an ACCEPTED one and re-judging it would make "open" stricter than "roots"), a REMOTE execution env (no local `realpath`; the same locality split the read-only face's tool card already discloses), and every library-direct mount that declares no boundary. The `bash_readonly` face is byte-unchanged in verdict and wording — it already ran this leg.
25
+ - **The over-fire, one shape and one premise.** The walk is program-blind, so a refused compound's `checkedPaths` include the argv WORDS of its non-reader segments (`make build` offers `<root>/build`, `npm test` offers `<root>/test`) and an option's separated value rides beside the operands (`grep -e vendor pkg.json` offers `<root>/vendor`); a read OF a link also counts as a read of its target (`readlink vendor`, `ls -d vendor`). Such a command is refused only where the workspace really holds an entry of that name that is a symlink OUT of the roots — **the condition is the point**: that same link already refuses every genuine read through it, so the deployment has to answer for it either way, and the answer is the same, DECLARE the target (`TaskSpec.additionalReadDirectories`), which the leg honours (measured: with the target declared, `make build` runs again). Telling an operand from an argv word needs a per-segment provenance fact the verdict does not carry, and inventing one here means a second hand-written parser beside the classifier's — the thing #753 exists to stop this file from growing. The refusal says the command NAMES the path, never that it reads it.
26
+ - **Also fixed, found by exposing it.** The resolver stripped a trailing separator before rejoining the missing tail, which turns a canonical FILESYSTEM ROOT into the empty string — inside no root. A deployment declaring `/` among its read roots therefore had `ls -d /` refused, with an empty path in the refusal's own text. With nothing to rejoin, the canonical answer is now returned unchanged. Pre-existing and equally live on `bash_readonly`; it only became reachable in practice when the full face started calling the same resolver.
27
+ - **Blind spots, disclosed and pinned as facts rather than assumed closed.** From the LEXICAL resolution that produces the candidates, all equally live on the read-only face: a `..` is collapsed in the TEXT before any link is resolved (`<link>/../x` is checked as `<root>/x`); a working-directory move is modelled as always succeeding (`cd nope || cat <link>/x` is checked under `<root>/nope/`); a command the GRAMMAR gate refuses whole yields no candidates at all. One is structural: a glob's ask can be retired by a stored allow rule or an auto tier, and this leg cannot tell that from a person's yes — `ToolExecuteContext` carries no record of how this call's gate settled, so both are left to the answerer. One is the ENVIRONMENT's: `ExecutionEnv.canonicalPath` takes a signal it is free to ignore (the built-in node env does), so a STALLED filesystem holds the recheck's await open and a cancellation cannot reach the spawn guard until it returns — a property of every caller of that resolver, filed against the adapter rather than raced around here.
28
+ - **API (additive, optional).** `HandsToolkitOptions.shellReadBoundary` and `createBashTool`'s `taskOpts.readBoundary` take the deployment's read boundary — the SAME declaration the gate's two shell probe seats read, so one closure now feeds three consumers and the two answers about one path cannot disagree. ABSENT ⇒ the leg does not run and costs no I/O (no boundary declared, no "outside" to be outside of — the reading the seats already take), and it deliberately does NOT fall back to the write side's `additionalRoots`, which would stand up a second root table that excludes the read roots and refuse reads the deployment sanctions.
29
+ - **Contract.** What this seat is and is not is now stated once, as `shell.read_boundary.friction_floor` on `bashReadBoundaryProbe`: the seat answers a question about TEXT; the containment for what a shell may reach is the `ExecutionEnv` the command runs in; the execute leg is the floor's last cell. Every other site links to it instead of restating it.
30
+ - Pins: `test/backlog768-bash-canonical-recheck.test.ts` (the real load path on both doctrines: the leak itself across reading verbs, compounds, the pipe-into-an-unlisted-program and `; :` shapes, the bounded poll loop and the background leg, with the wire card asserted on the frame; the read-root, in-root-symlink, plain-read, everyday-lower-bound, open-face and no-boundary negatives; the glob arm's deliberate silence beside the literal spelling that still refuses; the answered-question arm (an approved out-of-root read runs); the stated over-fire WITH its declare-the-target control; the blind spots as facts; two cancellation windows with a positive control on the spy; the `bash_readonly` face's own wording; the operator note; and the single-construction-site pins — eight mutation directions measured in the header).
31
+
32
+ ## 7.17.4 — 2026-09-14
33
+
34
+ ### Narrowing — an option's ATTACHED payload is a word like any other: the boundary walk judges it (#763; the residual filed at 7.17.3; @server @cli @test)
35
+ - **Before.** The boundary walk offered an attached option payload (`-fVALUE`, `-xVALUE` in a cluster, `--flag=VALUE`) as a path candidate only when the payload was path-SHAPED, and a sensitive-path deny row names BARE filenames (`.npmrc`, `.env`, `id_rsa`) — no separator, no shape. The payload was dropped before anything judged it, so the deny row was read with no question at all, for EVERY head, listed or not: measured on the real load path (Runner prepare → NodeExecutionEnv → the gate) under the default doctrine, `grep -f.npmrc`, `grep --file=.npmrc`, `awk -f.npmrc` and `base64 -i.npmrc` were asks=0 and EXECUTED (`awk -f.npmrc` put the file's bytes into the model record, since awk quotes the source it cannot parse), while the separated (`grep -f .npmrc`) and path-shaped (`-i./.npmrc`) spellings of the IDENTICAL read were one mandated ask. Under `"classify"` the same defect showed as `grep --file=.npmrc` asks=0 and the other three raising only the doctrine's own retirable ask, with no mandate. This is the same wrong assumption #758 removed one level down ("a word that does not look like a path is not a file"), surviving at the walk; it is NOT the #758 class — the read-only allowlist has nothing to do with it.
36
+ - **After (a rule removed, none added).** The payload arm's shape filter is gone. Every judged word — an attached payload and a positional operand alike — takes ONE lane split (`judgeOperand`): a word that can denote a location outside the working directory (path-shaped, or an absolute form) is a containment candidate; a separator-less word can only land under the working directory, so it is deny-judged and reported as a checked path but cannot demote. That is the lane the positional operands always used; the payload now shares it instead of being filtered out of the judgement entirely. The per-verb carve-outs are unchanged (grep's `-e`/`--regexp=` PATTERN payloads, cut's delimiter values), and an option payload is still NOT a traversal root.
37
+ - **Same class, second spelling: the cluster is read to its END.** `attachedOptionPayloads` walked a short-option cluster's option characters as LETTERS only, so a DIGIT stopped the scan: `grep -2f.npmrc` offered `2f.npmrc` (a bare word under the working directory) and never `.npmrc`, while the installed grep opens `.npmrc` — verified against the real program, not inferred from an option table. Digits are ordinary getopt option characters (`grep -1`…`-9`, `head -1`, `tar -0`), so the repair is the CHARSET: one constant (`CLUSTER_OPTION_CHAR`) read by BOTH readers of a cluster — the payload scan and grep's value-owner scan, which must agree about where the options end, or a digit-prefixed PATTERN payload (`grep -2e/x`) would become an out-of-root path candidate and a refusal on the readonly face. No per-program option list is introduced, and the charset closes the same digit hole in the DEVICE arm, which extracts payloads through the same function: `grep -2f/dev/zero a b` was measured auto-allowed (grep consuming the endless device as its pattern file) and is now the device refusal, beside the `grep -f/dev/zero` spelling that was already caught. `grep -2f.npmrc a b` is now a deny-row mandate and `grep -2f/etc/passwd a b` an out-of-root mandate, on both seats; every everyday numeric spelling (`head -n10`, `head -c100`, `tail -n+100`, `make -j8`, `git log -n5`, `grep -A2`/`-B3`/`-C2`/`-m1`, `ls -1`, `head -5`, `cut -c1-5`, `base64 -w0`, `ssh -p2222`, `docker run -p8080:80`, `mysql -h127.0.0.1`, `chmod -R755`, `ip -4`) keeps its zero-ask verdict, measured. **The over-fire it buys, swept for and named row by row:** payload extraction is program-blind and over-approximates by design (`-if../x` offers `f../x` AND `../x`), and the charset is what stops a split from being taken INSIDE a relative path (`-fsrc/a.txt` breaks at the `/` and never invents `/a.txt`). Admitting one more option character therefore moves some split points one place right, and where the next character is a `/`, an absolute reading appears that the letters-only scan did not produce. Three rows move, each an attached value whose DIGITS are followed by a separator: `date -d1/2/2026` (offers `/2/2026`), `diff -I1/foo` (a regex value) and `sed -e'1s/a/b/'` (a line-addressed script) — the last two a mandated ask on the shell lane and a hard refusal on the `bash_readonly` face. Their non-moving neighbours are pinned beside them (`date -d '1/2/2026'`, `sed -e'1,5s/a/b/'`, `sed -e'1d'`, `diff -Ifoo`, `head -n1`), as are the letters-only analogues that already mandated before this car (`date -da/b/c`, `sed -e's/a/b/'`). The repair for the whole row-set is #764's declared non-path-slot table (`sed -e` is a script, `diff -I` a regex, `date -d` a time string), not a per-name special case here and not narrowing the charset back — that reopens the attached-file reads this closes. **Residual, disclosed and pinned:** a PUNCTUATION option character still ends the scan, so `curl -#K.npmrc` hides the `-K` payload behind curl's `#` flag (verified against the installed curl) and the deny row is read unasked under the default doctrine; enumerating punctuation IS the per-program option table this file refuses to carry, and the sound answers are #764's value-ownership table or mandating on a cluster the scan cannot fully interpret.
38
+ - **The zero-option reading narrowed to where it names a location.** The suffix scan also offered `tok.slice(1)` — the whole post-dash text as a value no option consumed — unconditionally. getopt has no such form, and on an everyday cluster it is a word no program opens (`-f` offered the phantom `f`, `-xzf` offered `xzf`; `cat -/etc/passwd` is an illegal option on the installed cat/head/grep rather than a read), which stayed invisible only while payloads were shape-filtered. Dropping it outright was measured against the pre-car tree and lost three mandates — `grep -/etc/passwd f`, `cat -//` and `chpst -// cat etc/passwd` went SILENT where the baseline mandated — because that reading is what covers an option character this scan cannot enumerate: `-//` is `-/ /` to a program whose `/` takes a value, and the chroot that follows reads from the filesystem root. So shape decides here exactly as it does in the lane split — a location-shaped whole is offered, a bare word is not. An exact-`checkedPaths` pin for `tail -f app.log` therefore reads `[<root>/app.log]` as before, and every out-of-root boundary the pre-car scan drew is still drawn.
39
+ - **Behaviour, for consumers.** A shell command whose option payload names a deny row now asks ONCE, mandated, under both doctrines — a stored allow rule is disclosed as shadowed and does not clear it — and the ask is the same one the separated spelling already raised. The lower bound is unchanged and pinned: `grep -epattern f`, `head -n10 f`, `tar -xzf x.tgz`, `curl -Hheader`, `wc -l f`, `cut -d, -f1 f`, `sort -k1 f`, `ls -la`, `npm run build -- --silent`, `git log -n5`, `node --max-old-space-size=4096 x.mjs`, `make -j8`, `docker run -it img`, `jq -r .name pkg.json`, `sed -n '1,5p' f`, `base64 -w0 f` keep their zero-ask verdict on both seats. The widened judgement adds `checkedPaths` entries for payload words that resolve inside the roots (the extractor is getopt-blind and offers every suffix that follows at least one option character, so `curl -Hheader` now reports `header`, `eader`, … beside `curl`): those are candidates a filesystem-holding caller re-checks with symlinks resolved, and they never affect a verdict. @test: the class criterion is the SPELLING (attached short / cluster / long `=`) held against the separated and path-shaped spellings of one read, on a listed program and a synthetic unlisted name alike — a verification that enumerates tool names repeats the defect.
40
+ - **A pre-existing hole CLOSED beside the defect (a narrowing, not an over-fire).** Folding the two arms' shape tests into one predicate also gave the POSITIONAL arm the absolute-form half the payload arm always had. Measured over a win-form corpus on all three faces, exactly one answer moves: on a WIN32-rooted boundary `classifySimpleCommandReadBoundary` returned an EMPTY verdict for `cat \\host\share\secret` and `cat C:\other\secret` — no reason, no `refusedWhole`, no out-of-root finding, i.e. a read outside every root AUTO-ALLOWED on the one face whose doctrine is hard refusal, while the compound face refused the identical command whole. Both faces now name the out-of-root read. The in-file note that called the shape unreachable is corrected: the backslash hard-reject that makes it true covers the COMPOUND faces only, and never covered the single-command face. @server @cli: on a win32 tree a `bash_readonly` command naming a UNC or drive-letter path outside the roots is refused where it previously ran.
41
+ - **Stated over-fire, measured and pinned.** A payload that SPELLS a deny row gates even where the program would not open it: `grep --exclude=.npmrc`, `--include=`, `--label=` are filename patterns and labels, not files grep opens, and they now raise a mandated ask that a standing rule cannot retire (the ordinary values — `--exclude=build`, `--include=src` — keep their zero-ask verdict). Telling "a payload that names a file" from "a payload that names a pattern" needs the per-program option semantics this file refuses to carry; the file's stated error direction is that over-reading a token as a path costs a prompt while under-reading one is a boundary circumvention. The two existing carve-outs stay exactly where they are — payloads that provably are not filesystem targets AND whose over-read would be a hard REFUSAL rather than a prompt. Related, NOT fixed here: a listed verb's non-path slots have no declared table (`sed -e` is a script, `grep -e` a pattern, `head -n` a number), so `sed -e's/a/b/'` mandates on the `/a/b/` its cluster also spells — pre-existing, identical before and after this car, and filed as #764 rather than answered with a `sed` special case.
42
+ - Pins: `test/backlog763-attached-payload-deny.test.ts` (the real load path, two doctrines: the four measured-silent spellings, the mandated-ask lane under a covering rule, the listed/unlisted × attached/separated/path-shaped grid with its non-deny negative twin, the everyday-payload lower bound end to end and at both seats, the digit-cluster deny/out-of-root mandates with the pattern carve-out control and the numeric lower bound, the out-of-root payload and positional deny-row regressions, the accepted over-fire, the payload-is-not-an-operand-slot cases, and the closed win-form hole on both root families; four mutation directions measured in the header).
43
+
3
44
  ## 7.17.3 — 2026-09-13
4
45
 
5
46
  ### Narrowing — "not on the read-only allowlist" is not "reads no path": the read boundary judges every segment (#758; test [7138]; @server @cli @test)
@@ -1,6 +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
5
  import { defaultTaskRegistry } from "../task-registry.js";
5
6
  import { bashReadBoundaryProbe, bashReversibilityProbe, compileReadDeny, createHandsToolkit, deploymentReadFaceClampNotice, pdfModelCapabilitiesOf, resolveReadDenyBuiltins, inheritSessionReadFileState, resolveReadFace, seedReadFileStateFromContext, seedReadFileStateFromTranscript, } from "../../tools/fs/index.js";
6
7
  import { resolveKey, sha256 } from "../../tools/fs/safety.js";
@@ -293,6 +294,13 @@ export async function prepareHandsMount(input) {
293
294
  sessionSeats.reanchor(sessionId, readFileState, ownCommittedTailEntryId);
294
295
  }
295
296
  : undefined;
297
+ const shellReadBoundary = () => ({
298
+ roots: [rootCanonical, ...additionalRootsCanonical, ...additionalReadRootsCanonical],
299
+ ...(handsCwdRef?.current !== undefined ? { cwd: handsCwdRef.current } : {}),
300
+ ...(executionEnv.homeDir !== undefined ? { homeDir: executionEnv.homeDir } : {}),
301
+ denyMatch: (p) => readDenyMatcher?.matchPath(p)?.pattern ?? null,
302
+ ...(resolvedReadFace !== undefined ? { face: resolvedReadFace } : {}),
303
+ });
296
304
  const band = createHandsToolkit(executionEnv, readFileState, rootCanonical, {
297
305
  ...(additionalRootsCanonical.length > 0 ? { additionalRoots: additionalRootsCanonical } : {}),
298
306
  ...(additionalReadRootsCanonical.length > 0 ? { additionalReadRoots: additionalReadRootsCanonical } : {}),
@@ -306,6 +314,7 @@ export async function prepareHandsMount(input) {
306
314
  ...(input.onFileEdited !== undefined ? { onFileEdited: input.onFileEdited } : {}),
307
315
  includeShell: handsIncludeShell,
308
316
  readOnly: handsReadOnly,
317
+ shellReadBoundary,
309
318
  ...(handsCwdRef ? { cwdRef: handsCwdRef } : {}),
310
319
  taskRegistry: defaultTaskRegistry,
311
320
  taskOwner: hostTaskId,
@@ -346,18 +355,11 @@ export async function prepareHandsMount(input) {
346
355
  if (!(executionEnv instanceof StubExecutionEnv) && spec.handsReadOnly !== true) {
347
356
  if (shellGate === "off") {
348
357
  deps.onError?.(new Error(`shell gate doctrine is "off" while a real writable shell (Bash) is mounted — the read boundary is ` +
349
- `still judged on every command (a deny-listed or out-of-root read asks; the ask is mandated), but no ` +
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 ` +
350
359
  `residual shell-risk classifier is installed: writes, egress and unknown commands are adjudicated by ` +
351
360
  `the ordinary policy/hook chain only. Set spec.shellGate to "classify" or "always" if this deployment ` +
352
361
  `expects doctrine-gated shell behavior.`), { phase: "config", sessionId, classification: "shell-gate-off" });
353
362
  }
354
- const shellReadBoundary = () => ({
355
- roots: [rootCanonical, ...additionalRootsCanonical, ...additionalReadRootsCanonical],
356
- ...(handsCwdRef?.current !== undefined ? { cwd: handsCwdRef.current } : {}),
357
- ...(executionEnv.homeDir !== undefined ? { homeDir: executionEnv.homeDir } : {}),
358
- denyMatch: (p) => readDenyMatcher?.matchPath(p)?.pattern ?? null,
359
- ...(resolvedReadFace !== undefined ? { face: resolvedReadFace } : {}),
360
- });
361
363
  const installShellSeat = (name) => {
362
364
  const tierBefore = irreversibilityTier.get(name);
363
365
  const seatOwned = !egressTools.has(name) && tierBefore !== "always" && tierBefore !== "maybe";
@@ -56,7 +56,7 @@ export const TOOL_CATALOG_ENTRIES = [
56
56
  },
57
57
  {
58
58
  id: "bash", name: "Bash", source: "builtin", definedIn: "src/tools/fs/fs-bash.ts", mountedBy: ["hands"],
59
- cards: ["bash", "bash_invalid_timeout"],
59
+ cards: ["bash", "readonly_out_of_root", "bash_invalid_timeout"],
60
60
  face: {
61
61
  family: "shell", effect: "write", contentOrigin: "execution", contract: c(FULL_SHELL_CONTRACT_ID),
62
62
  ruleFace: SHELL_RULE_FACE,
@@ -140,10 +140,22 @@ export declare function coarseReadonlyCheck(command: string, allow: ReadonlySet<
140
140
  export declare function carriesShellRedirection(text: string): boolean;
141
141
  /** What {@link splitShellCompoundSegments} produces: the connector-delimited segments, plus which of
142
142
  * them a PIPE fed (the read-only classifier's stdin arm needs that distinction; a rule lane does not). */
143
+ /** The connector bash ran a segment through — `"start"` for the first one. A closed set, because the read
144
+ * boundary distinguishes all four: `&&` is the only one under which the previous segment is known to have
145
+ * exited ZERO, `||` is the only one under which the previous segment may have been SKIPPED ENTIRELY, `|`
146
+ * puts the segment in a subshell whose directory changes never reach the parent, and `;` is plain sequence. */
147
+ export type ShellSegmentConnector = "start" | ";" | "&&" | "||" | "|";
143
148
  export interface ShellCompoundSegments {
144
149
  segments: string[];
145
- /** Parallel to {@link segments}: was this segment preceded by `|` (rather than `;`/`&&`/`||`/nothing)? */
150
+ /** Parallel to {@link segments}: was this segment preceded by `|` (rather than `;`/`&&`/`||`/nothing)?
151
+ * A VIEW of {@link connectors}, derived at the one place both are minted — two rows that could drift are
152
+ * two rows that will. */
146
153
  pipeFed: boolean[];
154
+ /** Parallel to {@link segments}: the connector bash reached this segment through. Read by the read
155
+ * boundary, where it is the difference between a directory change the shell itself proved happened and
156
+ * one this walk cannot verify. A consumer that does not read it gets the unverified reading, which is
157
+ * the fail-closed one. */
158
+ connectors: ShellSegmentConnector[];
147
159
  }
148
160
  /**
149
161
  * Split a compound command into the segments bash would run, or refuse the whole string.
@@ -228,10 +240,10 @@ export interface BashReadonlyRootBoundary {
228
240
  * allowlists classifies by its ordinary form and can auto-run a traversal unjudged);
229
241
  * ② LEXICAL only — an in-root symlink whose target is a
230
242
  * guarded path reads as its innocent spelling here, exactly as it does for the containment half
231
- * (the enforcing/canonicalizing recheck is the bash_readonly leg's job via checkedPaths; the
243
+ * (the enforcing/canonicalizing recheck is both shell faces' execute leg, via checkedPaths; the
232
244
  * classify auto-allow lane has no I/O seat by contract). The structured read faces judge BOTH
233
- * views (canonical + lexical) — this seat is the shell lane's honest-friction floor, not its
234
- * security boundary. The `bash_readonly` face deliberately does NOT wire this seat (its
245
+ * views (canonical + lexical); what this seat is and is not is stated once, at
246
+ * `shell.read_boundary.friction_floor`. The `bash_readonly` face deliberately does NOT wire this seat (its
235
247
  * roots containment + command allowlist double gate is the deployment's own read-safety
236
248
  * declaration — v1 ruling). */
237
249
  denyMatch?: (resolvedPath: string) => string | null;
@@ -459,7 +471,8 @@ opts?: {
459
471
  * so this face bounds where a command may NAME a file, not where the kernel will ultimately take it.
460
472
  * A deployment that needs filesystem-true containment resolves the operands itself, and this
461
473
  * verdict hands it the list to resolve ({@link CompoundReadonlyVerdict.checkedPaths}, on both arms).
462
- * `createBashReadonlyTool` is that caller for the `bash_readonly` leg and does it on a local env.
474
+ * The engine's own shell legs are that caller on a local env — the read-only face before it runs an
475
+ * allowlisted command, the full face after its gate allowed one.
463
476
  */
464
477
  export declare function classifySimpleCommandReadBoundary(command: string, boundary: BashReadonlyRootBoundary): CompoundReadonlyVerdict;
465
478
  /**