@sema-agent/core 7.17.4 → 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 +29 -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 +180 -61
  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,34 @@
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
+
3
32
  ## 7.17.4 — 2026-09-14
4
33
 
5
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)
@@ -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
  /**
@@ -109,7 +109,7 @@ export function splitShellCompoundSegments(source, options) {
109
109
  }
110
110
  const scanned = options?.trailingTerminator !== "keep" ? source.replace(/;\s*$/, "") : source;
111
111
  const segments = [];
112
- const pipeFed = [false];
112
+ const connectors = ["start"];
113
113
  const mask = quoteMask(scanned);
114
114
  const quoted = (i) => mask.balanced && mask.quoted[i] === true;
115
115
  let cur = "";
@@ -122,7 +122,7 @@ export function splitShellCompoundSegments(source, options) {
122
122
  if (scanned[i + 1] === "&") {
123
123
  segments.push(cur);
124
124
  cur = "";
125
- pipeFed.push(false);
125
+ connectors.push("&&");
126
126
  i++;
127
127
  }
128
128
  else
@@ -132,25 +132,25 @@ export function splitShellCompoundSegments(source, options) {
132
132
  if (scanned[i + 1] === "|") {
133
133
  segments.push(cur);
134
134
  cur = "";
135
- pipeFed.push(false);
135
+ connectors.push("||");
136
136
  i++;
137
137
  }
138
138
  else {
139
139
  segments.push(cur);
140
140
  cur = "";
141
- pipeFed.push(true);
141
+ connectors.push("|");
142
142
  }
143
143
  }
144
144
  else if (c === ";") {
145
145
  segments.push(cur);
146
146
  cur = "";
147
- pipeFed.push(false);
147
+ connectors.push(";");
148
148
  }
149
149
  else
150
150
  cur += c;
151
151
  }
152
152
  segments.push(cur);
153
- return { segments, pipeFed };
153
+ return { segments, pipeFed: connectors.map((k) => k === "|"), connectors };
154
154
  }
155
155
  const EXEMPT_REDIRECTION = /^(?:2>\/dev\/null|[12]?>&[12])$/;
156
156
  function stripExemptRedirections(command) {
@@ -259,7 +259,8 @@ function tokensMoveBase(toks, allow) {
259
259
  const unwrapped = unwrapLauncher(toks.folded.slice(from), allow);
260
260
  return unwrapped.kind === "refused" || (unwrapped.kind === "reader" && unwrapped.head === "cd");
261
261
  }
262
- const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi)?sh|eval|source|\.)$/;
262
+ const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi)?sh|eval|source|\.|parallel)$/;
263
+ const ARGV_REENTRY_PROGRAM_RE = /^xargs$/;
263
264
  export function refusedCommandMayRead(command, allow, boundary) {
264
265
  return !refusedCommandExempt(command, allow, boundary, 0);
265
266
  }
@@ -579,6 +580,8 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
579
580
  const unwrapped = unwrapLauncher(toks.slice(i).map(fold), allow);
580
581
  if (unwrapped.kind === "refused")
581
582
  return false;
583
+ if (argvReentryUnjudged(unwrapped))
584
+ return false;
582
585
  if (unwrapped.kind === "unlisted") {
583
586
  if (!unlistedSegmentJudgedInside(toks, i))
584
587
  return false;
@@ -621,18 +624,35 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
621
624
  return true;
622
625
  }
623
626
  function unwrapLauncher(folded, allow) {
627
+ let argvFromStdin = ARGV_REENTRY_PROGRAM_RE.test(programBasename(folded[0] ?? ""));
628
+ let lastReentry = argvFromStdin ? 0 : -1;
624
629
  for (let i = 1; i < folded.length; i++) {
625
630
  const t = folded[i];
626
- if (ASSIGNMENT_WORD.test(t) || /^\d+[smhd]?$/.test(t) || COMMAND_LAUNCHERS.has(t))
631
+ if (ASSIGNMENT_WORD.test(t) || /^\d+[smhd]?$/.test(t))
632
+ continue;
633
+ const wordBasename = programBasename(t);
634
+ if (ARGV_REENTRY_PROGRAM_RE.test(wordBasename)) {
635
+ argvFromStdin = true;
636
+ lastReentry = i;
637
+ continue;
638
+ }
639
+ if (COMMAND_LAUNCHERS.has(wordBasename))
627
640
  continue;
628
641
  if (t.startsWith("-"))
629
642
  return { kind: "refused", reason: `"${folded[0]}" is given an option before the program it runs — which program runs is not readable without the launcher's option table ${NOT_AUTO_ALLOWED}` };
630
- const head = t.includes("/") ? t.slice(t.lastIndexOf("/") + 1) : t;
643
+ const head = wordBasename;
631
644
  if (SHELL_CONTROL_PREFIXES.has(head) || SHELL_BLOCK_WORDS.has(head))
632
645
  return { kind: "refused", reason: `"${folded[0]}" is given shell control syntax ("${head}") where the program it runs would be ${NOT_AUTO_ALLOWED}` };
633
- return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head } : { kind: "unlisted", head };
646
+ return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head, argvFromStdin } : { kind: "unlisted", head, argvFromStdin };
634
647
  }
635
- return { kind: "unlisted" };
648
+ return { kind: "unlisted", argvFromStdin, runsDefaultProgram: lastReentry === folded.length - 1 };
649
+ }
650
+ const ARGV_REENTRY_DEFAULT_PROGRAM = "echo";
651
+ function argvReentryUnjudged(unwrapped) {
652
+ if (!unwrapped.argvFromStdin)
653
+ return false;
654
+ const runs = unwrapped.head ?? (unwrapped.runsDefaultProgram === true ? ARGV_REENTRY_DEFAULT_PROGRAM : undefined);
655
+ return runs === undefined || !NO_PATH_OPERAND_COMMANDS.has(runs);
636
656
  }
637
657
  function tokenizeSegment(segment) {
638
658
  const raw = splitWordsQuoteAware(segment);
@@ -1208,7 +1228,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1208
1228
  const split = segmentCompoundForReadonly(command, boundary?.homeDir);
1209
1229
  if ("reject" in split)
1210
1230
  return { reason: split.reject, refusedWhole: true };
1211
- const { segments, pipeFed } = split;
1231
+ const { segments, pipeFed, connectors } = split;
1212
1232
  let nameRefusal;
1213
1233
  const unlistedSegments = new Set();
1214
1234
  const reheaded = new Map();
@@ -1250,6 +1270,9 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1250
1270
  if (unwrapped.kind === "unlisted" && unwrapped.head !== undefined && SHELL_REENTRY_PROGRAM_RE.test(unwrapped.head)) {
1251
1271
  return { reason: `"${written}" runs "${unwrapped.head}", which runs its argument as shell text this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1252
1272
  }
1273
+ if (argvReentryUnjudged(unwrapped)) {
1274
+ return { reason: `"${written}" takes the operands of the program it runs from its standard input, which this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1275
+ }
1253
1276
  if (unwrapped.kind === "unlisted")
1254
1277
  unlistedSegments.add(si);
1255
1278
  else
@@ -1288,13 +1311,29 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1288
1311
  };
1289
1312
  const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity, diff: 2, cmp: 2, comm: 2, sed: 2 };
1290
1313
  const foldedSegments = [];
1314
+ const foldedReach = [];
1315
+ let crossedAllAnd = true;
1316
+ let crossedAnyOr = false;
1291
1317
  let shapeRefusal = nameRefusal;
1292
1318
  for (let si = 0; si < segments.length; si++) {
1319
+ if (si > 0) {
1320
+ if (connectors[si] !== "&&")
1321
+ crossedAllAnd = false;
1322
+ if (connectors[si] === "||")
1323
+ crossedAnyOr = true;
1324
+ }
1325
+ const moveProvable = pipeFed[si] !== true && pipeFed[si + 1] !== true && reheaded.get(si) === undefined;
1326
+ const pushReach = () => {
1327
+ foldedReach.push({ andJoined: si > 0 && crossedAllAnd, orBroken: !crossedAllAnd && crossedAnyOr, moveProvable });
1328
+ crossedAllAnd = true;
1329
+ crossedAnyOr = false;
1330
+ };
1293
1331
  if (unlistedSegments.has(si)) {
1294
1332
  const unlisted = tokenizeSegment(segments[si]);
1295
1333
  if (unlisted.folded.length === 0)
1296
1334
  continue;
1297
1335
  foldedSegments.push({ folded: [LAUNCHER_SCAN_HEAD, ...unlisted.folded], raw: [LAUNCHER_SCAN_HEAD, ...unlisted.raw] });
1336
+ pushReach();
1298
1337
  continue;
1299
1338
  }
1300
1339
  const rehead = reheaded.get(si);
@@ -1303,6 +1342,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1303
1342
  if (toks.length === 0)
1304
1343
  continue;
1305
1344
  foldedSegments.push(segmentTokens);
1345
+ pushReach();
1306
1346
  const name = toks[0];
1307
1347
  const guardReason = guardedVerbFormReason(name, toks, segmentTokens.raw);
1308
1348
  if (guardReason !== undefined) {
@@ -1383,7 +1423,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1383
1423
  shapeRefusal ??= `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}`;
1384
1424
  }
1385
1425
  if (boundary !== undefined) {
1386
- const walk = evaluateReadBoundary(foldedSegments, boundary);
1426
+ const walk = evaluateReadBoundary(foldedSegments, boundary, foldedReach);
1387
1427
  if (shapeRefusal === undefined)
1388
1428
  return walk;
1389
1429
  const { reason: _walkSentence, ...structure } = walk;
@@ -1396,7 +1436,9 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1396
1436
  }
1397
1437
  return {};
1398
1438
  }
1399
- function evaluateReadBoundary(foldedSegments, boundary) {
1439
+ const BASE_CANDIDATE_MAX = 256;
1440
+ function evaluateReadBoundary(foldedSegments, boundary, reach) {
1441
+ const seen = { outside: new Set(), inside: new Set(), undecided: new Set(), recursive: new Set(), unresolved: new Set() };
1400
1442
  const outside = [];
1401
1443
  const inside = [];
1402
1444
  let unresolvable;
@@ -1404,60 +1446,120 @@ function evaluateReadBoundary(foldedSegments, boundary) {
1404
1446
  let baseLost = false;
1405
1447
  const undecided = [];
1406
1448
  const recursive = [];
1407
- let currentBase = boundary.cwd ?? boundary.roots[0];
1408
- for (const toks of foldedSegments) {
1449
+ let candidates = [{ base: boundary.cwd ?? boundary.roots[0], skippedCd: -1 }];
1450
+ let andRunStart = 0;
1451
+ const findingsByWords = new Map();
1452
+ const findingsRowFor = (wordsKey) => {
1453
+ const row = findingsByWords.get(wordsKey);
1454
+ if (row !== undefined)
1455
+ return row;
1456
+ const fresh = new Map();
1457
+ findingsByWords.set(wordsKey, fresh);
1458
+ return fresh;
1459
+ };
1460
+ const findingsFor = (row, toks, base) => {
1461
+ const hit = row.get(base);
1462
+ if (hit !== undefined)
1463
+ return hit;
1464
+ const segBoundary = base === (boundary.cwd ?? boundary.roots[0]) ? boundary : { ...boundary, cwd: base };
1465
+ const computed = collectSegmentBoundaryFindings(toks, segBoundary);
1466
+ row.set(base, computed);
1467
+ return computed;
1468
+ };
1469
+ for (let si = 0; si < foldedSegments.length; si++) {
1470
+ const toks = foldedSegments[si];
1409
1471
  if (baseLost)
1410
1472
  break;
1411
- const segBoundary = currentBase === (boundary.cwd ?? boundary.roots[0]) ? boundary : { ...boundary, cwd: currentBase };
1412
- for (const finding of collectSegmentBoundaryFindings(toks, segBoundary)) {
1413
- if (finding.kind === "unresolvable") {
1414
- unresolvable ??= finding.reason;
1415
- if (!unresolved.includes(finding.operand))
1416
- unresolved.push(finding.operand);
1417
- if (toks.folded[0] === "cd")
1418
- baseLost = true;
1419
- continue;
1420
- }
1421
- const denied = boundary.denyMatch?.(finding.path);
1422
- if (denied != null) {
1423
- return {
1424
- reason: `a command operand resolves to "${finding.path}", which matches the sensitive-path read deny list (pattern "${denied}") ${NOT_AUTO_ALLOWED}`,
1425
- readDenied: true,
1426
- };
1427
- }
1428
- if (finding.kind === "inside") {
1429
- if (!inside.includes(finding.path))
1430
- inside.push(finding.path);
1431
- continue;
1432
- }
1433
- if (finding.kind === "undecided") {
1434
- if (!undecided.includes(finding.path))
1435
- undecided.push(finding.path);
1436
- continue;
1437
- }
1438
- if (finding.kind === "recursive") {
1439
- if (!recursive.includes(finding.path))
1440
- recursive.push(finding.path);
1441
- continue;
1442
- }
1443
- if (boundary.face === "open") {
1444
- if (!inside.includes(finding.path))
1445
- inside.push(finding.path);
1446
- continue;
1473
+ if (si > 0 && reach?.[si]?.andJoined !== true)
1474
+ andRunStart = reach?.[si]?.orBroken === true ? si + 1 : si;
1475
+ const reachable = candidates.filter((c) => c.skippedCd < andRunStart);
1476
+ const row = findingsRowFor(JSON.stringify([toks.folded, toks.raw]));
1477
+ for (const { base: currentBase } of reachable) {
1478
+ for (const finding of findingsFor(row, toks, currentBase)) {
1479
+ if (finding.kind === "unresolvable") {
1480
+ unresolvable ??= finding.reason;
1481
+ if (!seen.unresolved.has(finding.operand)) {
1482
+ seen.unresolved.add(finding.operand);
1483
+ unresolved.push(finding.operand);
1484
+ }
1485
+ if (toks.folded[0] === "cd")
1486
+ baseLost = true;
1487
+ continue;
1488
+ }
1489
+ const denied = boundary.denyMatch?.(finding.path);
1490
+ if (denied != null) {
1491
+ return {
1492
+ reason: `a command operand resolves to "${finding.path}", which matches the sensitive-path read deny list (pattern "${denied}") ${NOT_AUTO_ALLOWED}`,
1493
+ readDenied: true,
1494
+ };
1495
+ }
1496
+ if (finding.kind === "inside") {
1497
+ if (!seen.inside.has(finding.path)) {
1498
+ seen.inside.add(finding.path);
1499
+ inside.push(finding.path);
1500
+ }
1501
+ continue;
1502
+ }
1503
+ if (finding.kind === "undecided") {
1504
+ if (!seen.undecided.has(finding.path)) {
1505
+ seen.undecided.add(finding.path);
1506
+ undecided.push(finding.path);
1507
+ }
1508
+ continue;
1509
+ }
1510
+ if (finding.kind === "recursive") {
1511
+ if (!seen.recursive.has(finding.path)) {
1512
+ seen.recursive.add(finding.path);
1513
+ recursive.push(finding.path);
1514
+ }
1515
+ continue;
1516
+ }
1517
+ if (boundary.face === "open") {
1518
+ if (!seen.inside.has(finding.path)) {
1519
+ seen.inside.add(finding.path);
1520
+ inside.push(finding.path);
1521
+ }
1522
+ continue;
1523
+ }
1524
+ if (!seen.outside.has(finding.path)) {
1525
+ seen.outside.add(finding.path);
1526
+ outside.push(finding);
1527
+ }
1447
1528
  }
1448
- if (!outside.some((o) => o.path === finding.path))
1449
- outside.push(finding);
1450
1529
  }
1451
1530
  if (!baseLost && toks.folded[0] === "cd") {
1452
1531
  const cdArgs = toks.folded.slice(1);
1453
1532
  const cdTargetIdx = cdArgs.findIndex((t) => !t.startsWith("-") || t === "-");
1454
1533
  const cdTarget = cdTargetIdx === -1 ? undefined : cdArgs[cdTargetIdx];
1455
1534
  if (cdTarget !== undefined && cdTarget !== "-") {
1456
- currentBase = resolveOperandLexically(currentBase, cdTarget, boundary.homeDir) ?? currentBase;
1535
+ const provable = reach?.[si]?.moveProvable === true;
1536
+ const branched = [];
1537
+ for (const c of candidates) {
1538
+ if (c.skippedCd < andRunStart)
1539
+ branched.push({ base: resolveOperandLexically(c.base, cdTarget, boundary.homeDir) ?? c.base, skippedCd: c.skippedCd });
1540
+ branched.push({ base: c.base, skippedCd: provable ? si : c.skippedCd });
1541
+ }
1542
+ const byBase = new Map();
1543
+ for (const c of branched) {
1544
+ const prior = byBase.get(c.base);
1545
+ if (prior === undefined || c.skippedCd < prior.skippedCd)
1546
+ byBase.set(c.base, c);
1547
+ }
1548
+ const next = [...byBase.values()];
1549
+ if (next.length > BASE_CANDIDATE_MAX) {
1550
+ unresolvable ??= `too many directory changes to track where "${cdTarget}" leaves the working directory ${NOT_AUTO_ALLOWED}`;
1551
+ if (!seen.unresolved.has(cdTarget)) {
1552
+ seen.unresolved.add(cdTarget);
1553
+ unresolved.push(cdTarget);
1554
+ }
1555
+ baseLost = true;
1556
+ }
1557
+ else
1558
+ candidates = next;
1457
1559
  }
1458
1560
  }
1459
1561
  }
1460
- const undecidedAll = [...undecided, ...recursive.filter((p) => !undecided.includes(p))];
1562
+ const undecidedAll = [...undecided, ...recursive.filter((p) => !seen.undecided.has(p))];
1461
1563
  const undecidedField = {
1462
1564
  ...(undecidedAll.length > 0 ? { undecidedPaths: undecidedAll } : {}),
1463
1565
  ...(recursive.length > 0 ? { recursiveReadPaths: recursive } : {}),
@@ -1504,7 +1606,7 @@ const COMMAND_LAUNCHERS = new Set([
1504
1606
  "env", "command", "builtin", "exec", "noglob", "xargs",
1505
1607
  "sudo", "doas", "pkexec", "su", "runuser", "chroot", "setpriv",
1506
1608
  "nohup", "nice", "ionice", "chrt", "taskset", "stdbuf", "timeout", "time", "watch",
1507
- "setsid", "flock", "unshare", "nsenter", "script", "numactl", "prlimit", "systemd-run", "parallel",
1609
+ "setsid", "flock", "unshare", "nsenter", "script", "numactl", "prlimit", "systemd-run",
1508
1610
  "strace", "ltrace", "valgrind", "firejail", "bwrap",
1509
1611
  ]);
1510
1612
  export const COMMAND_LAUNCHERS_FOR_TEST = COMMAND_LAUNCHERS;
@@ -1524,8 +1626,25 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
1524
1626
  if ("reject" in split)
1525
1627
  return NOT_GATED;
1526
1628
  const readers = [];
1629
+ const readerReach = [];
1630
+ let crossedAllAnd = true;
1631
+ let crossedAnyOr = false;
1527
1632
  let baseMoved = false;
1528
- for (const segment of split.segments) {
1633
+ for (let si = 0; si < split.segments.length; si++) {
1634
+ const segment = split.segments[si];
1635
+ if (si > 0) {
1636
+ if (split.connectors[si] !== "&&")
1637
+ crossedAllAnd = false;
1638
+ if (split.connectors[si] === "||")
1639
+ crossedAnyOr = true;
1640
+ }
1641
+ const moveProvable = split.pipeFed[si] !== true && split.pipeFed[si + 1] !== true;
1642
+ const pushReader = (st) => {
1643
+ readers.push(st);
1644
+ readerReach.push({ andJoined: si > 0 && crossedAllAnd, orBroken: !crossedAllAnd && crossedAnyOr, moveProvable });
1645
+ crossedAllAnd = true;
1646
+ crossedAnyOr = false;
1647
+ };
1529
1648
  const toks = tokenizeSegment(segment);
1530
1649
  if (toks.folded.length === 0)
1531
1650
  continue;
@@ -1539,16 +1658,16 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
1539
1658
  if (UNMODELLED_BASE_MOVERS.has(name))
1540
1659
  baseMoved = true;
1541
1660
  if (!allow.has(name) && name !== "cd") {
1542
- readers.push({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
1661
+ pushReader({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
1543
1662
  continue;
1544
1663
  }
1545
- readers.push(i === 0 && name === written ? toks : { folded: [name, ...toks.folded.slice(i + 1)], raw: [toks.raw[i] ?? name, ...toks.raw.slice(i + 1)] });
1664
+ pushReader(i === 0 && name === written ? toks : { folded: [name, ...toks.folded.slice(i + 1)], raw: [toks.raw[i] ?? name, ...toks.raw.slice(i + 1)] });
1546
1665
  }
1547
1666
  if (readers.length === 0)
1548
1667
  return NOT_GATED;
1549
1668
  if (baseMoved)
1550
1669
  return { gated: true, paths: [] };
1551
- const verdict = evaluateReadBoundary(readers, containmentOnly);
1670
+ const verdict = evaluateReadBoundary(readers, containmentOnly, readerReach);
1552
1671
  return verdict.outOfRootRead === true ? { gated: true, paths: verdict.outOfRootPaths ?? [] } : NOT_GATED;
1553
1672
  };
1554
1673
  const direct = judge(command);
@@ -54,6 +54,10 @@ import { type BashReadonlyRootBoundary } from "./bash-readonly-classifier.js";
54
54
  * The other non-reversible arms stay bare on purpose — their cause is legible in the command itself (a
55
55
  * write verb, a pipe, an unlisted name), and manufacturing an account for them would put text on a card
56
56
  * that adds nothing the reader cannot already see.
57
+ *
58
+ * What the boundary half of this probe is — and what it is not — is stated once on the seat that owns it
59
+ * ({@link bashReadBoundaryProbe}, `@contract shell.read_boundary.friction_floor`); composing it with the
60
+ * shape classifier here changes what happens to the residual shell risk, never that reading.
57
61
  */
58
62
  export declare function bashReversibilityProbe(allow?: readonly string[], boundary?: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined)): (args: unknown) => ReversibilityVerdict;
59
63
  /**
@@ -99,6 +103,19 @@ export declare function bashReversibilityProbe(allow?: readonly string[], bounda
99
103
  * The reader set is the engine's classify set ({@link BASH_CLASSIFY_DEFAULT_ALLOW}) — here it is NOT an
100
104
  * auto-allow list but the set of programs whose operands the faces read; a wider set means more reads are
101
105
  * judged, never that more runs are vouched.
106
+ *
107
+ * @contract shell.read_boundary.friction_floor — what this seat answers is a question about TEXT: which
108
+ * files the words of a command may name, resolved against the declared roots, the declared home and the
109
+ * observed working directory. It is not a question about what the process will open, and no amount of
110
+ * parsing makes it one. A deployment's containment for what a shell may reach is the `ExecutionEnv` the
111
+ * command runs in (a sandbox, a container, a remote adapter); everything here is the friction floor under
112
+ * that containment — it raises the cost of an accidental or careless read, and it is not a defence against
113
+ * a command written to defeat it. The floor's last cell belongs to the full shell's own execute leg: once
114
+ * the gate has allowed a call and before anything spawns, the candidates this seat accepted are resolved
115
+ * on the filesystem, and a target that leaves the roots only after a symlink is followed is REFUSED —
116
+ * refused rather than asked, because by then the approval step is behind the call (see `createBashTool`'s
117
+ * `readBoundary` option). The leg runs where an environment can resolve a link locally; a remote adapter
118
+ * keeps the textual answer alone.
102
119
  */
103
120
  export declare function bashReadBoundaryProbe(boundary: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined)): (args: unknown) => ReversibilityVerdict;
104
121
  /**
@@ -171,6 +188,17 @@ export declare function createBashTool(env: ExecutionEnv, rootCanonical: string,
171
188
  * unconfined; passing this never gates anything.
172
189
  */
173
190
  additionalRoots?: readonly string[];
191
+ /**
192
+ * The deployment's shell READ BOUNDARY — the same seat the gate's shell probes read
193
+ * ({@link bashReadBoundaryProbe}), handed to the tool so its execute leg can close the one gap a
194
+ * synchronous seat structurally cannot: the seat judges the WRITTEN spelling, the kernel opens the
195
+ * symlink-resolved target. Passed as a FUNCTION wherever the working directory is observable (an
196
+ * in-task `cd` moves the base a relative operand resolves against); evaluated per call.
197
+ *
198
+ * Absent ⇒ no recheck and no I/O: a deployment with no read boundary declared has no roots for a
199
+ * read to be outside of, exactly as the seat itself reads an absent boundary.
200
+ */
201
+ readBoundary?: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined);
174
202
  /** Override of the `timeout` parameter's DEFAULT budget, in ms — see
175
203
  * {@link import("./index.js").HandsToolkitOptions.bashDefaultTimeoutMs} for the resolution
176
204
  * contract (options over env over constant; invalid values discarded). */