@sema-agent/core 7.49.1 → 7.49.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/dist/core/arg-summary.js +23 -5
- package/dist/core/credential-vocabulary.js +19 -2
- package/dist/core/gate-fold.js +2 -2
- package/dist/core/runner/prepare-gate-stations.js +2 -2
- package/dist/core/runner/prepare-lsp.d.ts +4 -4
- package/dist/core/runner/prepare-lsp.js +2 -2
- package/dist/core/runner/prepare-safety-scan.js +10 -6
- package/dist/core/runner-deps.d.ts +2 -2
- package/dist/core/task-spec.d.ts +2 -2
- package/dist/core/tool-catalog-entries.js +1 -1
- package/dist/core/tool-conformance.js +2 -1
- package/dist/core/tool-roster.d.ts +11 -6
- package/dist/core/tool-roster.js +8 -4
- package/dist/core/write-protect.d.ts +3 -1
- package/dist/core/write-protect.js +4 -5
- package/dist/tools/fs/bash-readonly-classifier.d.ts +2 -2
- package/dist/tools/fs/bash-readonly-classifier.js +5 -3
- package/dist/tools/fs/read-deny-home.d.ts +5 -1
- package/dist/tools/fs/read-deny-home.js +40 -19
- package/dist/tools/fs/shell-read-check.d.ts +1 -1
- package/dist/tools/fs/shell-read-check.js +29 -15
- package/dist/tools/fs/shell-read-environment.d.ts +74 -0
- package/dist/tools/fs/shell-read-environment.js +71 -0
- package/dist/tools/fs/shell-read-establish.d.ts +8 -4
- package/dist/tools/fs/shell-read-establish.js +34 -36
- package/dist/tools/fs/shell-read-identity.d.ts +3 -2
- package/dist/tools/fs/shell-read-identity.js +5 -3
- package/dist/tools/fs/shell-read-judge.d.ts +69 -18
- package/dist/tools/fs/shell-read-judge.js +27 -14
- package/package.json +1 -1
- package/wire-changes/core@7.49.1.json +57 -0
- package/wire-changes/core@7.49.2.json +138 -0
- package/wire-changes/core@7.49.3.json +142 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
+
## 7.49.3 — 2026-10-13
|
|
2
|
+
|
|
3
|
+
**Hard-breaking changes (#1685, #1693, #1691, #1692):** none. **Closed sets:** none (`READ_CANDIDATE_PROVENANCES`, `SHELL_READ_FINDINGS`, `ASK_CLASSES`, `PERSISTED_RULE_MANDATES` unchanged). **Package exports:** +0 / −0 names (`npm run handoff:diff v7.49.2 HEAD`: 2 609 → 2 609); member changes, all optional: `ReadCandidate.device` / `through`, and the plan's redirection record `fd` (`ShellReadSegment.redirects[]`). **Behavior:** #1685 makes the reference's device table a non-question at every entry of the shell read station and of the shape tier — a loosening for the standard streams (open to veto: it departs from the reference at the output-redirection, read-operand and write-operand entries), and a one-line tightening for `touch` / `mkdir` of the null device; #1693 makes an environment non-answer a bypass-immune ask — a tightening that restores what 7.49.2's own documentation promised; #1691 removes a value that was left in clear after a bearer token (a name=value glued to the token by `:` `,` `@` `!` `;` `|` `&`) and hides the character that joins it; #1692 gives the overlapping re-scan's work allowance a floor (2^17 characters of matches, binding below 16 KB of input only), so a short text with a dense line is read in full instead of losing every line behind it — a scan that still runs out of work masks to the end of the input exactly as in 7.49.2 — redaction only, nothing a run could do before is refused. **Known limits added / closed** (`docs/KNOWN-LIMITS.md`): KL-1644-a closed; KL-1685-b / -c / -d / -e / -f added; KL-958-f, KL-958-g, KL-958-h added (KL-958-d and KL-958-e restated).
|
|
4
|
+
|
|
5
|
+
### The standard-device table is no containment question at any entry — and no write to a standard stream is a write (#1685; fix; loosening toward the reference's device predicate, one step stricter on rebinding; hard-breaking: none; closed sets: +0; exports: +0 / −0) (@test @cli @server @client-core @web-admin)
|
|
6
|
+
|
|
7
|
+
- **Before (v7.49.2).** Only `> /dev/null` (and a `tee` / `-o` operand written at the landing seat) was exempt from the containment question. Every other spelling of the reference's device table (`wwe`, cli296.js @18511591: `/dev/null` `/dev/stdin` `/dev/stdout` `/dev/stderr` `/dev/tty`, `/dev/fd/<digits>`, `/proc/self/fd/{0,1,2}`) asked at every entry: `echo x > /dev/stdout` the ordinary redirection question; `cat /dev/stdin`, `cat < /dev/tty`, `git diff /dev/null`, `ls /dev/tty` the MANDATED probe (a spec pre-approval did not release it); `echo x > /dev/fd/9` the unanswered probe (the host process holds no descriptor 9); `cp a.txt /dev/stdout` and `echo x | tee /dev/stderr` the probe. The shape tier kept `/dev/null` as the one redirection that writes nothing (`read-only-shell.ts`, the classify faces' `2>/dev/null`), so `echo x > /dev/stdout` also asked in `default`. A program's write to a device, and a rebinding to one, drew the shape tier's question in `default` as the file writes they are, measured on the real Runner (identical on both read faces): `echo x | tee /dev/tty`, `echo x | tee /dev/fd/2` and `cp a.txt /dev/stdout` the mandated probe ("writes outside the directories allowed"); `echo x | tee /dev/null a.txt`, `exec >/dev/null` and `exec 3>/dev/null; echo x >&3` the ordinary "performs an irreversible action".
|
|
8
|
+
- **Now.** ONE predicate (`src/tools/fs/shell-read-device.ts`: the table, read AS WRITTEN — `/dev/./null` is a path) is read by every entry: an output redirection's target, an input redirection's source, a read operand (a root of a tree walk included), the content a program writes through an operand (`tee`, an output file an option names, a plain copy's destination), a `git diff` operand — on both read faces, every mode — and by the shape tier's durability reading (the read-only reader and the classify faces' data-free `2>` read it for redirections; the read-only reader reads it for a program that writes nothing but standard devices — `tee` and `cp`, by the rows' own write facts — and for a bare `exec` whose redirections are devices; a program's device operand is read against the same binding map the station's rebinding rule reads — every redirection of the whole text binds its descriptor, a single one, a read, an `exec`'s and a `{name}` one included — so `cp b.txt /dev/fd/3 3<a.txt` and `exec 3<a.txt; echo x | tee /dev/fd/3` keep asking, where `tee /dev/fd/2` with nothing bound is quiet). A standard device is established as spelled (the filesystem is not asked where `/dev/fd/9` leads) and the write-protection table is not asked about it. In `default`, `echo x > /dev/stdout`, `echo x 2> /dev/stderr`, `cat a >> /dev/tty`, `ls 2>/dev/stderr`, `echo x | tee /dev/tty`, `echo x | tee /dev/fd/2`, `cp a.txt /dev/stdout`, `exec >/dev/null` and `exec 3>/dev/null; echo x >&3` run as quietly as `> /dev/null`; the durable ones keep the shape tier's ordinary question: `echo x > ./inroot.txt`, `echo x | tee a.txt`, `echo x | tee /dev/null a.txt` (one written operand is a file), `cp a.txt b.txt`, `exec >out.txt`, `curl -o /dev/null …` (it also sends), `dd … of=/dev/null` (no row, KL-1685-f).
|
|
9
|
+
- **What is NOT exempt.** (1) The exemption is I/O THROUGH the node: `rm` `mv` `mkdir` `touch` `ln` `chmod`, an in-place `sed`, a copy with an option word (`-f` `-b` `-l` `--remove-destination`: they replace or link the entry; any word starting with `-` counts, a bare `--` included), `find … -delete`, and any word of a program the station holds no row for, are judged as any path (the program read model's write roles carry a new `through` mark, claimed only by the rows that state how they handle their output files: `tee`'s operands, `curl`'s output files and a plain copy's destination — not a program's output option as such, which may name files beside the one it is given: `strace -ff -o F` writes `F.<pid>`, `curl --no-clobber -o F` writes `F.1` beside an existing `F`, `--remove-on-error` unlinks it; an option a row does not explain withdraws the mark). (2) A descriptor spelling names whatever the descriptor is bound to when it is opened: where the SAME command line rebinds it (`3< f`, `exec 3>f`, `1<f > /dev/stdout`, `{fd}>f`, a numbered here-document) or the station cannot read the line in full (`eval "$X"`, `source`, an unreadable text), it is judged at its landing as before — **one step stricter than the reference, which exempts it whatever the text does**. The null device and the terminal name no descriptor and are exempt always. (3) A spelling outside the table (`/dev/sda`, `/dev/shm/x`, `/proc/self/environ`, `/proc/self/fd/3`, `/dev/./null`) is a path. (4) The generator devices (`/dev/zero`, `/dev/random`, `/dev/urandom`, `/dev/full`) are no stream of the process: a read of one is no question only while the program read model knows it is bounded — `head -c N` with a small literal byte bound (the existing `byteBoundIsSmall` fact, now hoisted to the model); `head -n`, `cat /dev/zero`, `dd count=`, `od -N` keep asking (KL-1685-c).
|
|
10
|
+
- **Divergence from the reference (open to veto).** The reference exempts the table only at its input-redirection check, its git-diff operands, its read-path list and its write list, `/dev/null` alone at the output-redirection check (`lvr` @18502854) and four devices at `tee` (`Bwr` @18478565); here every entry reads the whole table — looser at the output-redirection, read-operand and write-operand entries (the owner's ruling: "the whole table asks nothing"). The rebinding rule is stricter than the reference (above).
|
|
11
|
+
- **Who needs it.** Every run that prints to its own streams (`> /dev/stderr`, `tee /dev/tty`, `cat /dev/stdin`, `grep -f /dev/stdin`, `git diff --no-index /dev/null file`, `curl -o /dev/null`, `head -c 16 /dev/urandom | base64`) under `roots` read face: the mandated probe a pre-approval did not clear is gone. **Who is hurt.** (a) `touch /dev/null` and `mkdir /dev/null` now ask (7.49.2 let them through only because they write at the landing seat — an artifact, not a design: they are metadata / namespace operations), and so do the output options of rows that do not claim the mark (`strace -o /dev/null`, `find … -fprint /dev/null`, `time -o` …: 7.49.2 exempted `/dev/null` alone for them, whatever the option did beside it); (b) a line that rebinds a descriptor asks where a reference would not (`exec 3>f; echo x > /dev/fd/3`, `cat /dev/stdin < f`); (c) `dd if=a.txt of=/dev/null` has no row in the program read model, so it keeps asking — the mandated probe on `roots`, the shape tier's ordinary question on `open` (KL-1685-f; a pre-approval and `bypassPermissions` release the second); (d) in `default`, a descriptor-naming device passes the shape tier only as the line's one redirection (`echo x > /dev/stdout 2> f.txt` still asks; KL-1685-b); (e) `tee` carries no declared option, so `echo x | tee -a /dev/null` (any option word on `tee`, any word starting with `-` on `cp` / `mv`, a bare `--` included) is judged as a path: on an `open` read face the 7.49.2 station vouched it (the null device at the landing seat) and `default` asked the shape tier's ordinary question; now the station asks the mandated one (a pre-approval no longer releases it), on `roots` as it already did (KL-1685-e); on `roots` it already asked.
|
|
12
|
+
- **Compensation.** None needed for (a) (`> /dev/null` and `: > /dev/null` are the idioms); (b) is the old behavior; (c) and (d) are released by the arms that release any ordinary ask.
|
|
13
|
+
- **Consumers.** cli: `verify/_bash-gate-danger-matrix.mjs` (classify-face strict arms) is unchanged for `> /dev/null` (the data-free face still passes only `2>`); `_bypass-disclosure-live-pty.mjs` reads `probe_mandate` / `probe_unanswered` on bypass releases (unchanged for outside-roots and unplaced reads). server: `test/read-face-e2e.test.ts` pins `released.askClass: "probe"` for a read outside the roots (unchanged); a deployment whose runs print to `/dev/stderr` stops getting the card. test: re-pinned readings are listed below; the equivalence corpora ignore the new marks.
|
|
14
|
+
- Pins: `test/backlog1685-device-table-exempt.test.ts` (① eleven table spellings × seven entries × two read faces × three arms on the real Runner; ② controls — the namespace operations, a copy with an option word, a rebound descriptor, the spellings outside the table, an unbounded generator, the in-root write — each reading as 7.49.2 gave, but for `touch` / `mkdir` of the null device; ③ the judge over the rebinding rule, the unreadable-line rule, quoting, the program's rows; ④ the plan's `device` mark and the write roles' `through` mark; ⑤ the shape tier's readings, directly and on the real Runner in `default` — the quiet forms and the durable controls above, the classify faces still admitting readers only). Re-pinned: `backlog1427-write-targets-roots` (the stream spellings are no finding; the rebound ones are), `backlog1644-device-targets` (the redirection cell now pins a file and a device NODE outside the table), `backlog7300-shell-reentry` (its `</dev/null` was an accidental read outside the roots), `backlog945-plan` (the redirection record carries its descriptor number), `program-read-model.structure` (the role schema has `through`), `module-size-ratchet` (plan / establish ceilings, with reasons), the equivalence corpora's views (the marks are the station's facts) and the #994 dual-run attribution `device_unasked`.
|
|
15
|
+
|
|
16
|
+
### An environment that gave no answer is an unreadable shell text, not a probe a mode releases (#1693; fix; security; hard-breaking: none, open to veto; exports: +0 / −0) (@server @cli @client-core @test @web-admin)
|
|
17
|
+
|
|
18
|
+
- **Before (v7.49.2).** The `unanswered` finding — a resolver error or timeout, a listing, link or identity lookup that errored, a bound the station stopped at — was mandated, but the gate stamped it as the `probe` class. `bypassPermissions` RELEASES the `probe` class with a disclosure: a real run under bypass answered `cat <path the environment could not resolve>` with `allowed`, `released: { askClass: "probe" }` and the content sent. `docs/PERMISSION-MODES.md` said "never released by a mode" — that sentence was false (**7.49.2 erratum**).
|
|
19
|
+
- **Now.** Every missing answer is the one finding `unanswered` — the fold's (a lookup errored, a bound was hit) and the station's own fallback, which the gate substitutes when the judge hung past the gate's bound or threw: the same infrastructure silence, not a fact about the command. The gate stamps an `unanswered` ask with the existing class of "the engine cannot read what this command touches", `unverifiable_shell_text` (a guard plus the real-approval bit: no mode releases it, no rule or prefix allow clears it, the classifier is not consulted); `probe` stays for a boundary the engine COULD read (outside the roots, a spelling the text leaves unplaced on a narrowed read face). Under `default`, `acceptEdits`, a pre-approval, a stored rule, a prefix allow and **`bypassPermissions`** a person is asked; `dontAsk` refuses. No new class, no new table row.
|
|
20
|
+
- **Who needs it.** (a) The `auto` half is the one that bites a multi-tenant host (it refuses `bypassPermissions`): on an `open` read face, `permissionMode: "auto"` sent an environment non-answer to the auto-mode classifier and its `allow` released the command, at five lookup sites — a read operand, the declared home view, a level of the home's identity chain, a write whose only answered lookup is the write guard's own probe, a directory listing behind a glob (the redirection and write-operand `target_unresolved` asks never consulted the classifier). Now the classifier is not asked, the question reaches a person as `unverifiable_shell_text`, and `dontAsk` refuses; pinned per site × fault × arm. (b) A deployment that runs `bypassPermissions` against a backend whose filesystem lookups can fail (SSH, remote adapters): a read the environment could not resolve no longer runs unasked. (c) A lane that declares its `canonicalPath` the authority on where a path lands (the k8s lane among them) reads a broken file-system face as `unanswered` — silence is a failure there, not a reason to fall back to the text — so every one of its face failures meets this class instead of a mode's release. **Who is hurt.** Such a run, and a host running `auto` mode on an `open` read face (the classifier no longer answers this ask; 7.49.2 consulted it there); a `bypassPermissions` host whose environment cannot resolve a written target (`touch loop/x` where `loop` is a link loop) is now asked instead of released — the run is refused by the mode where no one can answer. **Compensation.** Fix the environment, or answer the ask (the card names the unplaced word).
|
|
21
|
+
- **A card detail.** The card's `mandate` word is absent for this ask (its reason is the unreadable-text door, not a table word) — including where `target_unresolved` also holds (`touch loop/x` with `loop -> loop`: the class set is `unverifiable_shell_text` + `target_unresolved`, the mode refuses it under bypass). The persisted-rule table words are unchanged.
|
|
22
|
+
- **Not in this entry.** Unreadable rules (`rule_unreadable`) get their origin from the rules door's own mint.
|
|
23
|
+
- **Consumers.** server / cli: a card whose `askClass` was `probe` for an environment non-answer now reads `unverifiable_shell_text` (no `released.askClass: "probe"` for it); `docs/PERMISSION-MODES.md` states it.
|
|
24
|
+
- Pins: `test/backlog1693-unanswered-environment-class.test.ts` (① the verdict's origin bit — resolver, listing, bound, and the station's own fallback; absent on every other finding; ② three environment shapes × both read faces × eight arms on the real Runner: asked, never released, the classifier never consulted, the class never `probe`; ④ a judge that hangs past the gate's bound (a lookup that never settles) × `bypassPermissions` / `auto` (classifier allowing and blocking) / `dontAsk` / `default` / a pre-approval × both read faces: asked once with the class `unverifiable_shell_text`, nothing served, the classifier zero calls, `dontAsk` refuses; ③ controls: an unplaced word and a read outside the roots keep the `probe` and bypass still releases it). Re-pinned: `backlog1672-variable-read-ordinary-ask` (⑦ ⑨: the environment matrix; ⑦b: one cell per site — operand / home / homechain / onlywrite / glob — × injected `canonicalPath` / `fileIdentity` / `listDir` fault × arm, on the `open` face under `default`, a pre-approval, `bypassPermissions`, `auto` with the classifier allowing and blocking, and `dontAsk`, and on the `roots` face under `auto` and `dontAsk`: the classifier is never consulted, the class is `unverifiable_shell_text`, `dontAsk` refuses; a tripwire cell records that the station asks `fileInfo` only about the copy target), `backlog955-real-lane-matrix` (F5, `touch loop/x`: the first two arms carry the class `unverifiable_shell_text`, the `bypassPermissions` arm is `denied:mode`; the other forms are Write-tool or non-environment readings and do not move), `backlog1092-mandate-word` (the `target_unresolved` word is derived from its fact; the shell-lane card is a two-reason ask with no word), `backlog222-recursive-read-demote` (the bound card), `module-size-ratchet` (gate-fold).
|
|
25
|
+
|
|
26
|
+
### A bearer credential is the whole run to whitespace — whatever character joins a name=value to the token (#1691; fix; erratum to 7.49.2) (@server @cli @client-core @test)
|
|
27
|
+
|
|
28
|
+
- **Before (7.49.2, 7.49.1, 7.49.0).** The 7.49.2 entry said "a bearer credential is the whole run to whitespace — a name=value glued to the token goes with it". The glued tail started only behind an `=`: a name=value joined to the token by a token68 character (`_` `-` `.`) or by nothing went with it, but one joined by `:` `,` `@` `!` `;` `|` `&` did not — `Bearer <token>:auth=<value>` (a name outside the keyword list: `auth`, `apikey`, `sig`) printed `[redacted]:auth=<value>` on every face. v7.48.0 hid part of that family only by accident (the `Authorization:` keyword row's value run ate what followed). **Erratum:** the 7.49.2 claim that "no text shows a value or a token that 7.48.0 hid" (its probe's connector set was `_` `-` `.` / none) did not hold for the connectors `: , @ ! ; | &`. As reported to this repository (test, 2 268 forms): 1 176 forms show a value on 7.49.2 and on 7.49.1, and v7.48.0 had hidden 224 of them. Re-measured here (252 constructed texts: 7 connectors × 4 names × bare / `Authorization:` / JSON member × 3 token families; values built at run time): the value is shown in 165 / 156 / 156 texts on `scrubSecrets` / `redactSecrets` / `redactHostLeaks` on 7.49.2 and 141 / 132 / 132 on v7.48.0.
|
|
29
|
+
- **Now.** The bearer row's tail starts behind the token68 run and its `=` padding at the first non-white-space character that is no quote — `:` `,` `@` `!` `;` `|` `&`, or the first character of a value behind an `=` — takes that character whole, whatever it is, then runs to its usual end (white space, a quote, `;` `|` `&`). Everything else of the grammar is unchanged: a quote behind the `=` padding is read by the character that follows it and pairs with its own spelling at its own depth; an opening quote with no closing quote falls back to the value run; the engine's overlapping re-scan is untouched; no new table. A quote right behind the token68 run (no `=`) is the quote of a string around the text — nobody glues a quoted value to a bare token — so it starts no tail, in every spelling and whatever follows it: `'Authorization: Bearer <token>') && echo 'done'`, `x="Bearer <token>"&y=1` and `{"a":"Bearer <token>","b":1}` keep their structure exactly as in 7.49.2 (read by the character that follows it, as a quote behind an `=` is, the first two would be taken for the opening of a quoted value and swallow the rest of the command). Same probe: the value is shown in 0 of 252 texts on every face; the 2 400-text seeded random stratum shows no value on any face (7.49.2: 997 / 984 / 982), no token the v7.48.0 reading hid, every marker whole; the outer JSON of the one- and two-layer wrappers parses exactly where it parsed on 7.49.2 (JSON inside JSON: 165 / 169 / 169 broken on both, 142 / 146 / 146 on v7.48.0 — the keyword-row and signed-URL limits, KL-958-d ⑤). Token68 connectors (`/` `+` `.` `-` `_` `~`) read as before.
|
|
30
|
+
- **Who needs it.** Any host that prints, logs or forwards an `Authorization` header or a command line holding a bearer token that is followed by a connector and a name=value (a cookie-ish `…:sid=…`, a `,realm=…`, a `@host` suffix, a `;sig=…`), where the name is not a credential keyword.
|
|
31
|
+
- **Who is hurt.** Text glued to a bare token without a space now goes with it (over-redaction is this face's cheap direction): `(Bearer <token>) more` prints `([redacted] more`, `` `Bearer <token>` `` loses its closing backtick, `echo Bearer <token>|base64` prints `echo [redacted]`, `Bearer <token>, next` prints `[redacted] next`. On 1 800 constructed texts (a token, one of 18 glued characters or sequences, ordinary text after; seeded) 1 303 lose exactly that one character against 7.49.2; the four quote sequences and the token's own `.` are unchanged (KL-958-g). A host that counts findings sees the bearer finding's span reach the end of the glued tail. An unquoted connector-glued tail also runs over an ENCODED closing quote of a string around the text (`"`, `%22`, an HTML entity), exactly as a tail behind an `=` already did (KL-958-d ③): `{"payload":"{"auth": " Bearer <token>:auth=<value>"}"}` keeps its outer document but the inner string is cut at the marker — the value is hidden where 7.49.2 printed it with the structure intact (over-redaction preferred over structure).
|
|
32
|
+
- **Compensation.** Put a space (or a quote) behind the token; nothing else to do.
|
|
33
|
+
- **Not changed (KL-958-f).** `Bearer <token>:auth="<value>"` — a QUOTED value behind a connector-glued name — still prints `[redacted]"<value>"`, and so does a quote straight behind the connector (`Bearer <token>,"auth":"<value>"`): the tail's run ends at a quote, and reading past it needs a grammar extension. v7.48.0 and 7.49.2 print them too; the unquoted value, and the quoted value behind a token68 character or an `=`, are read.
|
|
34
|
+
- Pins: `test/backlog1691-bearer-connectors.test.ts` H (3 token families × 4 wrappers × 7 connectors × 4 names on four faces, exact output; nested JSON up to six layers; every quote spelling × 14 followers directly behind the token; the commonest hosts of that shape; the `=` padding's quote reading; the token68 connectors; the declared limits) and S (the seeded probe against the frozen v7.48.0 chain, ≥ 2 000 texts); re-pinned: `backlog1675` A3 (`Bearer <token>,next=1` no longer ends at the comma), `backlog954` G5 (the row's source).
|
|
35
|
+
|
|
36
|
+
### The overlapping re-scan's work allowance has a floor, so a dense line no longer masks the lines behind it (#1692; fix) (@server @cli @client-core @test)
|
|
37
|
+
|
|
38
|
+
- **Before (7.49.2).** The overlapping re-scan of a vocabulary row (7.49.2, #1675) was bounded to eight characters of matches per character of input; a scan that ran out of work masked everything from the start it stopped at to the END OF THE INPUT (a start left unread is a credential left in clear, so the over-strip is deliberate). A start nested in a run costs about half the run, so a dense same-line run — `password=<v>,password=<v>,…` joined by `,` `:` `/` with no white space — ran a SHORT text out of work at about twenty assignments, and every line behind it went (as reported by test: k = 400 on a 3.6 KB line masked about 88 KB behind it). Measured on 7.49.2 (`password=<12-character value><n>` × k joined by one separator, then ` and tail words`, then 2 or 40 ordinary lines; 36 cells = 3 separators × k ∈ {24, 50, 100, 103, 104, 400} × 2 line counts, on `scrubSecrets` / `redactSecrets` / `redactHostLeaks`): the lines behind the dense line survived on 3 of 36 cells (k = 24 with 40 lines behind); on v7.48.0 on 36 of 36; the value was shown in none.
|
|
39
|
+
- **Now.** The allowance has a floor: `max(8 × input length, 2^17)` characters of matches per row and scan (`SOLO_SCAN_WORK_FLOOR` = 131 072, one constant beside `SOLO_SCAN_WORK_PER_CHAR`; no new rule). Two bounds: the floor is the larger term only below 16 KB of input (`8 × 16 384 = 2^17`) and changes nothing from there on; and a row spends it at most once in each scan of a call (the format-free view, the raw text, up to 64 format-character cuts), so one call costs at most about `66 × 131 072` ≈ 8.6 million regex steps. Below the floor the re-scan is always read in full. A scan that still runs out of work does what 7.49.2's did and nothing else: ONE hit from the start that ran out to the end of the input, then that row's scan stops. The same 36 cells on this tree: the lines behind survive on 24 of 36 (every cell with k ≤ 103) and are masked on the 12 with k ≥ 104, as on 7.49.2; the value is shown in none. The threshold k₀ (smallest k that runs out; one line alone, two lines behind): **104** with 12-character values, 72 with 40-character values, 137 with 4-character values; the same line inside 100 KB of ordinary text: 265 (the allowance follows the text there, as before); inside about 1 MB: 829. A credential and a bearer token on a line behind the dense line are hidden on both sides of the threshold (read there, masked with the rest of the input at and above it). A bare-keyword run (`password=` × 2 000; it runs out from 171 starts on) with its value on the NEXT line (LF, CRLF, lone CR, blank lines between) hides that value: it is masked with the rest of the input; a roomier text reads it.
|
|
40
|
+
- **Who needs it.** Any host whose redacted text can carry one dense machine-generated line (a joined config dump, a query string of many `password=` assignments) among ordinary lines: before, a short text with about twenty such assignments lost every line behind it.
|
|
41
|
+
- **Who is hurt.** One white-space-free run of k₀ or more nested starts (about a hundred `password=` assignments joined without white space in a text under 16 KB) still masks the rest of the input from where the allowance ran out — unchanged from 7.49.2, fail-closed (KL-958-e). Time: one call at the threshold takes 0.4–0.8 ms (median, quiet machine); the worst case is the product above, measured on a 9 KB text with 64 zero-width characters and 400 nested assignments: `scrubSecrets` 19 ms and `redactHostLeaks` 36 ms, against 12 / 32 ms on 7.49.2 and 4 / 5 ms on v7.48.0.
|
|
42
|
+
- **Compensation.** None needed.
|
|
43
|
+
- **Not changed.** The fail-closed direction and shape (one hit to the end of the input, the row's scan stops), the trigger (cumulative match length against the allowance, per row and per scan) and the per-character rate (8).
|
|
44
|
+
- Pins: `test/backlog1691-bearer-connectors.test.ts` E1 (k = 24 / 50 / 100 × `,` `:` `/` × 2 / 40 lines behind, four faces: dense line read in full, lines behind byte-identical, no value), E2 (the threshold found by bisection and held in [90, 120]: one fewer reads in full, the threshold masks to the end of the input, a credential and a bearer token on a line behind hidden on both sides), E2b (inside 100 KB the threshold follows the text: in [240, 290] and more than twice the lone line's), E3 (`password=` × 2 000, then LF / CRLF / CR and a value: hidden on four faces; roomy control), E4 (a 1.2 MB dense line returns), E5 (24 nested starts in 245 characters read in full — the floor's unit), E6 (one finding runs to the end of the input); `backlog1675` A6i (its exhausting fixture raised from 100 to 400 nested starts: 100 no longer run out).
|
|
45
|
+
|
|
46
|
+
## 7.49.2 — 2026-10-13
|
|
47
|
+
|
|
48
|
+
**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.
|
|
49
|
+
|
|
50
|
+
### A leg without hands has no LSP tool (#1674; fix; security; hard-breaking: one, open to veto) (@server @cli @client-core @test)
|
|
51
|
+
|
|
52
|
+
**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.
|
|
53
|
+
|
|
54
|
+
- **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).
|
|
55
|
+
- **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).
|
|
56
|
+
- **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).
|
|
57
|
+
- **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.
|
|
58
|
+
- **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).
|
|
59
|
+
|
|
60
|
+
**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).
|
|
61
|
+
|
|
62
|
+
### 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)
|
|
63
|
+
|
|
64
|
+
- **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.
|
|
65
|
+
- **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.
|
|
66
|
+
- **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.
|
|
67
|
+
- **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).
|
|
68
|
+
- **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`.
|
|
69
|
+
- **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.
|
|
70
|
+
- **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.
|
|
71
|
+
- 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`.
|
|
72
|
+
|
|
73
|
+
**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).
|
|
74
|
+
|
|
75
|
+
### 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)
|
|
76
|
+
|
|
77
|
+
- **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).
|
|
78
|
+
- **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; `"` `'` `"` `'`; `%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.
|
|
79
|
+
- **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.
|
|
80
|
+
- **Consumers.** None needs to act.
|
|
81
|
+
- 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).
|
|
82
|
+
|
|
83
|
+
### 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)
|
|
84
|
+
|
|
85
|
+
- **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).
|
|
86
|
+
- **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.
|
|
87
|
+
- **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.
|
|
88
|
+
- **Who is hurt.** Nobody; the broken marker was never meaningful. A host that parsed `[redacted-credentials]` out of these outputs now finds it.
|
|
89
|
+
- **Consumers.** None needs to act.
|
|
90
|
+
- 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).
|
|
91
|
+
|
|
1
92
|
## 7.49.1 — 2026-10-12
|
|
2
93
|
|
|
3
94
|
**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).
|
package/dist/core/arg-summary.js
CHANGED
|
@@ -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 <
|
|
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
|
-
|
|
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
|
|
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 = [`"`, `'`, `"`, `'`, `"`, `'`, `%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:
|
|
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:
|
|
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
|
],
|
package/dist/core/gate-fold.js
CHANGED
|
@@ -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
|
|
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: "
|
|
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
|
|
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 =
|
|
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 ||
|
|
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.
|
|
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
|
package/dist/core/task-spec.d.ts
CHANGED
|
@@ -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
|
|
606
|
-
* manager. Overrides `RunnerDeps.lspManager`.
|
|
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: ["
|
|
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
|
|
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.
|
|
450
|
-
*
|
|
451
|
-
* doors+scan stretch
|
|
452
|
-
*
|
|
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
|
package/dist/core/tool-roster.js
CHANGED
|
@@ -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 &&
|
|
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 =
|
|
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
|
|
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)
|