@sema-agent/core 7.49.1 → 7.49.2

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 (33) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/core/arg-summary.js +23 -5
  3. package/dist/core/credential-vocabulary.js +19 -2
  4. package/dist/core/gate-fold.js +2 -2
  5. package/dist/core/runner/prepare-gate-stations.js +2 -2
  6. package/dist/core/runner/prepare-lsp.d.ts +4 -4
  7. package/dist/core/runner/prepare-lsp.js +2 -2
  8. package/dist/core/runner/prepare-safety-scan.js +10 -6
  9. package/dist/core/runner-deps.d.ts +2 -2
  10. package/dist/core/task-spec.d.ts +2 -2
  11. package/dist/core/tool-catalog-entries.js +1 -1
  12. package/dist/core/tool-conformance.js +2 -1
  13. package/dist/core/tool-roster.d.ts +11 -6
  14. package/dist/core/tool-roster.js +8 -4
  15. package/dist/core/write-protect.d.ts +3 -1
  16. package/dist/core/write-protect.js +4 -5
  17. package/dist/tools/fs/bash-readonly-classifier.d.ts +2 -2
  18. package/dist/tools/fs/bash-readonly-classifier.js +5 -3
  19. package/dist/tools/fs/read-deny-home.d.ts +5 -1
  20. package/dist/tools/fs/read-deny-home.js +40 -19
  21. package/dist/tools/fs/shell-read-check.d.ts +1 -1
  22. package/dist/tools/fs/shell-read-check.js +29 -15
  23. package/dist/tools/fs/shell-read-environment.d.ts +74 -0
  24. package/dist/tools/fs/shell-read-environment.js +71 -0
  25. package/dist/tools/fs/shell-read-establish.d.ts +8 -4
  26. package/dist/tools/fs/shell-read-establish.js +34 -36
  27. package/dist/tools/fs/shell-read-identity.d.ts +3 -2
  28. package/dist/tools/fs/shell-read-identity.js +5 -3
  29. package/dist/tools/fs/shell-read-judge.d.ts +69 -18
  30. package/dist/tools/fs/shell-read-judge.js +27 -14
  31. package/package.json +1 -1
  32. package/wire-changes/core@7.49.1.json +57 -0
  33. package/wire-changes/core@7.49.2.json +138 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,49 @@
1
+ ## 7.49.2 — 2026-10-13
2
+
3
+ **Hard-breaking changes (#1674):** one, open to veto — #1674 (see its entry). **Closed sets:** none. **Package exports:** +0 / −0 names, no member changes (the exported data `describeToolCatalog()` / `TOOL_CONFORMANCE_VECTORS` change values, below). **Behavior:** #1674 mounts `LSP` only on a leg that has an execution env, and seeds the pre-mount effect table for a caller's own tool that shares a hand-band name with the more conservative of its declared effect and the hand's. **Known limits added** (`docs/KNOWN-LIMITS.md`): KL-1674-a.
4
+
5
+ ### A leg without hands has no LSP tool (#1674; fix; security; hard-breaking: one, open to veto) (@server @cli @client-core @test)
6
+
7
+ **Security face first — a leg with no execution env no longer mounts `LSP`.** Before, a runner built without an execution env that still carried `lspManager` mounted the tool over the engine's stub environment, whose `fileInfo` answers `not_supported` (`src/core/stub-env.ts:62`) — a precheck outcome the tool lets through (`src/core/lsp.ts:488-491` blocks only not-found / invalid / directory) — and then asked the manager for a session (`lsp.ts:506`); the in-process manager's default reader opens the path on the host's own disk (`src/engine/lsp/node-lsp-manager.ts:520`). So the one leg with no read face at all had an LSP reader outside every fence. @server: the hands-less runner is built with `withoutExecutionEnv` (`src/boot/stage-06-runners.ts:303`) and the hands lane drops only `executionEnvFactory` (`src/capabilities/hands-lane.ts:29`) — both coordinates as reported to this repository, not re-read here (inferred); a deployment-side drop of `lspManager` on that runner is the matching shape on your side.
8
+
9
+ - **Before (v7.49.1).** The catalog row said `mountedBy: ["optional"]` and the mount read `if (lspManager)`: a manager on the task seat or the deployment seat mounted the tool on every leg, hands or not (measured on a hands-less leg that also carried a caller's own tool named `LSP`: the model was offered the name twice).
10
+ - **Now.** One rule: LSP mounts where the hands are AND a manager seat resolved (`spec.lspManager ?? deps.lspManager`). The catalog row says `mountedBy: ["hands"]` and the mount site carries the same tag. A hands-less leg with a manager wired has no engine-mounted `LSP` in its roster (a caller's own tool of that name still mounts), calls it as an unknown tool when none is mounted (`Tool LSP not found.` with the live roster, `details.code` `tool.not_found`, `absence` `unknown`), never reaches the manager, and its wiring manifest still discloses the seat: `lsp: { mounted: false }`. The diagnostics lane already required hands and is unchanged. Exported data: `describeToolCatalog()`'s LSP row `mountedBy` `["optional"]` → `["hands"]`; `TOOL_CONFORMANCE_VECTORS`: `hands-full` and `hands-readonly` gain the absent row `{ LSP, "needs a wired LSP manager" }` and `plan-mode` drops it — every vector's `expected` set is unchanged; `predictMountedToolNames` of a hands-only set now names `LSP`, and `handBandEffects()` / `HANDS_BAND_TAGS` consumers see one more hand-band name (`LSP`, effect `read`). The JSDoc of `RunnerDeps.lspManager` / `TaskSpec.lspManager` and the SDK page say "legs with hands". The hand band's effect table is filled by name up front on every leg with an execution env; with `LSP` in the band, a caller's own tool of that name on a leg with no manager wired would have read `read` there whatever it declared — a declared-`write` one would pass `allowDirs` confinement, which it was refused by before. So a caller tool's own name now claims the entry the way an alias already did: the table is seeded with the less repeat-safe of its declared effect (a write when it declares none) and the hand's, for every hand-band name, not only `LSP`, and read off the instance the roster keeps when several caller tools share a name (the later one — one selection, `survivorsByName`, used by the roster, by this table and by the post-assembly sync; an earlier same-named tool's declaration leaves no entry, none declared on the kept one ⇒ write). A caller tool is written into the table by that scan alone: the roster's scenario mount no longer writes it (it used to write an absent name from whichever same-named tool arrived first, over a kept tool that declared nothing — for any tool name, not only the hand band's), and the post-assembly sync fills only from each name's kept instance. The alias mirror (effect, tier, probe, egress mark onto each alias) walks the same kept instances in the roster's order too: a dropped duplicate's alias used to be registered with the dropped instance's effect (`Scratch(read, alias a)` then `Scratch(write, alias a)`: the kept write instance runs a call by `a`, the table said `read`, and a wake-time reconcile called an interrupted such call a safe retry). The seed is what the pre-mount readers see (the wake-time reconcile, the confinement policy) until a mount sets the entry, and an engine tool that does mount (`LSP` with a manager wired) replaces it with its own effect. The table keyed by the mounted tool instead of the name is the planned root fix (#1680, 7.50.0).
11
+ - **Hard-breaking (open to veto).** (1) **What changed:** a leg without an execution env and with a manager wired no longer has the `LSP` tool (it used to). (2) **Who is hurt:** a deployment whose hands-less scenarios relied on `LSP` — server `hands: none` runs with LSP enabled lose the tool (user-visible: the model is no longer offered code-intelligence lookups there); a host that derives its hand band from the catalog (`HANDS_BAND_TAGS`, `handBandEffects`) or checks its factory against `TOOL_CONFORMANCE_VECTORS` sees the row move as above (server's memory-notice filter keys on effect ≠ read — inferred unaffected); a probe that pinned `lsp.mounted: true` on a runner with no execution env. (3) **How to migrate:** give the scenario that needs LSP an execution env (hands); a library host that really wants LSP without hands mounts `createLspTool` itself over a manager and an env it owns (that mount is the host's own: the engine fences nothing in it, KL-1674-a).
12
+ - **Known limits.** The tool on a leg WITH hands is unchanged: its `filePath` is still not a declared read target and its session reads through the manager's own reader (KL-1674-a); the read-face wiring is a later release's.
13
+ - **Pins.** `test/backlog1674-lsp-needs-hands.test.ts` (① hands-less × deps seat / task seat: not in the roster, `{ mounted: false }`, no session lookup; ② the call is the unknown-tool miss in sentence and record, manager unreached; ③ hands × both seats: mounted, `{ mounted: true, lane: "tool" }`, a call reaches the manager once; ④ catalog and roster row carry `hands`; ⑤ plan-mode / bare mount none under a wired manager, hands-full / hands-readonly flip exactly `LSP`; ⑥ hands from an execution-env factory count, and a caller's own `LSP` tool on a hands-less leg is offered once and runs); `test/backlog1674-hand-band-same-name.test.ts` (a caller tool named `LSP` declaring write / declaring none / declaring read on a leg with hands and no manager: table and `allowDirs` verdict; the engine's own LSP unclaimed; another hand-band name, `TaskOutput`, under read-only hands; ⑦ two caller tools under one name — `[read, none]`, `[none, read]`, `[read, read]`, `[write, write]` × full / read-only hands × a hand-band name (`LSP`) and an ordinary name: table, roster row and `allowDirs` verdict follow the kept instance; ⑧ the same with a shared alias — the alias entry, the instance a call by the alias runs, the `allowDirs` verdict and the wake-time reconcile's wording follow the kept instance, with the claim order of the roster); `test/tool-roster.test.ts` unchanged and green; re-pinned: `test/prepare-lsp.test.ts`, `test/prepare-lsp-transcript.test.ts`, `test/backlog789-wiring-manifest-hooks-lsp.test.ts`, `test/public-knob-liveness.test.ts`, `test/tool-catalog.test.ts` (hand-band snapshots), `test/backlog1432-deny-universe.test.ts` (hand list), `test/design388-write-fences.test.ts` (a scenario mount writes nothing), `test/backlog1167-roster-freeze.test.ts` (R28: a non-array `tools` value is refused by the roster door's code).
14
+
15
+ **Hard-breaking changes (#1672):** one exported value's shape, and one exported ordering. `SHELL_READ_FINDING_RANK` is renumbered (`unanswered` sits between `deny_hit` and `outside_roots`: 4 / 3 / 2 / 1 / 0 for `unanswered` / `outside_roots` / `unestablishable` / `declined` / `none`; `identity_changed` 6 and `deny_hit` 5 keep their numbers). `SHELL_READ_DECISION.unestablishable.full_shell` is the object `{ narrowed: "mandated", open: "ordinary" }` where it was the string `"mandated"` (every other cell stays a string), and `SHELL_READ_FINDINGS` / `SHELL_READ_DECISION` gain the member / row `unanswered`. A consumer that compares that cell to a disposition word, or switches exhaustively over the findings, reads the new shape — the readers in the family are sema-cli's dist verify scripts (`verify/_bash-gate-danger-matrix.mjs`, dev-time, they compare cells and the six-word finding set) and sema-server's `test/read-face-e2e.test.ts` (prose only); both read `typeof cell === "string" ? cell : cell.narrowed` for the engine default. **Closed sets:** `ShellReadFinding` +1 (`unanswered`). **Package exports:** +0 / −0 names. **Behavior:** #1672 loosens the mandated probe 7.49.0 made of every read the station cannot place — on an `open` read face and for a written target alone, both toward the reference implementation's plain permission ask (CC 2.1.296), open to veto; the engine-default `roots` face keeps the mandate. **Known limits added** (`docs/KNOWN-LIMITS.md`): KL-1644-c rewritten (the rewritten redirection target is the ordinary question now), KL-1672-a, KL-1672-b, KL-1672-c (a followed sed backup the text places nowhere is still read open, as in 7.49.1).
16
+
17
+ ### A read the station cannot place is the read face's question: the plain permission ask on an `open` read face and for a written target, the mandated probe on a `roots` read face (#1672; fix; loosening of 7.49.0 toward the reference; hard-breaking: one value shape; closed sets: `ShellReadFinding` +1; exports: +0 / −0) (@web-admin @server @cli @test)
18
+
19
+ - **Before (7.49.1).** Every word the read station could not place — `ls -1 $ANDROID_HOME/platforms`, `cat ~root/x`, `cat $(echo …)`, a program the shell re-enters (`sh -c '…'`), a directory change the text does not settle, a redirection or `tee` operand the shell rewrites (`> "$DEST"`) — drew the write/read face's MANDATED probe: no allow of any kind, a spec pre-approval (`allowedTools: ["Bash"]`) included, released it, and under `auto` the classifier was never asked. The reference implementation asks the same word as a plain permission ask (CC 2.1.296 `sR`, cli296.js @13116941, reason `other`: "Shell expansion syntax in paths requires manual approval"; the tilde variants likewise) that a whole-tool allow (`$pn` @18717070), bypass (`$pn` @18716980) and the auto-mode classifier release and a prefix allow does not (`vX`'s ask returns before the allow rules, @18567915). A headless team run (`-p`, `auto`) whose first command read `$ANDROID_HOME/…` hung on the card.
20
+ - **Now.** One decision-table cell, keyed by the READ FACE the question was raised under (`SHELL_READ_DECISION.unestablishable.full_shell`, read by `decideShellRead`): on an **`open`** read face — where the station asks only because a deny list has rows — the unplaced read is the ORDINARY ask: `default` / `acceptEdits` ask it, a spec pre-approval and `bypassPermissions` release it, the auto-mode classifier is consulted and its yes releases it, `dontAsk` refuses it by the mode; a stored whole-tool rule and a prefix allow keep asking it. On a **`roots`** read face (the engine default) it stays the mandated probe, as in 7.49.1: a deployment that narrowed the fence asked to be asked. A **written target** the station cannot place and nothing else (`echo x > "$DEST"`, `printf x | tee $X`) is the ordinary ask on either read face — a narrowed write face fences what is written and is no request to be asked about every word the floor cannot place; a command that also has an unplaced READ keeps the read face's reading. The read-only face and the post-approval re-judgement still refuse; the deny row, the out-of-roots mandate, the removal guards and the judge that gave no answer rank above or beside the cell and do not move. The unplaced-read card (`read_unestablished`) rides the finding on either question. A deployment that wants the plain ask for such commands declares the read face `open` (`RunnerDeps.readFace: "open"`); a lane on the engine default keeps the card.
21
+ - **A judge that did not finish is its own finding, `unanswered`**: the gate's fallback when the station timed out, threw or a lookup it stands on was absent — and, recorded at ONE door, anything the station could not finish while establishing the command: an environment lookup that gave no answer (`canonicalPath`, `listDir`, `fileInfo`, `readLink` returning an error that is no conclusive "not there"; a conclusive absence and an adapter that declares no filesystem surface are not failures, and a resolver that declared nothing and answered NOTHING in the whole command keeps the lexical reading it always had — one answer anywhere in the command, the declared roots and a write guard's probe included, makes every silence beside it a failure, whichever word it falls on; the decision is taken once, after the write guards have asked too, so a read and a write on the same resolver are read alike), a bound the station stopped at (more directories or names than it enumerates, a link chain past its hop bound), a home view that could not be read (an identity lookup that errored on the path, on a level of the declared home's chain or on an auxiliary home's, or a home whose canonical spelling did not answer), and the write guards' own probes (they ask the environment through the command's one door; what their silence means is decided with the establishment's, and folded into the verdict BEFORE the plan's `writesPlaced` is read off it). Any one makes the WHOLE command `unanswered`, whatever the words it did reach found: mandated on the full shell on every read face — a standing allow was given on the premise that the judge works — and refused on the read-only face; ranked directly under `deny_hit` (`identity_changed > deny_hit > unanswered > outside_roots > unestablishable > declined > none`), so the redirection-only ordinary cell, a grant offer, a pre-approval or the classifier cannot answer for the part it did not check. It was the `unestablishable` row in 7.49.1, and earlier cuts of this ticket let a lookup's failure fall into that row one producer at a time — the ordinary question on an open read face with a deny list, so a pre-approval or the classifier could release a read the station had not checked against the deny table (`cat link` whose resolution errored while the link led to a denied file; a glob write whose listing errored; a path spelled through a bind-mount alias of the home whose identity lookup errored). The establisher now asks the environment ONLY through the observed door (`shell-read-environment.ts`: it is handed the door, not the environment), which records each failure and bound on the establishment's `incomplete`; the fold reads that list in one rule. No producer has to remember to mark anything. The door covers every lookup the command makes — the establishment, the declared roots, the home views, the write guards AND the leg's write judgment — and the station decides what a silence means once, after all of them have asked. `ShellReadBoundary.writeTargetJudge` therefore takes the command's door in its options (`opts.fs`, required) and resolves nowhere else (type-level change on a public member: a host-supplied judge keeps compiling and should resolve in `opts.fs`; no consumer in the sibling checkouts supplies one — **inferred**, grep of the local sema-server / sema-cli trees). What stays `unestablishable` is the TEXT's own unplaced word: a word the shell rewrites, a lost working directory, a pattern the matcher does not model, a spelling it cannot place.
22
+ - **The allow lane's floor reads `~user` / `~+` / `~-` as it reads `$`** (b): a tilde word that is neither the bare `~` nor `~/…` — also after `=` / `:` in an assignment-shaped word — is an expansion word (the account database or the shell's own variables decide where it points), so a standing prefix allow no longer matches a command carrying one, the classify faces read it as outside their residual space (no longer a read-only command), and a rule body naming one is `invalid.not_simple_command` (as with `$`). The bare `~`, `~/…`, quoted and mid-word tildes are read as before. Consequence: an `ask` / `deny` rule text naming a `~user` word can no longer be minted (an org snapshot carrying one fails loudly, as one naming `$X` does).
23
+ - **Who needs it.** Any session on an `open` read face with a deny list (a single-user product) whose shell reads go through a variable, a substitution, a re-entered shell text or an unknown program: the pre-approval and the classifier answer them now. **Who is hurt.** A deployment that relied on the mandate to stop an `allowedTools: ["Bash"]` run from executing an unplaced read on an open face no longer gets it (the deny rows still refuse every spelling the station can place; an unplaceable spelling is the ordinary question). A lane on the engine-default `roots` face is unchanged for reads — it is released only by `bypassPermissions`, a person's yes, or the deployment declaring the face `open`.
24
+ - **Divergence from the reference (open to veto).** A stored whole-tool `Bash` rule does not clear this ask (the reference's `N9` does): the shape tier's irreversibility mark still asks (the rule-silencing principle, as for the redirection ask in 7.49.1). The `roots` face's mandate is sema's (C-R261b), not the reference's.
25
+ - **Consumers.** web-admin / server: a sandbox lane that mounts the engine-default read face keeps the card for `$VAR` reads; declare the lane's read face `open` to release it (this ticket does not change a deployment's face). cli: `SHELL_READ_DECISION.unestablishable.full_shell` and the finding set changed shape (above). test: re-pinned readings are the open-face / written-target cells of the pins listed below.
26
+ - Pins: `test/backlog1672-variable-read-ordinary-ask.test.ts` (shapes × read face × nine arms on the real Runner, the tilde floor's four consumers, the table's face-keyed cell, the seats, `unanswered` on both faces, the floors above the cell on both faces); re-pinned where an OPEN-face cell or a written-target cell was pinned mandated: `backlog1100`, `backlog1102-program-facts`, `backlog1102-read-station-semantics`, `backlog1421-launcher-reread`, `backlog1425-readonly-binding`, `backlog1427-write-targets-roots`, `backlog1599-delegate-auto`, `backlog1644-device-targets`, `backlog769`, `backlog791`, `backlog854`, `backlog860`; the table readers `backlog924`, `backlog1004`, `backlog1010`; the equivalence allowed lists (class `1672-tilde-variant-floor` in both) and the #994 S1 attribution rows `tilde_variant_floor`, `write_target_ordinary`.
27
+
28
+ **Hard-breaking changes (#1675 / #1676):** none. **Closed sets:** +0 / −0. **Package exports:** +0 / −0 names, no member changes (`npm run handoff:diff v7.49.1 HEAD`: 2 609 → 2 609). **Behavior:** #1675 removes a value that was left in clear after a bearer token, and #1676 stops one marker from being written cut — redaction only, nothing a run could do before is refused. **Known limits added** (`docs/KNOWN-LIMITS.md`): KL-958-c, KL-958-d, KL-958-e (and KL-958-a restated).
29
+
30
+ ### A bearer credential is the whole run to whitespace — a name=value glued to the token goes with it (#1675; fix) (@server @cli @client-core @test)
31
+
32
+ - **Before (7.49.1, and 7.49.0).** `Authorization: Bearer <vendor token><connector><name>=<value>` left `<value>` in clear when the name was no credential keyword or sat glued to the token. The token pass takes the token (its body takes `_` `-`), the bearer row stopped at `=`, and the keyword pass found no name for the value. 7.48.0 hid it, in the `Authorization:` spelling, only by accident: its bearer row took the token, and the `Authorization:` keyword row's value run then ate the glued remainder. On a 19 000-text constructed probe (seeded; eleven token families × connectors `_` `-` `.` / none × ten keyword and ten plain names × seven wrappers; values and tokens built at run time) 7.49.1 left at least 6 characters of the value visible in 383 of 6 000 bearer-wrapped texts where 7.48.0 did not (3 488 of 6 000 in all on 7.49.1, most of them texts where 7.48.0 showed it too — a token row that takes the name before the bearer row reads the text; 4 386 on 7.48.0).
33
+ - **Now.** The bearer row's wide form reads the credential the way the header does: the token68 run and, when `=` follows it, whatever is glued after the `=` up to the same delimiters the keyword row's value run ends at (whitespace, a quote, `;`, `|`, `&` — one constant, read by both rows). The tail starts at `=` only: `Bearer <token>,next=1` still ends at the comma. A quote right after the `=` is read by the character that follows it. A boundary (`,` `}` `]` `:`, white space, another quote, the end) = the quote that closes a string around the text: the `=` is the token's padding and no tail starts. Anything else = an opening quote: the value up to the next quote of the same spelling goes whole, quotes included (bounded to 1 024 characters, one line; a backslash escape closes with the same word behind exactly the same number of backslashes — a quote of another depth inside is value content). An opening quote with no closing quote is read as an unquoted value, to its usual end and no further: it never lets the value through. Whether a string around the text closes inside that run is not something a local reading can tell, so it does not try: when the text sits inside a string whose own quotes are written as escapes (a backslash and a quote, a Unicode escape, `%22`), the run takes that string's encoded closing quote and the members after it in the same segment with the value, over-redaction being this face's cheap direction (known-limits row). The spellings: `"` and `'`; the backslash escapes `\"` `\'` `\x22` `\x27`, the Unicode escapes (a backslash, then `u0022` or `u0027`) and the octal `\042` `\047`, behind up to 63 backslashes; `&quot;` `&apos;` `&#34;` `&#39;`; `%22` `%27`. Lower case only (`\X22` and `\U0022` are no quotes), which is why the header word is spelled out (`[Bb][Ee]…`) instead of carrying the `i` flag. Any other backslash after the `=` is value content, and the tail starts. The run gives back the backslashes that escape the quote closing it (up to 63 — six layers of JSON inside JSON; the check looks a bounded 63 characters ahead, so a backslash run of any length costs a bounded amount per hit), so `\"Bearer <token>=\"` inside a string keeps its escape. A hit that ran over the next credential's keyword (`…,Bearer` at the end of a glued value) no longer hides that credential: the engine scans every vocabulary row on the original text from one character after each hit's start instead of at its end — bounded to eight characters of matches per character of input; a scan that runs out of that work stops, and everything from its last hit's start to the end of the text goes as one hit (over-redaction, never a leak; known-limits row) — and the union with the sequential run removes what the run left. No vendor row, character class or word list changed; the strict (refuse-write) form is untouched. The pass engine's existing union carries the enclosing hit, so the vendor token's own finding is still reported beside the bearer finding, whose span is now the whole credential. Same probe: 0 of 6 000 bearer-wrapped texts show a value on any of the three faces (the activity summary is pinned separately, A1), 0 show a token, and no text shows a value or a token that 7.48.0 hid.
34
+ - **Who is hurt.** Nobody who relied on a value surviving: text after `Bearer <token>=` up to whitespace is part of the credential now (over-redaction is this face's cheap direction). A host that counts findings sees the bearer finding's span reach the end of the glued tail.
35
+ - **Consumers.** None needs to act.
36
+ - Pins: `test/backlog1675-bearer-tail-and-url-marker.test.ts` A (two token families × four wrappers × four connectors × six names on four faces; the delimiters; the header word's case; JSON inside JSON up to six layers; every quote spelling × bare / spaced / comma-and-brace / inside a JSON string, opening and closing; the escape-depth boundary; a second credential whose keyword the first hit's run took in; a scan that runs out of work, fail-closed; a two-million-character run; the finding's span) and D (the seeded probe against the frozen v7.48.0 chain: ≥ 2 000 texts, no value or token the old reading hid is shown, every marker whole).
37
+
38
+ ### A replacement marker is never cut: a URL userinfo that holds a token and a keyword-named tail (#1676; fix) (@server @cli @client-core @test)
39
+
40
+ - **Before (7.49.0 and 7.49.1).** `https://user:<vendor token>_password=<value>@host/path` (the `curl -H` spelling too) read `https://user:-credentials]` on the same-principal tier: the marker lost its head and the host went with the value. The userinfo replacement is a function, so the engine inferred what it keeps from the characters it shares with the match; the match had run to the vendor token's marker `[redacted`, which `[redacted-credentials]` starts with, so the kept head reached into the marker and the union wrote the rest. The value was not shown; the output was broken. 933 of 4 000 constructed URL texts on 7.49.1 (0 on 7.48.0).
41
+ - **Now.** What a function replacement keeps at each end never reaches into the pass's own marker literal (the last occurrence in what it wrote); a replacement that writes no marker literal is read as before. Same probe: 0 broken markers, and no value or token shown, on the same-principal and human tiers.
42
+ - **Not changed (KL-958-c).** When the name is a credential keyword, the keyword hit read on the original runs over the `@` (a value run ends at whitespace), so the host tail still goes with it: 1 727 of 2 046 such texts on 7.49.1 and now, 887 on 7.48.0 (only for the families whose token body does not take the name). The host is no secret; the loss is the over-redacting direction. Closing it needs one more engine rule (text a pass explicitly keeps bounds the hits read on the original) and is left to 7.50.0.
43
+ - **Who is hurt.** Nobody; the broken marker was never meaningful. A host that parsed `[redacted-credentials]` out of these outputs now finds it.
44
+ - **Consumers.** None needs to act.
45
+ - Pins: `test/backlog1675-bearer-tail-and-url-marker.test.ts` B (both families, both name classes, the `curl` spelling, bare / userinfo tokens) and C (the engine law on synthetic passes — head side and tail side).
46
+
1
47
  ## 7.49.1 — 2026-10-12
2
48
 
3
49
  **Hard-breaking changes:** none. **Closed sets:** `OptionMode` +1 (`queries`; internal to the program read model, not exported). **Package exports:** +0 / −0 names, no member changes (`SHELL_WRAPPER_TABLE` is byte-equal: a lookup switch is no peel flag; the two types #1583 / #1640 add live in a runner-internal module). **Behavior:** #1583 removes a value that was left in clear next to a credential-shaped name and #1640 makes a line say what already happened — nothing a run could do before is refused; #1643 and #1644 restore two readings 7.49.0 made too strict to the shell's and the reference's own — both are loosenings of 7.49.0 toward 7.48.0 and the reference implementation (CC 2.1.296), each open to veto. **Known limits added** (`docs/KNOWN-LIMITS.md`): KL-1643-a / -b, KL-1644-a / -b / -c (and the rows #1583 / #1640 carry).
@@ -75,26 +75,44 @@ function removedRegion(pass, match, groups, inserted) {
75
75
  tail = "";
76
76
  return [head.length, match.length - tail.length];
77
77
  }
78
+ const markerAt = pass.marker === "" ? -1 : inserted.lastIndexOf(pass.marker);
79
+ const headStop = markerAt < 0 ? inserted.length : markerAt;
80
+ const tailStop = markerAt < 0 ? inserted.length : inserted.length - markerAt - pass.marker.length;
78
81
  let head = 0;
79
- while (head < match.length && head < inserted.length && match[head] === inserted[head])
82
+ while (head < match.length && head < headStop && match[head] === inserted[head])
80
83
  head++;
81
84
  let tail = 0;
82
- while (tail < match.length - head && tail < inserted.length - head && match[match.length - 1 - tail] === inserted[inserted.length - 1 - tail])
85
+ while (tail < match.length - head && tail < inserted.length - head && tail < tailStop && match[match.length - 1 - tail] === inserted[inserted.length - 1 - tail])
83
86
  tail++;
84
87
  return [head, match.length - tail];
85
88
  }
89
+ const SOLO_SCAN_WORK_PER_CHAR = 8;
86
90
  function soloHits(input, passes) {
87
91
  const hits = [];
88
92
  passes.forEach((pass, i) => {
89
- for (const m of input.matchAll(new RegExp(pass.re.source, pass.re.flags.includes("g") ? pass.re.flags : `${pass.re.flags}g`))) {
93
+ const re = new RegExp(pass.re.source, pass.re.flags.includes("g") ? pass.re.flags : `${pass.re.flags}g`);
94
+ const rescans = pass.family !== undefined;
95
+ let work = SOLO_SCAN_WORK_PER_CHAR * input.length;
96
+ for (let m = re.exec(input); m !== null; m = re.exec(input)) {
97
+ const at = m.index;
98
+ const familyAt = () => (typeof pass.family === "function" ? pass.family(input, at) : pass.family);
99
+ if (rescans) {
100
+ work -= m[0].length;
101
+ if (work < 0) {
102
+ hits.push({ start: at, end: input.length, marker: pass.marker === "" ? REDACTED_WHOLE : pass.marker, pass: i, family: familyAt(), matchStart: at, matchEnd: input.length });
103
+ break;
104
+ }
105
+ re.lastIndex = at + 1;
106
+ }
107
+ else
108
+ re.lastIndex = at + Math.max(m[0].length, 1);
90
109
  const inserted = insertedFor(pass, m[0], m.slice(1));
91
110
  if (inserted === m[0])
92
111
  continue;
93
112
  const [a, b] = removedRegion(pass, m[0], m.slice(1), inserted);
94
113
  if (b <= a)
95
114
  continue;
96
- const at = m.index;
97
- const family = typeof pass.family === "function" ? pass.family(input, at) : pass.family;
115
+ const family = familyAt();
98
116
  hits.push({ start: at + a, end: at + b, marker: inserted.slice(a, inserted.length - (m[0].length - b)), pass: i, family, matchStart: at, matchEnd: at + m[0].length });
99
117
  }
100
118
  });
@@ -1,5 +1,22 @@
1
1
  export const URI_SCHEME_SOURCE = String.raw `[A-Za-z][A-Za-z0-9+.\-_]{0,63}`;
2
2
  const VENDOR_BODY = "[A-Za-z0-9_-]{8,}";
3
+ const VALUE_END = String.raw `\s"';|&`;
4
+ const VALUE_CHAR = `[^${VALUE_END}]`;
5
+ const ESCAPE_DEPTH = 63;
6
+ const QUOTED_VALUE_MAX = 1024;
7
+ const PLAIN_QUOTES = [`"`, `'`, `&quot;`, `&apos;`, `&#34;`, `&#39;`, `%22`, `%27`];
8
+ const ESCAPED_QUOTES = [`"`, `'`, `x22`, `x27`, `u0022`, `u0027`, `042`, `047`];
9
+ const BACKSLASHES = String.raw `\\{1,${ESCAPE_DEPTH}}`;
10
+ const QUOTE = `(?:${PLAIN_QUOTES.join("|")}|${BACKSLASHES}(?:${ESCAPED_QUOTES.join("|")}))`;
11
+ const AFTER_CLOSING_QUOTE = String.raw `(?:[\s,}\]:]|["']|$)`;
12
+ const GLUED_RUN = `${VALUE_CHAR}*(?:(?<!\\\\)|(?!\\\\{0,${ESCAPE_DEPTH}}["']))`;
13
+ const pairedValue = (open, close) => `${open}(?!${AFTER_CLOSING_QUOTE})[^\\n]{0,${QUOTED_VALUE_MAX}}?${close}`;
14
+ const QUOTED_VALUE = [
15
+ ...PLAIN_QUOTES.map((q) => pairedValue(q, q)),
16
+ ...ESCAPED_QUOTES.map((q, i) => pairedValue(`(${BACKSLASHES})${q}`, `(?<!\\\\)(?:\\${i + 2})${q}`)),
17
+ `${QUOTE}(?!${AFTER_CLOSING_QUOTE})${GLUED_RUN}`,
18
+ ].join("|");
19
+ const BEARER_TAIL = `(?<==)(?:(?!${QUOTE})${GLUED_RUN}|${QUOTED_VALUE})`;
3
20
  export const CREDENTIAL_VOCABULARY = {
4
21
  values: [
5
22
  { id: "github-token", kind: "prefixed-token", source: "arg-summary", confidence: "high", pass: "vendor-prefixed", match: new RegExp(`(?<![A-Za-z0-9])gh[opsur][-_]${VENDOR_BODY}`, "g"), strict: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/ },
@@ -14,9 +31,9 @@ export const CREDENTIAL_VOCABULARY = {
14
31
  { id: "huggingface-token", kind: "prefixed-token", source: "arg-summary", confidence: "high", match: /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{20,}/g, strict: /\bhf_(?=[A-Za-z0-9]*[0-9])[A-Za-z0-9]{20,}\b/ },
15
32
  { id: "jwt", kind: "jwt", source: "arg-summary", confidence: "high", match: /(?<![A-Za-z0-9])eyJ[A-Za-z0-9._-]{20,}/g, strict: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{16,}\b/ },
16
33
  { id: "private-key", kind: "private-key-block", source: "arg-summary", confidence: "high", match: /-----BEGIN[ A-Z0-9]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END[ A-Z0-9]*PRIVATE KEY(?: BLOCK)?-----|$)/g, strict: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/ },
17
- { id: "bearer-token", kind: "bearer-token", source: "arg-summary", confidence: "high", match: /(?<![A-Za-z0-9])Bearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, strict: /\bAuthorization:\s*Bearer\s+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{20,}=*/ },
34
+ { id: "bearer-token", kind: "bearer-token", source: "arg-summary", confidence: "high", match: new RegExp(String.raw `(?<![A-Za-z0-9])()[Bb][Ee][Aa][Rr][Ee][Rr]\s+[A-Za-z0-9._~+/-]{8,}=*(?:${BEARER_TAIL})?`, "g"), strict: /\bAuthorization:\s*Bearer\s+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{20,}=*/ },
18
35
  { id: "azure-storage-account-key", kind: "prefixed-token", source: "arg-summary", confidence: "high", match: /(AccountKey=)[A-Za-z0-9+/]{40,}={0,2}/g, strict: /AccountKey=[A-Za-z0-9+/]{40,}={0,2}/ },
19
- { id: "keyword-value", kind: "keyword-value", source: "arg-summary", confidence: "medium", match: /(?<![A-Za-z0-9])((?:token|secret|key|password|passwd|credential|authorization)["']?\s*[=:]\s*["']?)[^\s"';|&]{4,}/gi },
36
+ { id: "keyword-value", kind: "keyword-value", source: "arg-summary", confidence: "medium", match: new RegExp(String.raw `(?<![A-Za-z0-9])((?:token|secret|key|password|passwd|credential|authorization)["']?\s*[=:]\s*["']?)${VALUE_CHAR}{4,}`, "gi") },
20
37
  { id: "url-userinfo", kind: "url-credentials", source: "untrusted-egress", confidence: "medium", match: new RegExp(String.raw `(${URI_SCHEME_SOURCE}:\/\/)((?:[^/\s"'@]*@)+)`, "gi") },
21
38
  { id: "signed-url-param", kind: "signed-url-param", source: "untrusted-egress", confidence: "high", match: /([?&#](?:sig|signature|sas|code)=)[^&\s"'#]+/gi },
22
39
  ],
@@ -194,7 +194,7 @@ function applyShellReadVerdict(pass, verdict, toolName, shellGated) {
194
194
  pass.targetUnresolved = true;
195
195
  if (verdict.unplacedOptions !== undefined)
196
196
  pass.shellUnplacedOptions = verdict.unplacedOptions;
197
- if (verdict.finding === "unestablishable" || verdict.finding === "declined" || verdict.unestablished === true)
197
+ if (verdict.finding === "unestablishable" || verdict.finding === "declined" || verdict.finding === "unanswered" || verdict.unestablished === true)
198
198
  pass.shellReadUnestablished = true;
199
199
  switch (verdict.disposition) {
200
200
  case "vouch":
@@ -244,7 +244,7 @@ function applyShellReadVerdict(pass, verdict, toolName, shellGated) {
244
244
  pass.shellReadOffer = true;
245
245
  pass.tightenedBy = shellGated ? "shell_gate" : "safety";
246
246
  }
247
- if (verdict.finding === "declined")
247
+ if (verdict.finding === "declined" || verdict.finding === "unestablishable")
248
248
  pass.probeBoundaryDeclined = true;
249
249
  return;
250
250
  }
@@ -10,7 +10,7 @@ import { askUnresolvableNotice } from "../ask-unresolvable-notice.js";
10
10
  import { askReachesPerson } from "../wiring-manifest.js";
11
11
  import { classifierUnavailableSeat } from "./classifier-ladder.js";
12
12
  import { PATH_CONFINABLE_WRITE_TOOLS } from "./session-rule-policy.js";
13
- import { callFaceSeat, rosterToolName, toolCallFaceOf } from "../tool-roster.js";
13
+ import { callFaceSeat, rosterToolName, survivorsByName, toolCallFaceOf } from "../tool-roster.js";
14
14
  import { heldBy, heldValues, seatOf, wireOf } from "../tool-name-readings.js";
15
15
  import { createApprovedCallScreen, createToolResultStation } from "./call-hook-stations.js";
16
16
  const PEER_MESSAGE_NAMES = new Set([SEND_MESSAGE_TOOL_NAME]);
@@ -73,7 +73,7 @@ export function prepareGateStations(input) {
73
73
  if (askLane !== undefined && parkAsk !== undefined) {
74
74
  const { adjudicate, resolveAskBound, notifyPermissionDenied, notifyHookError } = askLane;
75
75
  const { resolveContentAsk, suspendAsk } = parkAsk;
76
- for (const t of tools) {
76
+ for (const t of survivorsByName(tools).values()) {
77
77
  const eff = t.effect;
78
78
  if (eff !== undefined && !toolEffects.has(t.name))
79
79
  toolEffects.set(t.name, eff);
@@ -3,8 +3,8 @@
3
3
  * LSP tool mount over the per-task env with its git-ignore filter, the diagnostics lane behind its four
4
4
  * preconditions (a manager whose sessions feed a registry, write-capable hands, not opted out), the run-scoped
5
5
  * registry key, and the edit nudge (raw path → the SAME resolution the tool leg uses → registry reset +
6
- * session didChange). No manager ⇒ nothing mounts and both lane seats are undefined. The interface is the
7
- * dependency list the segment had implicitly.
6
+ * session didChange). No manager, or no hands (the manifest still reports the seat, `mounted: false`), ⇒ nothing mounts and
7
+ * both lane seats are undefined. The interface is the dependency list the segment had implicitly.
8
8
  *
9
9
  * SYNCHRONOUS FUNCTION, SYNCHRONOUS CALL: the stretch has no await, so the phase adds no yield between the
10
10
  * fork-governance note before it and the memory trio's occupancy read after it (a stretch
@@ -28,7 +28,7 @@ export interface PrepareLspInput {
28
28
  /** borrowed-readonly — the SETTLED task root: the tool's git-ignore root, and the nudge's resolution base when
29
29
  * no cwd is tracked. */
30
30
  taskRootFinal: string;
31
- /** borrowed-readonly — whether a real fs env is present (the lane's hands precondition). */
31
+ /** borrowed-readonly — whether a real fs env is present (the tool mount's and the lane's hands precondition). */
32
32
  handsEnabled: boolean;
33
33
  /** borrowed-readonly — the acquired session id: the run's key into the deployment-shared registry. */
34
34
  sessionId: string;
@@ -36,7 +36,7 @@ export interface PrepareLspInput {
36
36
  * resolution base (identity is the contract — the band's `cd` handling writes this exact cell). */
37
37
  handsCwdRef: Prepared["cwdRef"];
38
38
  /** borrowed-mutable — the run's roster builder (identity is the contract). Writer here: ONE mount (the LSP tool)
39
- * when a manager exists. */
39
+ * when a manager exists and the leg has hands. */
40
40
  roster: RosterBuilder;
41
41
  /** borrowed-mutable — the env-hand membership set the read-posture observer projects; writer here: `add("LSP")`
42
42
  * on the mount. */
@@ -4,9 +4,9 @@ import { RosterBuilder } from "../tool-roster.js";
4
4
  export function prepareLsp(input) {
5
5
  const { spec, deps, executionEnv, taskRootFinal, handsEnabled, sessionId, handsCwdRef, roster, envHandToolNames } = input;
6
6
  const lspManager = spec.lspManager ?? deps.lspManager;
7
- if (lspManager) {
7
+ if (lspManager && handsEnabled) {
8
8
  const lspRoot = taskRootFinal;
9
- roster.mount(createLspTool(lspManager, { isPathIgnored: gitCheckIgnoreFilter(executionEnv, lspRoot), env: executionEnv }), { source: "builtin", mountedBy: "optional" });
9
+ roster.mount(createLspTool(lspManager, { isPathIgnored: gitCheckIgnoreFilter(executionEnv, lspRoot), env: executionEnv }), { source: "builtin", mountedBy: "hands" });
10
10
  envHandToolNames.add("LSP");
11
11
  }
12
12
  const lspDiagnostics = spec.lspDiagnostics !== false && lspManager?.diagnostics !== undefined && handsEnabled && spec.handsReadOnly !== true
@@ -1,15 +1,16 @@
1
1
  import { NAMESPACED_NAME_SHAPES, carriesNamespaceSeparator } from "../protocol-table.js";
2
2
  import { engineCardTypes, handBandEffects } from "../tool-registry.js";
3
- import { callerCardIdOf, offendingResultCard, rosterMemberBoundProblem, schemaKeyBoundProblem } from "../tool-roster.js";
3
+ import { callerCardIdOf, offendingResultCard, rosterMemberBoundProblem, schemaKeyBoundProblem, survivorsByName } from "../tool-roster.js";
4
4
  import { toolFaceProblem } from "./tool-face-overlay.js";
5
5
  import { OFFLOAD_TOOL_NAME } from "../tool-result-store.js";
6
6
  import { PRESENT_PLAN_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME } from "../present-plan-tool.js";
7
7
  const REPEAT_RISK = { read: 0, idempotent: 1, write: 2 };
8
8
  const leastRepeatSafe = (a, b) => (REPEAT_RISK[a] >= REPEAT_RISK[b] ? a : b);
9
+ const tierOf = (t) => t.irreversibility ?? (t.reversibilityProbe ? "maybe" : undefined);
9
10
  export function prepareSafetyScan(input) {
10
11
  const { spec, deps } = input;
11
12
  const toolEffects = new Map();
12
- const ownToolNames = new Set((spec.tools ?? []).map((t) => t.name));
13
+ const survivors = survivorsByName(spec.tools ?? []);
13
14
  const claimedAliases = new Set();
14
15
  const egressTools = new Set();
15
16
  const irreversibleTools = new Set();
@@ -76,10 +77,10 @@ export function prepareSafetyScan(input) {
76
77
  throw e;
77
78
  }
78
79
  }
79
- if (t.effect) {
80
+ if (t.effect && survivors.get(t.name) === t) {
80
81
  toolEffects.set(t.name, t.effect);
81
82
  }
82
- const tier = t.irreversibility ?? (t.reversibilityProbe ? "maybe" : undefined);
83
+ const tier = tierOf(t);
83
84
  if (tier !== undefined) {
84
85
  irreversibilityTier.set(t.name, tier);
85
86
  if (tier === "always" || tier === "maybe") {
@@ -98,8 +99,11 @@ export function prepareSafetyScan(input) {
98
99
  egressTools.add(t.name);
99
100
  markedBy(t.name, "deployment");
100
101
  }
102
+ }
103
+ for (const t of survivors.values()) {
104
+ const tier = tierOf(t);
101
105
  for (const alias of t.aliases ?? []) {
102
- if (alias === t.name || ownToolNames.has(alias))
106
+ if (alias === t.name || survivors.has(alias))
103
107
  continue;
104
108
  if (claimedAliases.has(alias))
105
109
  continue;
@@ -122,7 +126,7 @@ export function prepareSafetyScan(input) {
122
126
  }
123
127
  }
124
128
  toolEffects.set(OFFLOAD_TOOL_NAME, "read");
125
- const claimerEffects = new Map([...claimedAliases].map((a) => [a, toolEffects.get(a) ?? "write"]));
129
+ const claimerEffects = new Map([...claimedAliases, ...survivors.keys()].map((a) => [a, toolEffects.get(a) ?? "write"]));
126
130
  const recordHandEffect = (name, effect) => {
127
131
  const claimed = claimerEffects.get(name);
128
132
  toolEffects.set(name, claimed === undefined ? effect : leastRepeatSafe(claimed, effect));
@@ -1249,10 +1249,10 @@ export interface RunnerDeps {
1249
1249
  * declaration refusing the batch legs). Absent = no posture check anywhere.
1250
1250
  */
1251
1251
  interactionPosture?: "interactive" | "headless";
1252
- /** LSP code-intelligence seam (design/64 §13.1): when set, every task mounts an `lsp` tool routed to this
1252
+ /** LSP code-intelligence seam (design/64 §13.1): when set, every task WITH HANDS mounts an `lsp` tool routed to this
1253
1253
  * manager (a deployment-provided language-server bridge, e.g. an E2B LSP sidecar). The manager returns
1254
1254
  * `undefined` for a file whose language has no server, and the tool degrades to "fall back to grep". A
1255
- * task's own `lspManager` overrides this. Unset ⇒ no `lsp` tool. */
1255
+ * task's own `lspManager` overrides this. Neither seat set, or a leg without an execution env ⇒ the engine mounts no `lsp` tool. */
1256
1256
  lspManager?: import("./lsp.js").LspServerManager;
1257
1257
  /**
1258
1258
  * The Artifacts hosting seat — a storage/service-tier seam of the same family as
@@ -602,8 +602,8 @@ export interface TaskSpec {
602
602
  * AskUserQuestion tool is mounted (one of the delivery faces in the criterion — see
603
603
  * {@link interactiveTools}). Overrides `RunnerDeps.onQuestion`. Distinct from `onAsk` (permission). */
604
604
  onQuestion?: import("./ask-question.js").OnQuestion;
605
- /** LSP code-intelligence seam (design/64 §13.1): when set, the `lsp` tool is mounted and routed to this
606
- * manager. Overrides `RunnerDeps.lspManager`. Unset ⇒ no `lsp` tool (the model uses grep/read_file). */
605
+ /** LSP code-intelligence seam (design/64 §13.1): when set, the `lsp` tool is mounted on a leg with hands and routed to
606
+ * this manager. Overrides `RunnerDeps.lspManager`. Neither seat set, or no hands ⇒ the engine mounts no `lsp` tool (the model uses grep/read_file). */
607
607
  lspManager?: import("./lsp.js").LspServerManager;
608
608
  /**
609
609
  * design/121 (CC 198 parity): after the agent edits a file, NEW language-server diagnostics are
@@ -98,7 +98,7 @@ export const TOOL_CATALOG_ENTRIES = [
98
98
  },
99
99
  },
100
100
  { id: "repo-map", name: "RepoMap", source: "builtin", definedIn: "src/tools/fs/repo-map.ts", mountedBy: ["hands"], cards: ["repo-map"], face: { family: "search", effect: "read", contract: c("core.repo_map@1") } },
101
- { id: "lsp", name: "LSP", source: "builtin", definedIn: "src/core/lsp.ts", mountedBy: ["optional"], cards: [], face: { family: "search", effect: "read", contract: c("core.lsp@1") } },
101
+ { id: "lsp", name: "LSP", source: "builtin", definedIn: "src/core/lsp.ts", mountedBy: ["hands"], cards: [], face: { family: "search", effect: "read", contract: c("core.lsp@1") } },
102
102
  {
103
103
  id: "artifact", name: ARTIFACT_TOOL_NAME, source: "builtin", definedIn: "src/tools/artifact/artifact-tool.ts", mountedBy: ["optional"], cards: ["artifact"],
104
104
  face: { family: "other", effect: "write", contract: c("core.artifact@1"), ruleFace: { primaryParams: ["action"] }, renderHints: { summaryParams: ["action", "file_path", "url"] } },
@@ -14,6 +14,7 @@ export const TOOL_CONFORMANCE_VECTORS = [
14
14
  { name: "SendMessage", why: "needs the runner self seat (a direct prepare has none)" },
15
15
  { name: "AgentTranscript", why: "needs the runner self seat (a direct prepare has none)" },
16
16
  { name: "ListAgents", why: "mounts with the delegation face only under a runner self seat (a direct prepare has none)" },
17
+ { name: "LSP", why: "needs a wired LSP manager" },
17
18
  ], [OFFLOAD_READER]),
18
19
  vector("hands-readonly", ["always", "hands"], [
19
20
  { name: "Edit", why: "the read-only band mounts the read half only" },
@@ -23,9 +24,9 @@ export const TOOL_CONFORMANCE_VECTORS = [
23
24
  { name: "TaskStop", why: "the background-shell pair mounts on the full shell only" },
24
25
  { name: "EnterWorktree", why: "the worktree pair needs the tracked cwd the full shell owns" },
25
26
  { name: "ExitWorktree", why: "the worktree pair needs the tracked cwd the full shell owns" },
27
+ { name: "LSP", why: "needs a wired LSP manager" },
26
28
  ], [OFFLOAD_READER]),
27
29
  vector("plan-mode", ["always", "optional"], [
28
- { name: "LSP", why: "needs a wired LSP manager" },
29
30
  { name: "Artifact", why: "needs a wired artifact host" },
30
31
  { name: "Skill", why: "needs a skills listing" },
31
32
  { name: "StructuredOutput", why: "needs an output schema" },
@@ -392,6 +392,11 @@ export declare function ruleFaceOfInstance(schema: TSchema | undefined, declared
392
392
  primaryParams: string[];
393
393
  params: string[];
394
394
  };
395
+ /** The instance each NAME dispatches to when several are mounted under it: the LATER mount (the harness keeps the
396
+ * later one). The one selection — the roster's snapshot and every table keyed by a caller tool's name read it here. */
397
+ export declare function survivorsByName<T extends {
398
+ readonly name: string;
399
+ }>(mounted: readonly T[]): Map<string, T>;
395
400
  /** What a mount site knows that the instance does not: where the tool came from and which condition fired. */
396
401
  export interface RosterMountMeta {
397
402
  source: RosterSource;
@@ -443,13 +448,13 @@ export declare class RosterBuilder {
443
448
  get tools(): readonly AgentTool[];
444
449
  get length(): number;
445
450
  metaOf(tool: AgentTool): RosterMountMeta | undefined;
446
- /** Mount one instance at the tail. An instance that DECLARES an effect writes it into the leg's effect map —
447
- * the later mount under a name is the one the harness dispatches to (`snapshot`'s yield rule), so its
451
+ /** Mount one instance at the tail. An engine-lane instance that DECLARES an effect writes it into the leg's effect
452
+ * map — the later mount under a name is the one the harness dispatches to (`snapshot`'s yield rule), so its
448
453
  * declaration is the map's truth for that name; a pre-mount registration (the hand band, the plan pair, an
449
- * MCP axis) stands only for names no mounted instance declares. ONE lane is different by construction: the
450
- * `scenario` lane's declarations were latched by the safety scan BEFORE any mount, on the synchronous
451
- * doors+scan stretch (a microtask cannot flip them) — so a scenario mount never re-reads its instance's
452
- * effect over a registered name; an engine lane's mount under that name still displaces it. */
454
+ * MCP axis) stands only for names no mounted instance declares. The `scenario` lane never writes the map: its
455
+ * declarations were latched by the safety scan BEFORE any mount (on the kept instance of each name, on the
456
+ * synchronous doors+scan stretch — a microtask cannot flip them), so a scenario mount, kept or dropped, leaves
457
+ * the map as the scan made it; an engine lane's mount under that name still displaces it. */
453
458
  mount(tool: AgentTool, meta: RosterMountMeta): void;
454
459
  /** An ENGINE-INJECTED instance the harness list carries outside the mount array (the ToolSearch lane): a roster
455
460
  * row — the model can call it — never a mount-array element (the disclosure lane owns that list and appends the
@@ -294,6 +294,12 @@ function capRenderHints(h) {
294
294
  out.approvalCard = h.approvalCard;
295
295
  return Object.keys(out).length > 0 ? out : undefined;
296
296
  }
297
+ export function survivorsByName(mounted) {
298
+ const winner = new Map();
299
+ for (const t of mounted)
300
+ winner.set(t.name, t);
301
+ return winner;
302
+ }
297
303
  export const faceOf = (t) => t;
298
304
  function configError(code, message) {
299
305
  const e = new Error(message);
@@ -333,7 +339,7 @@ export class RosterBuilder {
333
339
  throw configError("config.tool_roster_mount_tag", `tool ${JSON.stringify(tool.name)}: mount tag ${JSON.stringify(meta.mountedBy)} is outside the closed set`);
334
340
  this.#meta.set(tool, meta);
335
341
  const eff = faceOf(tool).effect;
336
- if (eff !== undefined && !(meta.mountedBy === "scenario" && this.#effects.has(tool.name)))
342
+ if (eff !== undefined && meta.mountedBy !== "scenario")
337
343
  this.#effects.set(tool.name, eff);
338
344
  }
339
345
  mountAll(tools, meta) {
@@ -382,9 +388,7 @@ export class RosterBuilder {
382
388
  }
383
389
  snapshot(facts) {
384
390
  const all = [...this.#tools, ...this.#engineLane];
385
- const winner = new Map();
386
- for (const t of all)
387
- winner.set(t.name, t);
391
+ const winner = survivorsByName(all);
388
392
  const entries = [];
389
393
  const emitted = new Set();
390
394
  for (const t of all) {
@@ -1,4 +1,5 @@
1
1
  import type { ExecutionEnv } from "../internal/harness-types.js";
2
+ import type { ObservedFilesystem } from "../tools/fs/shell-read-environment.js";
2
3
  import type { PermissionResult } from "./tool-policy.js";
3
4
  import { type ToolCallLike } from "./tool-registry.js";
4
5
  /**
@@ -231,7 +232,8 @@ export interface LegWriteJudgment {
231
232
  * write rows together — one reading of the target (its spelling, its real target, its symlink chain; a `canonical` key
232
233
  * judged as a key), one exempt root, one absence law. `undefined` ⇒ no row covers it. Absent when neither table has a
233
234
  * row. */
234
- readonly judgeWriteTarget: ((target: string, opts?: {
235
+ readonly judgeWriteTarget: ((target: string, opts: {
236
+ readonly fs: ObservedFilesystem;
235
237
  readonly signal?: AbortSignal;
236
238
  readonly canonical?: true;
237
239
  }) => Promise<WriteTargetCoverage | undefined>) | undefined;
@@ -223,7 +223,7 @@ export function compileLegWriteJudgment(entries, opts) {
223
223
  judgeWriteTarget: rows.length === 0 && guardRows === undefined
224
224
  ? undefined
225
225
  : async (target, judgeOpts) => {
226
- const hit = await compiled.judgment(target, { ...(judgeOpts?.signal !== undefined ? { signal: judgeOpts.signal } : {}), ...(judgeOpts?.canonical === true ? { canonical: true } : {}) }, guardRows);
226
+ const hit = await compiled.judgment(target, { ...(judgeOpts.signal !== undefined ? { signal: judgeOpts.signal } : {}), ...(judgeOpts.canonical === true ? { canonical: true } : {}) }, judgeOpts.fs, guardRows);
227
227
  return hit === undefined ? undefined : hit.unreadable === undefined && TABLE_ROWS.has(hit.row) ? "protected" : "guarded";
228
228
  },
229
229
  };
@@ -283,9 +283,8 @@ function compileWriteJudgment(rows, origin, opts) {
283
283
  const base = typeof cwd === "string" && cwd.length > 0 ? cwd : undefined;
284
284
  return base !== undefined ? anchorSpelling(pathFamilyOf({ cwd: base }), base, path) : path;
285
285
  };
286
- const env = opts?.env;
287
286
  const rootPath = opts?.rootPath;
288
- const judgment = async (path, ctx, extraSource) => {
287
+ const judgment = async (path, ctx, env, extraSource) => {
289
288
  let noted = false;
290
289
  const noteAbsence = (cause, unresolvedSymlink) => {
291
290
  if (noted)
@@ -384,13 +383,13 @@ function compileWriteJudgment(rows, origin, opts) {
384
383
  };
385
384
  const matcher = {
386
385
  rows,
387
- targetView: env === undefined ? "spelling-only" : "target",
386
+ targetView: opts?.env === undefined ? "spelling-only" : "target",
388
387
  matchPath(path, cwd) {
389
388
  const hit = judgePathRows(tableAt([]), { views: [spelledView(anchoredSpelling(path, cwd), "lexical")] }, "tighten");
390
389
  return hit === undefined ? null : { name: hit.row.name, kind: hit.row.kind };
391
390
  },
392
391
  async judge(path, ctx = {}) {
393
- const hit = await judgment(path, ctx);
392
+ const hit = await judgment(path, ctx, opts?.env);
394
393
  if (hit === undefined)
395
394
  return { kind: "clear" };
396
395
  if (hit.unreadable !== undefined)
@@ -167,8 +167,8 @@ export interface StrictResidualOptions {
167
167
  * 1. the quote-blind class ({@link SHELL_SEGMENT_QUOTE_BLIND_REJECT}) over every written spelling — a word, a
168
168
  * redirection target under `tolerateRedirection` — plus every group, here-document, line-break connector
169
169
  * and (unless tolerated or data-free) redirection token;
170
- * 2. the expansion character where bash expands it ({@link expandsDollar}), the home-directory word excepted
171
- * under `homeDeclared`;
170
+ * 2. the expansion character where bash expands it and a `~user` / `~+` / `~-` tilde word
171
+ * ({@link expandsBeyondText}), the home-directory word excepted under `homeDeclared`;
172
172
  * 3. a backgrounding `&` and a `|&`;
173
173
  * 4. a word whose value the text does not settle ({@link UNRESOLVED_SENTENCE}) — a compound array, an
174
174
  * off-table ANSI-C escape, a subscript or parameter expansion this lexer does not read.
@@ -26,20 +26,22 @@ function hasBareShellOperator(s, quotedOperatorsAreText) {
26
26
  for (const t of lexed.segments[0].tokens) {
27
27
  if (t.kind !== "word")
28
28
  return true;
29
- if (expandsDollar(t) || t.assignment?.form === "compound")
29
+ if (expandsBeyondText(t) || t.assignment?.form === "compound")
30
30
  return true;
31
31
  if (t.pieces.some((p) => p.quotedBy === "\\" || (p.quotedBy === undefined && p.literal !== undefined && /[\r\\]/.test(p.literal))))
32
32
  return true;
33
33
  }
34
34
  return false;
35
35
  }
36
- function expandsDollar(w) {
36
+ function expandsBeyondText(w) {
37
37
  if (w.quotedSpans.some((q) => q.by === '$"'))
38
38
  return true;
39
39
  for (const p of w.pieces) {
40
40
  const k = p.expansion?.kind;
41
41
  if (k === "parameter" || k === "command" || k === "process" || k === "arithmetic")
42
42
  return true;
43
+ if (k === "tilde" && p.expansion?.body !== "~")
44
+ return true;
43
45
  if (p.literal !== undefined && (p.quotedBy === undefined || p.quotedBy === '"') && /[$`]/.test(p.literal))
44
46
  return true;
45
47
  }
@@ -126,7 +128,7 @@ export function strictResidualRefusal(lexed, source, opts = {}) {
126
128
  }
127
129
  const home = opts.homeDir !== undefined && !textBindsHome(source) ? opts.homeDir : undefined;
128
130
  for (const w of words) {
129
- if (expandsDollar(w) && !(home !== undefined && homeSpelledForClassify(w, home) !== undefined))
131
+ if (expandsBeyondText(w) && !(home !== undefined && homeSpelledForClassify(w, home) !== undefined))
130
132
  return reason;
131
133
  }
132
134
  for (const s of lexed.segments) {
@@ -93,7 +93,7 @@ export type HomeViewEnv = Pick<ExecutionEnv, "homeDir" | "canonicalPath" | "file
93
93
  export interface HomeViewSource {
94
94
  /** The home views of one judgment (a tool call, a command's establishment, one search): a view per directory is read
95
95
  * once and shared by every path below it. */
96
- views(signal?: AbortSignal): HomeViews;
96
+ views(signal?: AbortSignal, env?: HomeViewEnv): HomeViews;
97
97
  }
98
98
  /** A judgment's views. `of` answers the view only where it differs from the path as spelled; `undefined` otherwise, and
99
99
  * wherever it cannot be read: no `fileIdentity` seat, no home, a level of the path whose identity fails (the path is
@@ -103,6 +103,10 @@ export interface HomeViews {
103
103
  of(path: string, opts?: {
104
104
  readonly leaf?: "file";
105
105
  }): Promise<string | undefined>;
106
+ /** The paths whose identity lookup gave no answer in this judgment (a throw, a timeout, an error that is no conclusive "not
107
+ * there"): for them `of` answered `undefined` for want of a reading, not because there is no view — a consumer that
108
+ * must not vouch past an unread view (the shell station) reads this after its lookups. Live: grows as `of` is asked. */
109
+ readonly unread: readonly string[];
106
110
  }
107
111
  /** A judge that carries the home views of the placement its rows were compiled at. */
108
112
  export interface HomeViewsCarrier {