@sema-agent/core 7.49.2 → 7.49.4
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 +49 -0
- package/dist/core/arg-summary.js +2 -1
- package/dist/core/credential-vocabulary.js +1 -1
- package/dist/core/gate-fold.js +7 -3
- package/dist/core/permission-rule-model.js +5 -5
- package/dist/core/read-only-shell.d.ts +8 -1
- package/dist/core/read-only-shell.js +19 -4
- package/dist/tools/fs/bash-readonly-classifier.js +12 -29
- package/dist/tools/fs/program-read-model.d.ts +15 -0
- package/dist/tools/fs/program-read-model.js +24 -0
- package/dist/tools/fs/program-read-model.rows.js +15 -12
- package/dist/tools/fs/shell-read-check.js +3 -3
- package/dist/tools/fs/shell-read-device.d.ts +92 -0
- package/dist/tools/fs/shell-read-device.js +109 -0
- package/dist/tools/fs/shell-read-establish.js +2 -1
- package/dist/tools/fs/shell-read-judge.js +4 -3
- package/dist/tools/fs/shell-read-plan.d.ts +5 -0
- package/dist/tools/fs/shell-read-plan.js +9 -6
- package/dist/tools/fs/shell-read-write-targets.d.ts +14 -0
- package/dist/tools/fs/shell-read-write-targets.js +63 -12
- package/package.json +1 -1
- package/wire-changes/core@7.49.3.json +142 -0
- package/wire-changes/core@7.49.4.json +69 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
1
|
+
## 7.49.4 — 2026-10-13
|
|
2
|
+
|
|
3
|
+
**Rebuild of 7.49.3 — no source change.** The npm tarball of 7.49.3 (shasum `77a7fd37…`, now deprecated) carried the `dist/` of 7.49.2: it was published with `--ignore-scripts` from a checkout whose `dist/` had not been rebuilt (the release-candidate build and gates had run on a detached gate copy). None of the four 7.49.3 entries below (#1685, #1691, #1692, #1693) were in that package. 7.49.4 is the same source as tag `v7.49.3` built in the publishing checkout and compared byte-for-byte with the gated dist before publish; every 7.49.3 entry, criterion (`docs/release-criteria/7.49.3.json`) and wire manifest applies to 7.49.4. **Hard-breaking changes:** none. **Closed sets:** none. **Package exports:** +0 / −0 (`npm run handoff:diff v7.49.3 HEAD`). Consumers pinned to 7.49.3 must move to 7.49.4 (@server @cli @web-admin @client-core @test).
|
|
4
|
+
|
|
5
|
+
## 7.49.3 — 2026-10-13
|
|
6
|
+
|
|
7
|
+
**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).
|
|
8
|
+
|
|
9
|
+
### 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)
|
|
10
|
+
|
|
11
|
+
- **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".
|
|
12
|
+
- **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).
|
|
13
|
+
- **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).
|
|
14
|
+
- **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).
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
- **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.
|
|
18
|
+
- 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`.
|
|
19
|
+
|
|
20
|
+
### 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)
|
|
21
|
+
|
|
22
|
+
- **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**).
|
|
23
|
+
- **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.
|
|
24
|
+
- **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).
|
|
25
|
+
- **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.
|
|
26
|
+
- **Not in this entry.** Unreadable rules (`rule_unreadable`) get their origin from the rules door's own mint.
|
|
27
|
+
- **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.
|
|
28
|
+
- 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).
|
|
29
|
+
|
|
30
|
+
### 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)
|
|
31
|
+
|
|
32
|
+
- **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.
|
|
33
|
+
- **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.
|
|
34
|
+
- **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.
|
|
35
|
+
- **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).
|
|
36
|
+
- **Compensation.** Put a space (or a quote) behind the token; nothing else to do.
|
|
37
|
+
- **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.
|
|
38
|
+
- 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).
|
|
39
|
+
|
|
40
|
+
### 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)
|
|
41
|
+
|
|
42
|
+
- **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.
|
|
43
|
+
- **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.
|
|
44
|
+
- **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.
|
|
45
|
+
- **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.
|
|
46
|
+
- **Compensation.** None needed.
|
|
47
|
+
- **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).
|
|
48
|
+
- 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).
|
|
49
|
+
|
|
1
50
|
## 7.49.2 — 2026-10-13
|
|
2
51
|
|
|
3
52
|
**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.
|
package/dist/core/arg-summary.js
CHANGED
|
@@ -87,12 +87,13 @@ function removedRegion(pass, match, groups, inserted) {
|
|
|
87
87
|
return [head, match.length - tail];
|
|
88
88
|
}
|
|
89
89
|
const SOLO_SCAN_WORK_PER_CHAR = 8;
|
|
90
|
+
const SOLO_SCAN_WORK_FLOOR = 1 << 17;
|
|
90
91
|
function soloHits(input, passes) {
|
|
91
92
|
const hits = [];
|
|
92
93
|
passes.forEach((pass, i) => {
|
|
93
94
|
const re = new RegExp(pass.re.source, pass.re.flags.includes("g") ? pass.re.flags : `${pass.re.flags}g`);
|
|
94
95
|
const rescans = pass.family !== undefined;
|
|
95
|
-
let work = SOLO_SCAN_WORK_PER_CHAR * input.length;
|
|
96
|
+
let work = Math.max(SOLO_SCAN_WORK_PER_CHAR * input.length, SOLO_SCAN_WORK_FLOOR);
|
|
96
97
|
for (let m = re.exec(input); m !== null; m = re.exec(input)) {
|
|
97
98
|
const at = m.index;
|
|
98
99
|
const familyAt = () => (typeof pass.family === "function" ? pass.family(input, at) : pass.family);
|
|
@@ -16,7 +16,7 @@ const QUOTED_VALUE = [
|
|
|
16
16
|
...ESCAPED_QUOTES.map((q, i) => pairedValue(`(${BACKSLASHES})${q}`, `(?<!\\\\)(?:\\${i + 2})${q}`)),
|
|
17
17
|
`${QUOTE}(?!${AFTER_CLOSING_QUOTE})${GLUED_RUN}`,
|
|
18
18
|
].join("|");
|
|
19
|
-
const BEARER_TAIL = `(
|
|
19
|
+
const BEARER_TAIL = `(?:(?!${QUOTE})\\S${GLUED_RUN}|(?<==)(?:${QUOTED_VALUE}))`;
|
|
20
20
|
export const CREDENTIAL_VOCABULARY = {
|
|
21
21
|
values: [
|
|
22
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/ },
|
package/dist/core/gate-fold.js
CHANGED
|
@@ -208,9 +208,13 @@ function applyShellReadVerdict(pass, verdict, toolName, shellGated) {
|
|
|
208
208
|
case "mandated": {
|
|
209
209
|
if (verdict.details !== undefined)
|
|
210
210
|
pass.shellReadDetails = verdict.details;
|
|
211
|
+
const unreadable = verdict.finding === "unanswered";
|
|
211
212
|
if (pass.decision.action !== "allow") {
|
|
212
|
-
if (pass.decision.action === "ask")
|
|
213
|
-
pass.decision =
|
|
213
|
+
if (pass.decision.action === "ask") {
|
|
214
|
+
pass.decision = !unreadable
|
|
215
|
+
? { ...pass.decision, probeMandated: true }
|
|
216
|
+
: { ...pass.decision, requiresRealApproval: true, ...(pass.decision.guard === undefined ? { guard: "unverifiable_shell_text" } : { foldedAskClasses: [...new Set([...(pass.decision.foldedAskClasses ?? []), "unverifiable_shell_text"])] }) };
|
|
217
|
+
}
|
|
214
218
|
return;
|
|
215
219
|
}
|
|
216
220
|
const candidate = normalizeReadRootCandidate(verdict.readRootCandidate);
|
|
@@ -218,7 +222,7 @@ function applyShellReadVerdict(pass, verdict, toolName, shellGated) {
|
|
|
218
222
|
action: "ask",
|
|
219
223
|
message: shellReadAskMessage(toolName, verdict),
|
|
220
224
|
decisionReason: "safety",
|
|
221
|
-
probeMandated: true,
|
|
225
|
+
...(unreadable ? { requiresRealApproval: true, guard: "unverifiable_shell_text" } : { probeMandated: true }),
|
|
222
226
|
...(account !== undefined ? { probeReason: account } : {}),
|
|
223
227
|
...(candidate !== undefined ? { probeReadRootCandidate: candidate } : {}),
|
|
224
228
|
};
|
|
@@ -9,7 +9,7 @@ import { heldValues } from "./tool-name-readings.js";
|
|
|
9
9
|
import { carriesDotSegment, isAbsoluteForFamily, isIdentityDir, isPathIdentity, isShellRootedSpellingUnmapped, isUncIdentity, pathFamilyOf, pathIdentityOf } from "../tools/fs/safety.js";
|
|
10
10
|
import { bindPathRules, judgeRuleRows, rowsWhereTheyLead } from "./path-rule-binding.js";
|
|
11
11
|
import { readShellCommand } from "./shell-lexer.js";
|
|
12
|
-
import { readOnlyLineObjection, readOnlyShellVerdict } from "./read-only-shell.js";
|
|
12
|
+
import { lineDescriptorsOfText, readOnlyLineObjection, readOnlyShellVerdict } from "./read-only-shell.js";
|
|
13
13
|
export const RULE_BEHAVIORS = Object.freeze(["deny", "ask", "allow"]);
|
|
14
14
|
const RULE_BEHAVIOR_SET = new Set(RULE_BEHAVIORS);
|
|
15
15
|
export function isRuleBehavior(v) {
|
|
@@ -951,7 +951,7 @@ export function findAdmittingRule(rules, call) {
|
|
|
951
951
|
const dir = cdDirs[i];
|
|
952
952
|
const first = firstEligibleAdmittingRule(rules, segment, call) ?? (dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined);
|
|
953
953
|
if (first === undefined) {
|
|
954
|
-
if (readsOnlyAsSegment(segment, lineObjection))
|
|
954
|
+
if (readsOnlyAsSegment(segment, lineObjection, call.command))
|
|
955
955
|
continue;
|
|
956
956
|
return undefined;
|
|
957
957
|
}
|
|
@@ -960,8 +960,8 @@ export function findAdmittingRule(rules, call) {
|
|
|
960
960
|
}
|
|
961
961
|
return covering.length > 0 ? covering : undefined;
|
|
962
962
|
}
|
|
963
|
-
function readsOnlyAsSegment(segment, lineObjection) {
|
|
964
|
-
return lineObjection === undefined && !movesShellCursor(segment) && readOnlyShellVerdict(segment).readOnly;
|
|
963
|
+
function readsOnlyAsSegment(segment, lineObjection, whole) {
|
|
964
|
+
return lineObjection === undefined && !movesShellCursor(segment) && readOnlyShellVerdict(segment, undefined, undefined, () => lineDescriptorsOfText(whole)).readOnly;
|
|
965
965
|
}
|
|
966
966
|
function firstEligibleDirectoryRule(rules, directory, call) {
|
|
967
967
|
for (const rule of rules) {
|
|
@@ -1067,7 +1067,7 @@ export function segmentCoverageOf(command, rules, call) {
|
|
|
1067
1067
|
const dir = cdDirs[i];
|
|
1068
1068
|
return {
|
|
1069
1069
|
segment: segment.trim(),
|
|
1070
|
-
covered: readsOnlyAsSegment(segment, lineObjection) ||
|
|
1070
|
+
covered: readsOnlyAsSegment(segment, lineObjection, folded) ||
|
|
1071
1071
|
firstEligibleAdmittingRule(rules.persisted, segment, call) !== undefined ||
|
|
1072
1072
|
(dir !== undefined && firstEligibleDirectoryRule(rules.persisted, dir, call) !== undefined) ||
|
|
1073
1073
|
proposed.some((p) => p.rule.tool === "Read"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ShellCommandShape } from "./shell-lexer.js";
|
|
2
|
+
import { type LineDescriptors } from "../tools/fs/shell-read-device.js";
|
|
2
3
|
/** The reader's answer: read-only, or not — with the first reason the reading stopped on (a sentence a
|
|
3
4
|
* trace or a card can show; never adjudication input). */
|
|
4
5
|
export type ReadOnlyShellVerdict = {
|
|
@@ -32,7 +33,7 @@ export declare function changesDirectoryBeforeGit(shape: ShellCommandShape, cwd?
|
|
|
32
33
|
* stands ({@link changesDirectoryBeforeGit}). Anything the reading does not positively admit is NOT read-only — the
|
|
33
34
|
* answer is never a guess, and a `false` costs one question.
|
|
34
35
|
*/
|
|
35
|
-
export declare function readOnlyShellVerdict(command: string, shape?: ShellCommandShape, cwd?: string): ReadOnlyShellVerdict;
|
|
36
|
+
export declare function readOnlyShellVerdict(command: string, shape?: ShellCommandShape, cwd?: string, bound?: LineDescriptors | (() => LineDescriptors)): ReadOnlyShellVerdict;
|
|
36
37
|
/**
|
|
37
38
|
* The reader's LINE-level objection to `command` — what makes no segment of it read-only in its place, whatever the
|
|
38
39
|
* segment reads on its own: the line's structure, or a `cd` beside a `git` ({@link changesDirectoryBeforeGit}). A caller
|
|
@@ -40,3 +41,9 @@ export declare function readOnlyShellVerdict(command: string, shape?: ShellComma
|
|
|
40
41
|
* line first.
|
|
41
42
|
*/
|
|
42
43
|
export declare function readOnlyLineObjection(command: string, cwd?: string): string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* What the whole text of `command` binds — every redirection of every segment (a single one, an `exec`'s, a `{name}` one included),
|
|
46
|
+
* read by the station's own computation ({@link descriptorsOfPlan}): the one binding map the shape tier's operand reading shares with
|
|
47
|
+
* the station's rebinding rule. The plan is read with no listed readers: the bindings do not depend on which programs read.
|
|
48
|
+
*/
|
|
49
|
+
export declare function lineDescriptorsOfText(command: string): LineDescriptors;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { readShellCommand } from "./shell-lexer.js";
|
|
3
|
+
import { descriptorsOfPlan, newLineDescriptors, standardDeviceUnrebound } from "../tools/fs/shell-read-device.js";
|
|
4
|
+
import { planShellReads } from "../tools/fs/shell-read-plan.js";
|
|
5
|
+
import { writesOnlyStandardDevices } from "../tools/fs/shell-read-write-targets.js";
|
|
3
6
|
import { FIND_ACTION_PRIMARIES, FIND_NEWER_PRIMARY, FIND_VALUE_PRIMARIES, FLAG_VALUE_ACCEPTS, READ_ONLY_BARE_ONLY, READ_ONLY_BARE_PROGRAMS, READ_ONLY_COMMAND_TABLE, READ_ONLY_ENV_NAMES, READ_ONLY_EXACT_FORMS, READ_ONLY_GLOB_PROGRAMS, XARGS_READ_ONLY_TARGETS, dockerRetargetDanger, } from "./read-only-shell-table.js";
|
|
4
7
|
const NOT = (reason) => ({ readOnly: false, reason });
|
|
5
8
|
export const CD_GIT_COMPOUND_SENTENCE = "This command changes directory before running a version-control command, which can pick up untrusted hooks or repository configuration from the target directory. Approve only if you trust it.";
|
|
@@ -39,7 +42,7 @@ const PRINTF_ESCAPE = new RegExp(`%[^%a-zA-Z]*${PRINTF_LENGTH}\\\\[0-7xX]`);
|
|
|
39
42
|
const PRINTF_NUMERIC = new RegExp(`%[-+ 0#']*[0-9.*]*${PRINTF_LENGTH}[diouxXeEfFgGaAn]`);
|
|
40
43
|
const JQ_DANGER_FLAG = /^['"]?-[a-zA-Z]*[fL]/;
|
|
41
44
|
const JQ_DANGER_TEXT = /--from-file|--rawfile|--slurpfile|--run-tests|--library-path|\benv\b|\$ENV\b|\binclude\b|\bimport\b/;
|
|
42
|
-
export function readOnlyShellVerdict(command, shape = readShellCommand(command), cwd) {
|
|
45
|
+
export function readOnlyShellVerdict(command, shape = readShellCommand(command), cwd, bound) {
|
|
43
46
|
const line = lineStructureObjection(shape);
|
|
44
47
|
if (line !== undefined)
|
|
45
48
|
return NOT(line);
|
|
@@ -59,8 +62,12 @@ export function readOnlyShellVerdict(command, shape = readShellCommand(command),
|
|
|
59
62
|
}
|
|
60
63
|
if (changesDirectoryBeforeGit(shape, cwd))
|
|
61
64
|
return NOT(CD_GIT_REASON);
|
|
65
|
+
const descriptors = newLineDescriptors();
|
|
66
|
+
descriptors.unknown = shape.segments.reduce((n, s) => n + s.redirections.length, 0) > 1;
|
|
67
|
+
let boundOfText;
|
|
68
|
+
const bindings = () => (boundOfText ??= (typeof bound === "function" ? bound() : bound) ?? lineDescriptorsOfText(command));
|
|
62
69
|
for (const seg of shape.segments) {
|
|
63
|
-
const verdict = segmentVerdict(seg);
|
|
70
|
+
const verdict = segmentVerdict(seg, descriptors, bindings);
|
|
64
71
|
if (!verdict.readOnly)
|
|
65
72
|
return verdict;
|
|
66
73
|
}
|
|
@@ -125,10 +132,10 @@ function peelBuiltinPrefix(words) {
|
|
|
125
132
|
return t;
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
|
-
function segmentVerdict(seg) {
|
|
135
|
+
function segmentVerdict(seg, line, bindings) {
|
|
129
136
|
for (const r of seg.redirections) {
|
|
130
137
|
const target = r.target.text;
|
|
131
|
-
if (!READING_REDIRECTS.has(r.op) && target
|
|
138
|
+
if (!READING_REDIRECTS.has(r.op) && !standardDeviceUnrebound(target, line, []) && !(r.op === ">&" && /^\d+$/.test(target)))
|
|
132
139
|
return NOT(`the redirection \`${r.op} ${target}\` writes`);
|
|
133
140
|
if (/^\/dev\/(tcp|udp)\//.test(target))
|
|
134
141
|
return NOT("a redirection to a network pseudo-device");
|
|
@@ -148,11 +155,17 @@ function segmentVerdict(seg) {
|
|
|
148
155
|
const program = words[0];
|
|
149
156
|
if (run.some((w) => expansionKind(w) === "glob"))
|
|
150
157
|
return GLOB_PROGRAMS.has(program) ? READ_ONLY : NOT(`\`${program}\` with a glob argument — the glob may name anything`);
|
|
158
|
+
if (DEVICE_WRITERS.has(program) && writesOnlyStandardDevices(program, words.slice(1), bindings()))
|
|
159
|
+
return READ_ONLY;
|
|
151
160
|
const structural = structuralReading(words);
|
|
152
161
|
if (structural !== null)
|
|
153
162
|
return structural ? READ_ONLY : NOT(`\`${program}\` with these arguments is not a read-only form`);
|
|
154
163
|
return tableReading(words, run);
|
|
155
164
|
}
|
|
165
|
+
export function lineDescriptorsOfText(command) {
|
|
166
|
+
return descriptorsOfPlan(planShellReads(command, { readers: new Set(), cwd: "/", roots: [] }));
|
|
167
|
+
}
|
|
168
|
+
const DEVICE_WRITERS = new Set(["tee", "cp"]);
|
|
156
169
|
function structuralReading(words) {
|
|
157
170
|
const program = words[0];
|
|
158
171
|
if (BARE_ONLY.has(program))
|
|
@@ -167,6 +180,8 @@ function structuralReading(words) {
|
|
|
167
180
|
return form[0] !== "docker" || !dockerRetargetDanger(words);
|
|
168
181
|
if (program === "echo")
|
|
169
182
|
return true;
|
|
183
|
+
if (program === "exec")
|
|
184
|
+
return words.length === 1;
|
|
170
185
|
if (program === "printf")
|
|
171
186
|
return printfReading(words);
|
|
172
187
|
if (program === "ls")
|
|
@@ -2,8 +2,9 @@ import { anchorSpelling, isAbsoluteForFamily, isBlockedDevicePath, isShellRooted
|
|
|
2
2
|
import { PARSE_FAILURE_SENTENCE, UNRESOLVED_SENTENCE, argvOf, lexShell, literalTildeSpelling, } from "../../shell/lex.js";
|
|
3
3
|
import { shellWordsOf } from "../../shell/shell-words.js";
|
|
4
4
|
import { textBindsHome } from "./shell-read-bindings.js";
|
|
5
|
+
import { GENERATOR_DEVICES, newLineDescriptors, bindDescriptors, isNamedDescriptorWord, standardDeviceUnrebound, descriptorsBoundBy } from "./shell-read-device.js";
|
|
5
6
|
import { NOT_AUTO_ALLOWED, argvReentryUnjudged, isBareControlWord, programPositionRefusalSentence, readProgramPosition, } from "./bash-program-position.js";
|
|
6
|
-
import { BLOCK_WORD_NAMES, attachedStdinFile, declaresOption, firstOperandSlot, opensNoOperand, programDelivery, programRow, programTextReadings,
|
|
7
|
+
import { BLOCK_WORD_NAMES, attachedStdinFile, byteBoundIsSmall, declaresOption, firstOperandSlot, opensNoOperand, programDelivery, programRow, programTextReadings, readArgs, readProgramTextLanguage, runsShellText, selectedOptions, slotValueCandidates, suppliesFirstOperandSlot, takesSeparatedDatum, valuesAreLiteral, writeReadings, writesPath, } from "./program-read-model.js";
|
|
7
8
|
export const BASH_READONLY_DEFAULT_ALLOW = [
|
|
8
9
|
"ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
|
|
9
10
|
"grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
|
|
@@ -82,8 +83,8 @@ export function carriesShellRedirection(text) {
|
|
|
82
83
|
return SHELL_REDIRECTION_OPERATORS.test(text);
|
|
83
84
|
}
|
|
84
85
|
const SHELL_REDIRECTION_OPERATORS = /[<>]/;
|
|
85
|
-
function isDataFreeRedirection(r) {
|
|
86
|
-
if (r.op === ">" && r.fd === 2 && r.target !== undefined && r.target.raw
|
|
86
|
+
function isDataFreeRedirection(r, line) {
|
|
87
|
+
if (r.op === ">" && r.fd === 2 && r.target !== undefined && standardDeviceUnrebound(r.target.raw, line, descriptorsBoundBy(r)))
|
|
87
88
|
return true;
|
|
88
89
|
return r.op === ">&" && r.dup !== undefined && /^[12]$/.test(r.dup) && (r.fd === undefined || r.fd === 1 || r.fd === 2);
|
|
89
90
|
}
|
|
@@ -103,6 +104,13 @@ export function strictResidualRefusal(lexed, source, opts = {}) {
|
|
|
103
104
|
if ("parseFailure" in lexed)
|
|
104
105
|
return inClass(source) || SHELL_EXPANSION_CHARS.test(source) ? reason : PARSE_FAILURE_SENTENCE[lexed.parseFailure.reason];
|
|
105
106
|
const words = [];
|
|
107
|
+
const line = newLineDescriptors();
|
|
108
|
+
for (const s of lexed.segments)
|
|
109
|
+
for (const t of s.tokens)
|
|
110
|
+
if (t.kind === "redirect")
|
|
111
|
+
bindDescriptors(line, t);
|
|
112
|
+
else if (t.kind === "word" && isNamedDescriptorWord(t.raw))
|
|
113
|
+
line.named = true;
|
|
106
114
|
for (const s of lexed.segments) {
|
|
107
115
|
if (s.connector === "\n")
|
|
108
116
|
return reason;
|
|
@@ -115,7 +123,7 @@ export function strictResidualRefusal(lexed, source, opts = {}) {
|
|
|
115
123
|
}
|
|
116
124
|
if (t.kind !== "redirect")
|
|
117
125
|
return reason;
|
|
118
|
-
if (opts.dataFreeRedirections === true && isDataFreeRedirection(t))
|
|
126
|
+
if (opts.dataFreeRedirections === true && isDataFreeRedirection(t, line))
|
|
119
127
|
continue;
|
|
120
128
|
if (!tolerate)
|
|
121
129
|
return reason;
|
|
@@ -795,30 +803,6 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
|
795
803
|
}
|
|
796
804
|
unlistedSegments.add(si);
|
|
797
805
|
}
|
|
798
|
-
const HEAD_AUTO_ALLOW_MAX = 1_000_000;
|
|
799
|
-
const byteBoundIsSmall = (row, args) => {
|
|
800
|
-
if (row === undefined)
|
|
801
|
-
return false;
|
|
802
|
-
let byteBound = false;
|
|
803
|
-
for (const e of readArgRoles(row, args)) {
|
|
804
|
-
if (e.kind === "operand" || e.kind === "end-of-options")
|
|
805
|
-
continue;
|
|
806
|
-
if (e.kind === "value" && e.spec?.bounds === "bytes") {
|
|
807
|
-
if (!/^\d+$/.test(args[e.at]) || Number(args[e.at]) > HEAD_AUTO_ALLOW_MAX)
|
|
808
|
-
return false;
|
|
809
|
-
byteBound = true;
|
|
810
|
-
continue;
|
|
811
|
-
}
|
|
812
|
-
if (e.kind === "option" && typeof e.reading === "object" && e.reading.spec?.bounds === "bytes") {
|
|
813
|
-
if (!/^\d+$/.test(e.reading.attached) || Number(e.reading.attached) > HEAD_AUTO_ALLOW_MAX)
|
|
814
|
-
return false;
|
|
815
|
-
byteBound = true;
|
|
816
|
-
continue;
|
|
817
|
-
}
|
|
818
|
-
return false;
|
|
819
|
-
}
|
|
820
|
-
return byteBound;
|
|
821
|
-
};
|
|
822
806
|
const foldedSegments = [];
|
|
823
807
|
let shapeRefusal = nameRefusal;
|
|
824
808
|
for (let si = 0; si < segments.length; si++) {
|
|
@@ -896,7 +880,6 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
|
896
880
|
shapeRefusal ??= `\`${name}\` in follow mode never terminates ${NOT_AUTO_ALLOWED}`;
|
|
897
881
|
continue;
|
|
898
882
|
}
|
|
899
|
-
const GENERATOR_DEVICES = new Set(["/dev/zero", "/dev/random", "/dev/urandom", "/dev/full"]);
|
|
900
883
|
const deviceCandidates = openableWordsOf(row, restArgs);
|
|
901
884
|
const deviceArgs = deviceCandidates.map((t) => resolveOperandLexically(undefined, t, boundary?.homeDir) ?? normalizeAbsPathLexically(t)).filter(isBlockedDevicePath);
|
|
902
885
|
const rescuedByHead = byteBoundIsSmall(row, restArgs) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
|
|
@@ -65,6 +65,11 @@ export type ArgRole =
|
|
|
65
65
|
* copy READS its sources; the write face still judges them, as the reference implementation does). Absent: every
|
|
66
66
|
* seat in {@link seats}. */
|
|
67
67
|
readonly writtenAt?: readonly WriteSeat[];
|
|
68
|
+
/** A writing effect that writes the path's CONTENT by opening it (`true`), or does so only while the invocation carries
|
|
69
|
+
* no option word (`"plain"`: a copy's `-f`, `-b` and `--remove-destination` replace the entry, `-l` and `-s` link it) —
|
|
70
|
+
* the one kind of write a standard device takes without a containment question (`passesUnasked`). Absent: the program
|
|
71
|
+
* creates, unlinks, renames or retargets the path's ENTRY, or the row does not say it only writes through. */
|
|
72
|
+
readonly through?: true | "plain";
|
|
68
73
|
/** The program RUNS in this directory (`env -C <dir>`), or with it as its root (`sudo -R <dir>`): the paths it is
|
|
69
74
|
* handed resolve against it, not against the working directory the command line stands in. Or the program word is
|
|
70
75
|
* LOOKED UP in these directories (`env -P <dirs>`): which program runs is not the one the word names on the PATH. */
|
|
@@ -789,4 +794,14 @@ export declare function messageWords(program: string, words: readonly {
|
|
|
789
794
|
readonly expansions: readonly WordPiece[];
|
|
790
795
|
};
|
|
791
796
|
}[], programIndex: number): Set<number>;
|
|
797
|
+
/** The most bytes a read may be bounded to and still count as small (`head -c N`): a bound above it is no bound a person would call one. */
|
|
798
|
+
export declare const SMALL_BYTE_BOUND_MAX = 1000000;
|
|
799
|
+
/**
|
|
800
|
+
* Does this invocation read at most a SMALL, LITERAL number of BYTES? The rule is positive: the row's role walk ({@link readArgRoles})
|
|
801
|
+
* must find an option the row says bounds the output in BYTES ({@link OptionSpec.bounds}), valued with a plain integer within
|
|
802
|
+
* {@link SMALL_BYTE_BOUND_MAX}, and NO other option word — a line bound is no bound for a source that holds no newline, and a
|
|
803
|
+
* switch, a spelling the row does not explain or an option-shaped word past an operand makes the invocation unreadable, which is
|
|
804
|
+
* not exempt (the safe side). `false` for a program with no row.
|
|
805
|
+
*/
|
|
806
|
+
export declare function byteBoundIsSmall(row: ProgramRow | undefined, args: readonly string[]): boolean;
|
|
792
807
|
export {};
|
|
@@ -1351,3 +1351,27 @@ export function messageWords(program, words, programIndex) {
|
|
|
1351
1351
|
}
|
|
1352
1352
|
return out;
|
|
1353
1353
|
}
|
|
1354
|
+
export const SMALL_BYTE_BOUND_MAX = 1_000_000;
|
|
1355
|
+
export function byteBoundIsSmall(row, args) {
|
|
1356
|
+
if (row === undefined)
|
|
1357
|
+
return false;
|
|
1358
|
+
let byteBound = false;
|
|
1359
|
+
for (const e of readArgRoles(row, args)) {
|
|
1360
|
+
if (e.kind === "operand" || e.kind === "end-of-options")
|
|
1361
|
+
continue;
|
|
1362
|
+
if (e.kind === "value" && e.spec?.bounds === "bytes") {
|
|
1363
|
+
if (!/^\d+$/.test(args[e.at]) || Number(args[e.at]) > SMALL_BYTE_BOUND_MAX)
|
|
1364
|
+
return false;
|
|
1365
|
+
byteBound = true;
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
if (e.kind === "option" && typeof e.reading === "object" && e.reading.spec?.bounds === "bytes") {
|
|
1369
|
+
if (!/^\d+$/.test(e.reading.attached) || Number(e.reading.attached) > SMALL_BYTE_BOUND_MAX)
|
|
1370
|
+
return false;
|
|
1371
|
+
byteBound = true;
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
return byteBound;
|
|
1377
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { shellWordsOf } from "../../shell/shell-words.js";
|
|
2
2
|
const READ = { kind: "path", effect: "read" };
|
|
3
3
|
const WRITE = { kind: "path", effect: "write" };
|
|
4
|
+
const WRITE_THROUGH = { kind: "path", effect: "write", through: true };
|
|
4
5
|
const CREATED = { kind: "path", effect: "create" };
|
|
5
6
|
const TOUCHED = { kind: "path", effect: "create", seats: ["landing"] };
|
|
6
7
|
const REMOVED = { kind: "path", effect: "write", seats: ["entry"] };
|
|
7
8
|
const COPY_SOURCE = { kind: "path", effect: "write", seats: ["landing", "entry"], writtenAt: [] };
|
|
8
9
|
const MOVE_SOURCE = { kind: "path", effect: "write", seats: ["landing", "entry"], writtenAt: ["entry"] };
|
|
9
|
-
const
|
|
10
|
+
const MOVE_DESTINATION = { kind: "path", effect: "write", seats: ["landing", "entry"] };
|
|
11
|
+
const COPY_DESTINATION = { kind: "path", effect: "write", seats: ["landing", "entry"], through: "plain" };
|
|
10
12
|
const EDITED = { kind: "path", effect: "edit-in-place", seats: ["entry"] };
|
|
11
13
|
const NAMES = { kind: "path", effect: "names" };
|
|
12
14
|
const INDIRECT = { kind: "path", effect: "read", valueForm: "indirect" };
|
|
@@ -203,18 +205,18 @@ const find = {
|
|
|
203
205
|
const curl = {
|
|
204
206
|
key: "curl",
|
|
205
207
|
grammars: [grammar({
|
|
206
|
-
A: val(LABEL), b: val(INDIRECT), C: val(COUNT), c: val(
|
|
207
|
-
F: val(FORM), H: val(HEADER), K: val(READ), m: val(COUNT), o: val(
|
|
208
|
+
A: val(LABEL), b: val(INDIRECT), C: val(COUNT), c: val(WRITE_THROUGH), D: val(WRITE_THROUGH), d: val(INDIRECT), E: val(READ), e: val(LABEL),
|
|
209
|
+
F: val(FORM), H: val(HEADER), K: val(READ), m: val(COUNT), o: val(WRITE_THROUGH), P: val(LABEL), Q: val(LABEL), r: val(COUNT),
|
|
208
210
|
T: val(UPLOAD), t: val(LABEL), U: val(LABEL), u: val(LABEL), w: val(INDIRECT), X: val(LABEL), x: val(LABEL), Y: val(COUNT),
|
|
209
211
|
y: val(COUNT), z: val(READ), g: { arity: 0, literalValues: true },
|
|
210
212
|
...switches("#012346aBfGiIjJklLnNOpqRsSvZ"),
|
|
211
213
|
...switches("hMV", { mode: "banner" }),
|
|
212
214
|
}, {
|
|
213
|
-
cacert: val(READ), capath: val(READ), cert: val(READ), config: val(READ), "connect-timeout": val(COUNT), cookie: val(INDIRECT), "cookie-jar": val(
|
|
214
|
-
data: val(INDIRECT), "data-ascii": val(INDIRECT), "data-binary": val(INDIRECT), "data-raw": val(LABEL), "data-urlencode": val(FORM), "dump-header": val(
|
|
215
|
-
form: val(FORM), "form-string": val(LABEL), header: val(HEADER), key: val(READ), "max-time": val(COUNT), "netrc-file": val(READ), output: val(
|
|
216
|
-
proxy: val(LABEL), range: val(COUNT), referer: val(LABEL), request: val(LABEL), resolve: val(LABEL), trace: val(
|
|
217
|
-
"trace-ascii": val(
|
|
215
|
+
cacert: val(READ), capath: val(READ), cert: val(READ), config: val(READ), "connect-timeout": val(COUNT), cookie: val(INDIRECT), "cookie-jar": val(WRITE_THROUGH),
|
|
216
|
+
data: val(INDIRECT), "data-ascii": val(INDIRECT), "data-binary": val(INDIRECT), "data-raw": val(LABEL), "data-urlencode": val(FORM), "dump-header": val(WRITE_THROUGH),
|
|
217
|
+
form: val(FORM), "form-string": val(LABEL), header: val(HEADER), key: val(READ), "max-time": val(COUNT), "netrc-file": val(READ), output: val(WRITE_THROUGH),
|
|
218
|
+
proxy: val(LABEL), range: val(COUNT), referer: val(LABEL), request: val(LABEL), resolve: val(LABEL), trace: val(WRITE_THROUGH), "time-cond": val(READ),
|
|
219
|
+
"trace-ascii": val(WRITE_THROUGH), "upload-file": val(UPLOAD), url: val(URL), user: val(LABEL), "user-agent": val(LABEL), "write-out": val(INDIRECT),
|
|
218
220
|
...longSwitches(["append", "compressed", "disable", "fail", "get", "head", "include", "insecure", "junk-session-cookies", "list-only", "location", "netrc", "next", "no-buffer", "parallel", "proxytunnel", "remote-header-name", "remote-name", "remote-time", "show-error", "silent", "use-ascii", "verbose"]), ...BANNERS,
|
|
219
221
|
manual: { arity: 0, mode: "banner" }, globoff: { arity: 0, literalValues: true },
|
|
220
222
|
})],
|
|
@@ -497,6 +499,7 @@ const git = {
|
|
|
497
499
|
merge: gitSubcommand("nqve", "S", "mFsX", ["message", "file", "strategy", "strategy-option", "into-name", "cleanup"]),
|
|
498
500
|
stash: gitSubcommand("pkuaqS", "", "m", ["message", "pathspec-from-file"]),
|
|
499
501
|
grep: GIT_GREP,
|
|
502
|
+
diff: { grammar: grammar({}, {}), operands: { rest: READ } },
|
|
500
503
|
show: gitObjectSubcommand(),
|
|
501
504
|
"cat-file": gitObjectSubcommand(switches("ptsep")),
|
|
502
505
|
archive: gitObjectSubcommand({ o: val(WRITE) }, { output: val(WRITE), format: val(LABEL), prefix: val(LABEL), remote: val(LABEL) }),
|
|
@@ -568,15 +571,15 @@ const codeBodyRows = [
|
|
|
568
571
|
{ key: "compgen", grammars: [grammar({ ...switches("abcdefgjksuv"), ...values("oAGWPSXV"), F: val(COMMAND), C: val(SHELL_TEXT) })], body: "option-values", witness: BINARY },
|
|
569
572
|
{ key: "complete", grammars: [grammar({ ...switches("abcdefgjksuvDEIpr"), ...values("oAGWPSXV"), F: val(COMMAND), C: val(SHELL_TEXT) })], body: "option-values", witness: { declared: "a completion spec runs its command only when a person asks for a completion interactively" } },
|
|
570
573
|
];
|
|
571
|
-
const copyGrammars = (bsdSuffixSwitch) => implementations(grammar({ S: val(LABEL, { effect: "writes" }), t: val(
|
|
574
|
+
const copyGrammars = (bsdSuffixSwitch, destination) => implementations(grammar({ S: val(LABEL, { effect: "writes" }), t: val(destination, { fillsLast: true }) }, { "target-directory": val(destination, { fillsLast: true }), suffix: val(LABEL, { effect: "writes" }), "no-preserve": val(LABEL), sparse: val(LABEL) }), { afterFirstOperand: "options" }, { ...(bsdSuffixSwitch ? { reads: { "-S": SWITCH } } : {}), lacks: [...(bsdSuffixSwitch ? [] : ["-S"]), "-t", "--target-directory", "--suffix", "--no-preserve", "--sparse"], afterFirstOperand: "operands" });
|
|
572
575
|
const writerRows = [
|
|
573
576
|
{ key: "mkdir", grammars: [], operands: { rest: CREATED, dash: "file" }, witness: BINARY },
|
|
574
577
|
{ key: "touch", grammars: implementations(grammar({ h: { arity: 0, writesAt: ["landing", "entry"] } }, { "no-dereference": { arity: 0, writesAt: ["landing", "entry"] } }), {}, { lacks: ["--no-dereference"] }), operands: { rest: TOUCHED, dash: "file" }, witness: BINARY },
|
|
575
578
|
{ key: "rm", grammars: implementations(grammar({ ...switches("rR", { selects: "traversal" }), f: { arity: 0, mode: "forced" } }, { ...longSwitches(["recursive"], { selects: "traversal" }), force: { arity: 0, mode: "forced" } }), {}, { lacks: ["--recursive", "--force"] }), operands: { rest: REMOVED, dash: "file" }, witness: BINARY },
|
|
576
579
|
{ key: "rmdir", grammars: implementations(grammar({ p: { arity: 0, effect: "writes" } }, { parents: { arity: 0, effect: "writes" } }), {}, { lacks: ["--parents"] }), operands: { rest: REMOVED, dash: "file" }, witness: BINARY },
|
|
577
|
-
{ key: "tee", grammars: [], operands: { rest:
|
|
578
|
-
{ key: "cp", grammars: copyGrammars(true), operands: { rest: COPY_SOURCE, last:
|
|
579
|
-
{ key: "mv", grammars: copyGrammars(false), operands: { rest: MOVE_SOURCE, last:
|
|
580
|
+
{ key: "tee", grammars: [], operands: { rest: WRITE_THROUGH, dash: "file" }, witness: BINARY },
|
|
581
|
+
{ key: "cp", grammars: copyGrammars(true, COPY_DESTINATION), operands: { rest: COPY_SOURCE, last: COPY_DESTINATION, dash: "file" }, witness: BINARY },
|
|
582
|
+
{ key: "mv", grammars: copyGrammars(false, MOVE_DESTINATION), operands: { rest: MOVE_SOURCE, last: MOVE_DESTINATION, dash: "file" }, witness: BINARY },
|
|
580
583
|
];
|
|
581
584
|
const stdinReaders = [
|
|
582
585
|
{ key: "cat", grammars: [], operands: { stdinBelow: 1 }, witness: BINARY },
|
|
@@ -8,7 +8,8 @@ import { bashClassifyDefaultAllow } from "./bash-readonly-classifier.js";
|
|
|
8
8
|
import { planShellReads } from "./shell-read-plan.js";
|
|
9
9
|
import { createShellReadEstablisher, establishShellReadsLexically } from "./shell-read-establish.js";
|
|
10
10
|
import { declaredReadDenyHomes, homeViewsAt } from "./read-deny-home.js";
|
|
11
|
-
import { programArgs } from "./shell-read-write-targets.js";
|
|
11
|
+
import { anyOptionWord, programArgs } from "./shell-read-write-targets.js";
|
|
12
|
+
import { passesUnasked } from "./shell-read-device.js";
|
|
12
13
|
import { programRow, selectedOptions } from "./program-read-model.js";
|
|
13
14
|
import { allFindings, anyFaceGuards, decideShellRead, denyRowAtOrBelow, foldShellReadFindings, judgeEstablishedPlan, readRootGrantCandidateOf, stationGuards, writeFaceGuards, } from "./shell-read-judge.js";
|
|
14
15
|
import { seatOf } from "../../core/tool-name-readings.js";
|
|
@@ -32,7 +33,6 @@ const WRITES_BENEATH = {
|
|
|
32
33
|
};
|
|
33
34
|
const isPattern = (f) => f.read.candidate.expands !== false && f.read.candidate.expands !== "unresolvable";
|
|
34
35
|
const RESHAPES_TREE = new Set(["mv"]);
|
|
35
|
-
const anyOptionWord = (words) => words.some((w) => w.startsWith("-"));
|
|
36
36
|
const UNPLACED_OPTIONS = {
|
|
37
37
|
rmdir: (ws) => selectedOptions(programRow("rmdir"), ws).some((o) => o.effect === "writes"),
|
|
38
38
|
sed: (ws) => selectedOptions(programRow("sed"), ws).some((o) => o.value?.kind === "program-file"),
|
|
@@ -117,7 +117,7 @@ async function writeGuardsOf(judged, boundary, env, signal) {
|
|
|
117
117
|
runs.forEach((s, at) => {
|
|
118
118
|
for (const f of s.findings) {
|
|
119
119
|
const written = f.read.written;
|
|
120
|
-
if (written === undefined || f.read.established === "unestablishable" || f.read.target === undefined)
|
|
120
|
+
if (written === undefined || f.read.established === "unestablishable" || f.read.target === undefined || passesUnasked(f.read.candidate))
|
|
121
121
|
continue;
|
|
122
122
|
const child = written.child === true;
|
|
123
123
|
const key = f.read.established === "canonical" || f.read.established === "expansion";
|