@sema-agent/core 7.47.0 → 7.48.0
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 +54 -0
- package/dist/core/allow-entry.js +1 -1
- package/dist/core/checkpoint-store.d.ts +1 -1
- package/dist/core/mcp-server-spec.d.ts +1 -1
- package/dist/core/runner/park-commit.d.ts +1 -1
- package/dist/core/safety-axis-vocab.d.ts +1 -1
- package/dist/core/safety-merge-corpus.d.ts +1 -1
- package/dist/core/session-policy-store.d.ts +6 -3
- package/dist/core/session-policy-store.js +28 -10
- package/dist/core/shell-wrapper-table.d.ts +2 -2
- package/dist/core/shell-wrapper-table.js +1 -1
- package/dist/core/tool-name-aliases.d.ts +9 -8
- package/dist/core/tool-name-aliases.js +2 -0
- package/dist/core/tool-policy.js +4 -5
- package/dist/orchestration/workflow.js +2 -2
- package/dist/prompt-assembly/types.d.ts +1 -1
- package/dist/shell/lex.js +30 -4
- package/dist/tools/fs/bash-program-position.d.ts +7 -0
- package/dist/tools/fs/bash-program-position.js +5 -2
- package/dist/tools/fs/index.d.ts +3 -2
- package/dist/tools/fs/index.js +2 -2
- package/dist/tools/fs/program-read-model.d.ts +60 -5
- package/dist/tools/fs/program-read-model.js +100 -36
- package/dist/tools/fs/program-read-model.rows.js +13 -5
- package/dist/tools/fs/read-deny-home.d.ts +20 -1
- package/dist/tools/fs/read-deny-home.js +28 -7
- package/dist/tools/fs/read-deny.d.ts +13 -6
- package/dist/tools/fs/read-deny.js +12 -3
- package/dist/tools/fs/removal-targets.js +12 -3
- package/dist/tools/fs/shell-read-argv.d.ts +4 -7
- package/dist/tools/fs/shell-read-argv.js +5 -7
- package/dist/tools/fs/shell-read-bindings.d.ts +34 -9
- package/dist/tools/fs/shell-read-bindings.js +166 -60
- package/dist/tools/fs/shell-read-establish.js +3 -4
- package/dist/tools/fs/shell-read-plan.d.ts +1 -14
- package/dist/tools/fs/shell-read-plan.js +102 -101
- package/dist/tools/fs/shell-read-start.d.ts +57 -0
- package/dist/tools/fs/shell-read-start.js +92 -0
- package/dist/tools/fs/shell-read-word.d.ts +35 -6
- package/dist/tools/fs/shell-read-word.js +85 -9
- package/dist/tools/fs/shell-reentry-text.d.ts +23 -21
- package/dist/tools/fs/shell-reentry-text.js +31 -26
- package/package.json +1 -1
- package/wire-changes/core@7.48.0.json +252 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
+
## 7.48.0 — 2026-10-10
|
|
2
|
+
|
|
3
|
+
**Docs and tooling (zero behavior).** `docs/ARCHITECTURE-LAYERS.md` — the layering constitution (five layers + the L−1 primitives layer, rules R1–R8, six placement tests, gates G1–G3; adopted as drafted, C-R318, #1469; mirrored in the shared audits), with the reference-adapter positioning note (`src/examples/adapters/pg-adapter.ts`) and the `docs/ARCHITECTURE.md` storage line rewritten under it (one contract, several implementations is the legal shape; a second contract of the same name is the defect). Comments and docs say `web-admin` where they said the retired names (C-R325, #1499; historical records and the fixture env names that mirror server's real keys are unchanged). `scripts/verify-debts.mjs --rc-gate` resolves the shared ledger through the main checkout via the one git seam and refuses with exit 1 when no ledger is found (#1477; a release-candidate tree under /private/tmp used to pass vacuously).
|
|
4
|
+
|
|
5
|
+
**Shell read family (#1461 #1459 #1460 #1446 #1452 #1463 #1424 #1466) —** hard-breaking: none; closed sets: 0; exports: 0 added / 0 removed; `SHELL_WRAPPER_TABLE` (exported constant): the `watch` row gains its option sets (the projection of its two implementations' grammar rows; `-d`, which they read differently, is left out). Behavior: narrowing, with four loosenings named below (each open to veto; #1461 names two) and one plan-level reading (#1452).
|
|
6
|
+
|
|
7
|
+
- **#1461 one value table (narrowing; open to veto).** A segment is read once per combination of one value for each name its words may hold several values of; in each reading a settled word is its value for every reader — the program position (`P=rm; "$P" -rf <root>`: `dangerous_delete` on every arm; was `ordinary`, run under a `Bash` allow and under bypassPermissions), a launcher's fields, a re-entered text, an operand, a pipe's printed text. An unquoted value of words splits into its fields (`X="<root> zz"; sudo -s rm -rf $X`: `dangerous_delete`; was `probe`). The recursive-forced-removal text check reads a value's safe-root reference as the reference it is (`X='$PWD'; sudo -s rm -rf "$X"` ≡ `sudo -s rm -rf '$PWD'`: `unverifiable_delete`). **Loosening:** an unquoted value is no longer an unresolved read unless it holds a backslash, a `~` or an extglob parenthesis (a quote, `$`, brace or `!` in it is a character: bash re-reads none) — a multi-word one is split into its fields, and each field is read as a word of its own under the same identity rule as a written word (named by its text, its `NAME=value` shape kept), never as syntax (`X="for <root>/apple"; $X` runs a program named `for`; no field is a loop, block, control word, `coproc` or assignment), by every reader, the removal guard included: `cat $X` with `X='a b'` reads `a` and `b`; removing the task root still asks (`X="-- <root>"; rm -rf $X`, `X="-v <root>"; rm -rf $X`, `X="-rf <root>"; rm $X`, `command rm $X`: `dangerous_delete`, as `rm -rf -- <root>`; were `unverifiable_delete` / `probe`), and `X='if=a of=b'; dd $X` reads `a`; `X=ls; su root -c "$X"` is no longer a runtime-script `dangerous_delete` (the script is `ls`). The lexer's command position is the one reading of "is this word syntax", and it now covers `coproc`: the word after `coproc`, and after `coproc NAME`, stands at command position (bash's grammar), so `coproc [NAME] [[ 0 -eq 'a[$(cat <key>)]' ]]` / `(( … ))` / `{ …; }` plan the nested command the arithmetic or group runs, as on 7.47.0 (without it, `coproc [[ 0 -eq 'a[$(rm -rf <root>)]' ]]` would run without a removal card under a `Bash` allow rule and under bypassPermissions, and the protected-file read inside the same form would still be denied — through the operand reading alone).
|
|
8
|
+
- **#1459 every launcher field asks the value table (narrowing).** `su -c` / `runuser -c` / `watch` / `bash -c` hand every established value (were: one) — a link to a denied file named by any value is refused.
|
|
9
|
+
- **#1460 the fold counts distinct names; no sample (narrowing).** Names are read in the order the words read them while the readings stay within 64; a name past that is unknown, every value it held a deny floor — carried into the shell a launcher starts. The 7.47.0 diagonal sample is gone: `sudo -s "$P" -rf "$X"` nine × nine with `rm` and the root off the diagonal is `dangerous_delete` (was `probe`, run under bypassPermissions). A loop nested in a body (`do for x in …`) now binds its name (was unknown: the test-[9595] double `for` loop asked `probe`). **Loosening:** such a loop's reads are now established (a read-only nested loop over in-root files no longer asks the read-face `probe`: it runs under a `Bash` allow or bypassPermissions without a card; the default arm still asks, `ordinary`).
|
|
10
|
+
- **#1446 one launcher reading: getopt clusters (narrowing).** A launcher option cluster is read as getopt reads it (`sudo -iu root rm -rf ~`, `sudo -su root …`, `env -iu FOO rm -rf ~`: `dangerous_delete`; were a decline run under bypassPermissions) — its command-line letter too: the rest of the word, or the next word, is the command line it runs (`env -vS 'rm -rf ~'`, `env -iS'rm -rf ~'`: `dangerous_delete`; were run under a `Bash` allow and bypassPermissions on 7.47.0 as well).
|
|
11
|
+
- **#1452 `watch` declares its options, procps and BusyBox (narrowing).** procps: `-n` / `--interval`, `-q` / `--equexit`, `-s` / `--shotsdir` (a directory it writes into) take a value, `-d` / `--differences` one only attached (`-dn` is `-d` holding `n`), `-x` / `--exec` run the program; BusyBox: `-d` / `-t` switches, `-n` a value. `--` ends the options; each implementation's words from its first operand on are a shell text, read (an implementation that rejects an option reads none): `watch -n 1 'rm -rf ~'` (was `probe`, run under bypassPermissions), `watch -dn 1 'rm -rf ~'`, `watch -dn rm -rf ~`: `dangerous_delete`. A launcher's option is told apart by its spelling — a short switch in front (`watch -d 'rm -rf ~'`, `-c`, `-b`, `-t -d`, `-d=permanent`) no longer reads as `-x`, nor `su -f` as `-l`. **Plan only:** `watch -n 5 ls -l` names `ls -l` as its text (was a program named `5`); the lane still asks `probe` for it, as on 7.47.0.
|
|
12
|
+
- **#1463 a shell run as another user starts with that user's home (narrowing; message change).** `sudo -i`, `su` / `runuser` (not `-m` / `-p`), `doas -s`: `HOME` is the target's (root: `/root`, macOS `/var/root`; another user: unknown); `sudo -s`: the caller's or the target's (sudoers policy); a login shell (`sudo -i`, `su -` / `-l`) stands in that home (`sudo -i rm -rf ../etc`: `dangerous_delete`; was `probe`, run under bypassPermissions). `sudo -i rm -rf ~`, `su root -c 'rm -rf ~'` now card the top-level `/root` (were: the caller's home). KL-1421-a retired.
|
|
13
|
+
- **#1424 standard input flows (narrowing; open to veto).** A group's members read the pipe that feeds the group (`echo 'rm -rf ~' | { true; bash; }`); a `-c` text reads its shell's input (`bash -c 'bash' <<EOF`); a shell's process-substitution script file (`bash <(…)`) and a shell whose argv a stream fills in (`… | xargs -I{} bash -c '{}'`) run a script built when the command runs (`dangerous_delete`, `runtime_script`) — also for a harmless text (`ls | xargs -I{} sh -c 'echo {}'`).
|
|
14
|
+
- **#1466 the judge sees every name bash opens (narrowing).** A part of a pattern word (an attached option value, `if=` / `NAME=` values) carries the word's widened pattern (`grep --file=["!"a]* x`, `grep -f["!"a]* x`, `dd if=["!"a]*` refused where they reach a denied link); a `[:` / `[=` / `[.` that forms no unit is a member as bash reads it, and a class holding one is widened (`[[:alpha]]`, `[[:a]x/y:]`, `[a[=b]c=]` — bash 3.2's literal name and 5.3's match both judged); an array element's floor carries its pattern; an unquoted value's glob characters form its pattern (`X='<root>/["!"a]*'; cat $X`) and an expansion inside brackets forms the class from its value (`X=/; cat [${X}a]*`); the pattern is formed once the whole field has expanded — a value's `[` and a literal's `]` form one class (`X='['; cat ${X}a]pple` refused where `apple` is a denied link), a value split inside brackets hands its words (`X=" <root> "; rm -rf [${X}]`: `dangerous_delete`; was `probe`, run under bypassPermissions), a quoted `-` there is a member, never a range. **Loosening:** an unquoted value with glob characters is no longer an unresolved read (a pattern over in-root names no longer asks the read-face `probe`: it runs under a `Bash` allow or bypassPermissions without a card; the default arm still asks, `ordinary`). KL-7470-c / KL-7470-d retired.
|
|
15
|
+
|
|
16
|
+
**Package exports** (`npm run handoff:diff v7.47.0 HEAD`): +0 / −0 for the four items below (no export added or removed; one exported function, `normalizeRules`, throws where it used to return, and one exported type, `ReadDenyEntry`, documents a spelling it always accepted).
|
|
17
|
+
|
|
18
|
+
### Session rules: a bucket that is not a list of strings is refused — a bare `"rm"` was stored as `["r","m"]` (#1471; behavior narrowing: string-shaped rules are now rejected) (@server)
|
|
19
|
+
|
|
20
|
+
- **Before (v7.47.0).** `normalizeRules` spread whatever a bucket held: `commandDeny: "rm"` became `["r","m"]` — a deny of two commands that do not exist, so `rm` ran — and `allowDirs: "/x"` threw a raw `.map is not a function`. Both bundled stores' `putRules` stored the result.
|
|
21
|
+
- **Now.** `normalizeRules` reads each of the five buckets (`toolAllow`, `toolDeny`, `commandAllow`, `commandDeny`, `allowDirs`) through one reader and throws a `TypeError` (`session rules commandDeny must be an array of strings, got a string …`) for a bucket that is not an array of strings — a bare string, `null`, an object, an array holding a non-string, or an array holding an empty or white-space-only entry (`""` is a string and no name; `allowDirs: [""]` used to be stored as `"."`) — the last in the same sentence family (`… got an array holding an empty or blank entry — a rule list is written as a list of names`), no new code. Both stores' `putRules` call it before they judge names, tighten or persist anything, so the write rejects and nothing is stored: no row, no high-water mark, no rev taken (an existing row keeps its rev and its list; pinned for a shape refusal, an empty / blank entry, a retired name and an unmintable name, on both stores). The refusal is not a `SessionPolicyError` code (the four-word code set is unchanged). Absent / `undefined` buckets stay absent; `[]` is a list.
|
|
22
|
+
- **Who is hurt.** A host that handed rules as a string, with non-string entries, or with an empty entry (for example a list split from an empty setting, `"".split(",")` is `[""]`) — now a `TypeError` from `putRules` / `normalizeRules` instead of a stored dead list. Callers of `normalizeRules` outside the bundled stores (read from the server tree: its SQL session-policy store and session sync) throw on a malformed record where they stored characters. A row an older release already stored as characters (or holding `""`) stays as written; an operator write replaces it (a non-operator write reads the removed entries as a loosening — `loosenReasons`; the judge answers `current` for `""`, so a stored empty `toolDeny` entry is not removable by the session's owner).
|
|
23
|
+
- **Compensation.** Write `commandDeny: ["rm"]`.
|
|
24
|
+
- Pins: `test/backlog7480-smallfix.test.ts` #1471 ①②.
|
|
25
|
+
|
|
26
|
+
### A protocol name with an empty tool segment (`mcp__srv__`) is judged `unmintable_segment`, not `current` (#1400; behavior narrowing: a rule that guarded nothing in silence is now refused or announced) (@server @cli)
|
|
27
|
+
|
|
28
|
+
- **Before (v7.47.0).** `judgeRuleToolName("mcp__srv__", roster)` answered `current` and `compileAllowEntry("mcp__srv__", "deny" | "allow", roster)` compiled a `tool` row named `mcp__srv__` — a row no tool is minted under, with no notice. `APPROVAL_DENY=mcp__srv__` read as "deny the server" and guarded nothing.
|
|
29
|
+
- **Now.** A protocol name that ends in the separator (an empty tool segment, in every namespace of the protocol table) answers the existing `unmintable_segment` verdict, with a one-line `guidance` naming the empty tool segment and the two spellings (`<prefix><peer>` for every tool of a peer, `<prefix><peer>__<tool>` for one tool) and no `currentName`. No new verdict word (the closed set stays four). It is judged before the peer's spelling, so no suggested rewrite is itself dead. Its readers answer as for the empty peer: a session-rule write is refused (`SessionPolicyError` `legacy_tool_name`, `detail.verdict: "unmintable_segment"`); a run whose tool policy or stored session row holds the name fails at prepare (`config.legacy_tool_name`); an `allowedTools` / `disallowed-tools` entry read against a roster is announced as `tool_name`, and the notice says "a namespaced name with a segment the engine never mints" (was "a peer segment …"). The roster-less compile still reads the grammar alone (as it does for the empty peer).
|
|
30
|
+
- **Who is hurt.** A deployment, policy or stored session row that holds `mcp__<server>__` / `a2a__<peer>__`: a run that started with the dead entry now fails at prepare (a session write is refused); a stored row's owner takes the entry out with an ordinary write (removing it is not a loosening).
|
|
31
|
+
- **Compensation.** Write `mcp__<server>` to cover the whole server, `mcp__<server>__<tool>` for one tool.
|
|
32
|
+
- **Consumers.** server — the one question `namespaced-rule-name.ts` kept for this name (server's file; the ledger names it) can go once it takes this build.
|
|
33
|
+
- Pins: `test/backlog7480-smallfix.test.ts` #1400 ①②③.
|
|
34
|
+
|
|
35
|
+
### `workflow_run.error` is secret-redacted where the engine mints it (#1472; behavior change: credential-shaped text in a failed run's `error` is now `[redacted…]`) (@server)
|
|
36
|
+
|
|
37
|
+
- **Before (v7.47.0).** `runWorkflow` minted `WorkflowRun.error` from the thrown text unredacted while `result` went through `redactSecrets`; the file run store (`DB_BACKEND=local`) wrote `error` to disk as it came, and a SQL store had to redact it again on its own.
|
|
38
|
+
- **Now.** The failure branch passes the thrown text through the same `redactSecrets` (same `[redacted…]` markers) — no length bound is added. The thrown value itself reaches the caller untouched. In-memory and file stores hold the redacted text; a store that redacts a second time changes nothing (the redactor is idempotent on its own output — pinned).
|
|
39
|
+
- **Who is hurt.** A consumer that matched a credential-shaped fragment inside `run.error`. Rows an older release already wrote stay as written.
|
|
40
|
+
- **Consumers.** server — its own pass at the SQL store's write (`workflow-run-store-sql.ts:91`) is now a second pass over already-redacted text; it stays until the 7.49.0 fixture union settles ONE detection table — the two tables are not supersets of each other today ([9588]: each side passes patterns the other catches), so retiring it now would drop the server-only patterns (#1468 phase two).
|
|
41
|
+
- Pins: `test/backlog7480-smallfix.test.ts` #1472.
|
|
42
|
+
|
|
43
|
+
### `readDenyPatterns`: a pattern that opens with `/` is rooted (#1442; behavior narrowing: the row now names that one location, not that tail at any depth) (@server @cli)
|
|
44
|
+
|
|
45
|
+
- **Before (v7.47.0).** `normalizeEntry` stripped the leading `/` and the row matched its segments at ANY depth (ASCII-folded): a deployment's `/app/config-lkg.json` also blocked a user repo's `<anything>/app/config-lkg.json` — Read refused it, Grep silently left it out, the rg legs excluded `!**/app/config-lkg.json`.
|
|
46
|
+
- **Now.** A pattern that opens with `/` (and whose first segment is not the home word) is **rooted**: its run starts at the first segment of an absolute path — `/app/config-lkg.json` and what lies under it, nothing at depth. The mechanism is the home word's (`anchors` + `rootedOnly`), the case rule is the entry's `caseSensitive` knob (default ASCII-folded), `*` stays one segment. The canonical entry text keeps the leading `/` (`effectiveReadDenyPatterns`, the persisted entries), so `/a/b` and `a/b` are two rows and `//a//b/` is `/a/b`; `/~/x` is still the home word (`~/x`). A path spelling that is not absolute is not judged by a rooted row (the resolved key is). A rooted row carries no rg exclusion glob (`unexcludableUnder`): a Grep whose scope is relative, above or at the row does not use the rg exclusion and judges with the matcher — the route a `~/` row already takes (read from `search.ts`; the Grep cell below runs it). The built-in table has no row that opens with `/` (additive). **A rooted row is placed where its literal head leads, as a `~/` row is**: the head (the run's segments before the first `*`) is resolved in the execution environment the way the read faces mint their keys, and where a component of it is a symlink or a mount point the row is placed at the target too, the rest of its run matched below it — so `/app/config-lkg.json` with `/app` a link to `/data/app` also refuses a direct read of `/data/app/config-lkg.json`, and the deployment names the declared spelling alone. It is one machine (`linkedLocationsOf`, `read-deny-home.ts`) with a second row builder (`readDenyRootLinksOf`, beside `readDenyHomeLinksOf`) and the one `linked` arm in `compileReadDeny`; the placement reads it whether or not a home answered, a head the environment does not answer leaves the placement not whole (asked again, as for a `~/` row), a head with nothing below it or none at all (`/*.json`) adds nothing. The module-internal functions `readDenyRootLinksOf` / `isRootedReadDenyPattern` are not package exports.
|
|
47
|
+
- **Who is hurt.** A deployment whose `readDenyPatterns` row opens with `/` and that relied on the any-depth reading (for example a row meant to guard a mounted path under several parents): it now guards the exact absolute location only. A win32 path keeps its drive segment (`C:`), so a `/…` row does not name it (read from the matcher; not run on win32). A checkpoint written by an older release holds the stripped, any-depth form and resumes with it beside the new rooted row (the union, wider; inferred from the resume seat's addition list).
|
|
48
|
+
- **Compensation.** Spell the row without the leading `/` to keep the any-depth reading; write `/…` for the exact location. A deployment need not list the declared spelling and the realpath both: the placement adds the target of a link. server's two rows derived from the config path (declared spelling and realpath) are exact locations now and the second is redundant but harmless (@server).
|
|
49
|
+
- **Known limits.** The placement resolves a row's literal head only. A link BELOW the head, under a wildcard run (`/app/*.json` with `/app/secret.json` a link to `/vault/key`), is not enumerated: the row refuses the spelling that names the link and not a direct read of `/vault/key` — the same for a `~/` row, and not a regression (the any-depth row did not refuse it either); name the target to guard it. A rooted head under a directory the engine may not enter leaves a library mount's placement not whole, asked again at the next placement (a `~/` head's law).
|
|
50
|
+
- Pins: `test/backlog7480-smallfix.test.ts` #1442 ①②③④ (③ is the real Read and Grep through the production toolkit; ④ is the placement: the link's target through the toolkit, a wildcard rest, the Runner's carried placement, the held-anywhere path and an unanswered head; `~/` row placed today as the premise).
|
|
51
|
+
|
|
52
|
+
**Pins and mutations.** `test/backlog7480-smallfix.test.ts` (80 cells, r2 included; with each ticket's domain files put back to the base `5c7d569b9` the pin reads 13 / 12 / 3 / 12 red for #1471 / #1400 / #1472 / #1442, and 6 more red for the #1442 placement alone, every control cell green). Mutations (`.gate-logs/criteria-7480-mutations.md`, per fixture, each one's expected-red fixtures hit and its controls kept): M1471a–e, g–k (validation off; a bare string accepted as one rule; one bucket unchecked, twice; non-string entries accepted; a sparse array's hole skipped; r2: empty / blank entries accepted, names judged after the store's write, in both stores), M1400a–g (the arm off; arm after the peer rewrite; bare peer also flagged; mcp only; a new verdict word; the verdict set gains a word; the allow layer ignores the verdict), M1472a–b (unredacted; length-bounded), M1442a–f, h (leading `/` stripped; `rootedOnly` dropped; unanchored; home word loses precedence; persisted check on the old spelling; case fold lost; rg globs minted), MP1–MP7 (the placement: linked arm skips rooted rows; the main path drops the rooted answer; the held-anywhere path omits it; the places-nothing test ignores rooted rows; the head ignores its first star; `whole` ignores the rooted heads; an emptied `anchored`). `test/read-deny.test.ts` "separator spellings canonicalize ONCE" is re-pinned (its first entry `/corp-secrets` was the old any-depth reading; it is `corp-secrets//` now).
|
|
53
|
+
- **Rollback.** A checkpoint 7.48.0 writes carries a `/…` row in its rooted, canonical form; a worker on core 7.47.x resuming it refuses (`checkpoint.invalid_outcome`, reason `read_face_damaged` — the older screen holds every persisted deny entry to its own canonicality bar) and the checkpoint stays pending. Roll back only when no parked task holds such a checkpoint, or resume them on 7.48.0 first (@server).
|
|
54
|
+
|
|
1
55
|
## 7.47.0 — 2026-10-09
|
|
2
56
|
|
|
3
57
|
**Erratum (docs; 7.43.0 compensation).** The 7.43.0 compensation sentence «finish a parked task on a worker of the release that parked it» named a way out without its cost: that release reads the twin / retired `disallowed-tools` entry as nothing too, so the tool the entry meant to deny runs there with no refusal (KL-1372-b). Finishing there is a choice to run that task without that deny; the recovering way out is to expire the task and submit it again with the entry corrected. `docs/INTEGRATION-CORE.md` says so now. Reported by the test lane ([9368] ④).
|
package/dist/core/allow-entry.js
CHANGED
|
@@ -405,7 +405,7 @@ export function inertEntryPhrase(e) {
|
|
|
405
405
|
case "unprefixed_namespace":
|
|
406
406
|
return 'tool_name: a "__" name with no protocol prefix';
|
|
407
407
|
case "unmintable_segment":
|
|
408
|
-
return `tool_name: a
|
|
408
|
+
return `tool_name: a namespaced name with a segment the engine never mints${spelling !== undefined ? `, write "${spelling}"` : ""}`;
|
|
409
409
|
default: {
|
|
410
410
|
const exhaustive = e.verdict;
|
|
411
411
|
return exhaustive;
|
|
@@ -1231,7 +1231,7 @@ export interface CheckpointState {
|
|
|
1231
1231
|
* [c209-C] Q5 — the listing frames' announced NAME-SETS at suspend (agent types / skills the model
|
|
1232
1232
|
* has actually been shown this run, committed on first-frame delivery or intact boundary survival).
|
|
1233
1233
|
* A resume leg seeds its producers from these names and DIFFS the current roster/skill set against
|
|
1234
|
-
* them, so cross-leg drift (
|
|
1234
|
+
* them, so cross-leg drift (web-admin roster change between suspend and resume) is announced as
|
|
1235
1235
|
* a delta frame at the first boundary instead of silently swallowed — the exact event the listing
|
|
1236
1236
|
* eviction from the cached prefix exists to serve. Names only, never contents (a description change
|
|
1237
1237
|
* on a surviving name is deliberately not re-announced — name-keyed diff, agent_listing parity).
|
|
@@ -9,7 +9,7 @@ import type { ToolContentOrigin, ToolEffect } from "./tool-spec.js";
|
|
|
9
9
|
import type { ToolFamily, ToolPathTarget, ToolRenderHints, ToolRuleFace } from "./tool-face.js";
|
|
10
10
|
/** Definition of an MCP server to connect to for the duration of one task, then discard. */
|
|
11
11
|
export interface McpServerSpec {
|
|
12
|
-
/** Stable name; tool names are namespaced as
|
|
12
|
+
/** Stable name; tool names are namespaced as `mcp__<server>__<tool>` (the protocol prefix, then this name as the peer segment) to avoid collisions. */
|
|
13
13
|
name: string;
|
|
14
14
|
/**
|
|
15
15
|
* Where this declaration CAME FROM, in the declaring side's own words (a shell's `plugin` / `user` /
|
|
@@ -33,7 +33,7 @@ export declare const DEFAULT_UNATTENDED_APPROVAL_TTL_MS: number;
|
|
|
33
33
|
* A caller-supplied TTL is only a TTL if it is a finite, positive number.
|
|
34
34
|
* `TaskSpec.durableApproval.ttlMs` / `resourceSuspend.ttlMs` are typed as a bare `number`, and the mint
|
|
35
35
|
* arithmetic was `Date.now() + ttlMs` with NO validation — so `Number(process.env.APPROVAL_TTL_MS)` on an
|
|
36
|
-
* unset/malformed env var (a
|
|
36
|
+
* unset/malformed env var (a web-admin miss, an upstream arithmetic slip) silently produced
|
|
37
37
|
* `deadline = NaN`. Every reaper comparison is `deadline <= cutoff`, and `NaN <= anything` is FALSE, so a
|
|
38
38
|
* checkpoint that LOOKS bounded (it carries a ttlMs and a deadline) became permanently IMMORTAL: no reap
|
|
39
39
|
* cutoff, however far in the future, could ever expire it → a pinned session + a stranded paused VM, and
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `SAFETY_AXIS_VOCABULARY` — the PUBLISHED safety-axis vocabulary as a zero-logic, zero-runtime-dependency DATA
|
|
3
3
|
* artifact (pure string / number / boolean literal constants).
|
|
4
4
|
*
|
|
5
|
-
* **design/99 center control-plane seam (2026-06-27).** The
|
|
5
|
+
* **design/99 center control-plane seam (2026-06-27).** The web-admin control plane's per-field
|
|
6
6
|
* safety-classification table lives in `@sema-agent/registry-core` and, for every safety-critical config field, cites a
|
|
7
7
|
* core axis VALUE (e.g. `effect: "write"`, `irreversibility: "always"`). That contract package **must not depend on
|
|
8
8
|
* `@sema-agent/core` (the engine) at runtime** — so it dev-imports THIS const (test-only) and runs a conformance test
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SessionPermissionRules } from "./session-policy-store.js";
|
|
2
2
|
/**
|
|
3
|
-
* design/99 (
|
|
3
|
+
* design/99 (web-admin control-plane seam, channel review R2): the PUBLISHED golden conformance corpus
|
|
4
4
|
* for the safety-domain MERGE predicate {@link loosenReasons}.
|
|
5
5
|
*
|
|
6
6
|
* THE SEAM. The center control-plane's ConfigBundle merger is a DISPATCHER: for any field it classifies
|
|
@@ -67,7 +67,7 @@ export interface SessionRuleNameRefusal {
|
|
|
67
67
|
/** Typed error from {@link SessionPolicyStore.putRules}. `conflict` = stale `expectedRev`; `loosen_forbidden`
|
|
68
68
|
* = a non-operator write tried to relax the rules; `corrupt` = a backend could not read the erase-surviving
|
|
69
69
|
* rev mark ({@link SessionPolicyStore.deleteBySession}) and refuses rather than stamp an unproven rev; the fourth
|
|
70
|
-
* code = a tool-name bucket names a retired name, a prefix-less `__` name or a
|
|
70
|
+
* code = a tool-name bucket names a retired name, a prefix-less `__` name or a segment no mint spells (an empty one, or a peer spelled otherwise)
|
|
71
71
|
* ({@link assertSessionRuleNamesCurrent}) — `detail` is present on that code and only on it. */
|
|
72
72
|
export declare class SessionPolicyError extends Error {
|
|
73
73
|
readonly code: "conflict" | "loosen_forbidden" | "corrupt" | "legacy_tool_name";
|
|
@@ -121,7 +121,8 @@ export interface SessionPolicyStore {
|
|
|
121
121
|
/** Write the rules for a key: CAS on `expectedRev`, then the name judge ({@link assertSessionRuleNamesCurrent} —
|
|
122
122
|
* operator writes included), then, for a non-operator write over an existing row, the
|
|
123
123
|
* tighten-only check ({@link loosenReasons}) — both against the names the deployment declares it mounts its own
|
|
124
|
-
* tools under.
|
|
124
|
+
* tools under. A bucket that is not a list of strings (a bare `"rm"` for `commandDeny`) throws a `TypeError` from
|
|
125
|
+
* {@link normalizeRules} before any of those is judged, and nothing is written. */
|
|
125
126
|
putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
|
|
126
127
|
/**
|
|
127
128
|
* 2c session-sync: every (principal, rules) record for `sessionId` across ALL principals — `getRules`
|
|
@@ -172,7 +173,9 @@ export interface SessionPolicyStore {
|
|
|
172
173
|
*/
|
|
173
174
|
declare function lexWithin(root: string, p: string): boolean;
|
|
174
175
|
/** Normalize a rules object's `allowDirs` (lexical `posix.normalize`) so stored rules are `..`-free and the
|
|
175
|
-
* tighten diff compares the same canonical-ish form the runtime resolves.
|
|
176
|
+
* tighten diff compares the same canonical-ish form the runtime resolves. Every bucket must be a list of strings
|
|
177
|
+
* ({@link ruleList}); a bucket that is not one throws `TypeError`, so neither bundled store (nor a durable twin that
|
|
178
|
+
* normalizes through this function) can store a rule list the matcher would read differently from the one written. */
|
|
176
179
|
export declare function normalizeRules(rules: SessionPermissionRules): SessionPermissionRules;
|
|
177
180
|
/**
|
|
178
181
|
* Pure tighten-only diff: the reasons `next` LOOSENS `prior` (empty ⇒ `next` is a pure tightening or no-op).
|
|
@@ -75,18 +75,36 @@ function lexWithin(root, p) {
|
|
|
75
75
|
return true;
|
|
76
76
|
return np === nr || np.startsWith(nr + "/");
|
|
77
77
|
}
|
|
78
|
+
function ruleList(field, value) {
|
|
79
|
+
if (value === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
const refuse = (got) => {
|
|
82
|
+
throw new TypeError(`session rules ${field} must be an array of strings, got ${got} — a rule list is written as a list of names (["rm"], not "rm")`);
|
|
83
|
+
};
|
|
84
|
+
if (Array.isArray(value) && Array.from(value).every((entry) => typeof entry === "string")) {
|
|
85
|
+
if (value.some((entry) => entry.trim() === ""))
|
|
86
|
+
refuse("an array holding an empty or blank entry");
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
return refuse(Array.isArray(value) ? "an array holding an entry that is not a string" : value === null ? "null" : `a ${typeof value}`);
|
|
90
|
+
}
|
|
78
91
|
export function normalizeRules(rules) {
|
|
79
92
|
const out = {};
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
const toolAllow = ruleList("toolAllow", rules.toolAllow);
|
|
94
|
+
const toolDeny = ruleList("toolDeny", rules.toolDeny);
|
|
95
|
+
const commandAllow = ruleList("commandAllow", rules.commandAllow);
|
|
96
|
+
const commandDeny = ruleList("commandDeny", rules.commandDeny);
|
|
97
|
+
const allowDirs = ruleList("allowDirs", rules.allowDirs);
|
|
98
|
+
if (toolAllow !== undefined)
|
|
99
|
+
out.toolAllow = [...toolAllow];
|
|
100
|
+
if (toolDeny !== undefined)
|
|
101
|
+
out.toolDeny = [...toolDeny];
|
|
102
|
+
if (commandAllow !== undefined)
|
|
103
|
+
out.commandAllow = [...commandAllow];
|
|
104
|
+
if (commandDeny !== undefined)
|
|
105
|
+
out.commandDeny = [...commandDeny];
|
|
106
|
+
if (allowDirs !== undefined)
|
|
107
|
+
out.allowDirs = allowDirs.map((d) => posix.normalize(d).replace(/\/+$/, "") || "/");
|
|
90
108
|
return out;
|
|
91
109
|
}
|
|
92
110
|
export function loosenReasons(prior, next, mountedToolNames = NO_DECLARED_TOOLS) {
|
|
@@ -114,8 +114,8 @@ export declare const SHELL_WRAPPER_TABLE: {
|
|
|
114
114
|
readonly commandString: readonly [];
|
|
115
115
|
};
|
|
116
116
|
readonly watch: {
|
|
117
|
-
readonly valueFlags: readonly [];
|
|
118
|
-
readonly flags: readonly [];
|
|
117
|
+
readonly valueFlags: readonly ["--equexit", "--interval", "--shotsdir", "-n", "-q", "-s"];
|
|
118
|
+
readonly flags: readonly ["--beep", "--chgexit", "--color", "--differences", "--errexit", "--exec", "--follow", "--help", "--no-color", "--no-rerun", "--no-title", "--no-wrap", "--precise", "--version", "-C", "-b", "-c", "-e", "-f", "-g", "-h", "-p", "-r", "-t", "-v", "-w", "-x"];
|
|
119
119
|
readonly commandString: readonly [];
|
|
120
120
|
readonly viaShell: true;
|
|
121
121
|
};
|
|
@@ -15,7 +15,7 @@ export const SHELL_WRAPPER_TABLE = {
|
|
|
15
15
|
timeout: { valueFlags: ["--kill-after", "--signal", "-k", "-s"], flags: ["--foreground", "--help", "--preserve-status", "--verbose", "--version", "-v"], commandString: [], duration: true },
|
|
16
16
|
stdbuf: { valueFlags: ["--error", "--input", "--output", "-e", "-i", "-o"], flags: ["--help", "--version"], commandString: [] },
|
|
17
17
|
xargs: { valueFlags: ["--arg-file", "--delimiter", "--max-args", "--max-chars", "--max-procs", "--process-slot-var", "-E", "-I", "-L", "-P", "-a", "-d", "-n", "-s"], flags: ["--eof", "--exit", "--help", "--interactive", "--max-lines", "--no-run-if-empty", "--null", "--open-tty", "--replace", "--show-limits", "--verbose", "--version", "-0", "-o", "-p", "-r", "-t", "-x"], attached: ["-i", "-l"], commandString: [] },
|
|
18
|
-
watch: { valueFlags: [], flags: [], commandString: [], viaShell: true },
|
|
18
|
+
watch: { valueFlags: ["--equexit", "--interval", "--shotsdir", "-n", "-q", "-s"], flags: ["--beep", "--chgexit", "--color", "--differences", "--errexit", "--exec", "--follow", "--help", "--no-color", "--no-rerun", "--no-title", "--no-wrap", "--precise", "--version", "-C", "-b", "-c", "-e", "-f", "-g", "-h", "-p", "-r", "-t", "-v", "-w", "-x"], commandString: [], viaShell: true },
|
|
19
19
|
ionice: { valueFlags: ["--class", "--classdata", "--pgid", "--pid", "--uid", "-P", "-c", "-n", "-p", "-u"], flags: ["--help", "--ignore", "--version", "-V", "-h", "-t"], commandString: [] },
|
|
20
20
|
setsid: { valueFlags: [], flags: ["--ctty", "--fork", "--help", "--version", "--wait", "-c", "-f", "-w"], commandString: [] },
|
|
21
21
|
taskset: { valueFlags: [], flags: ["--all-tasks", "--cpu-list", "--help", "--pid", "--version", "-a", "-c", "-p"], commandString: [], positional: /^(?:0x)?[\da-f]+$|^[\d,:-]+$/i },
|
|
@@ -38,12 +38,13 @@ export declare const RULE_TOOL_NAME_VERDICTS: readonly ["current", "retired", "u
|
|
|
38
38
|
* - `unprefixed_namespace` — carries the `__` namespace separator with no protocol prefix. The roster cannot clear it:
|
|
39
39
|
* a caller tool name or alias carrying `__` is refused at prepare, every engine tool name is `__`-free, and every
|
|
40
40
|
* protocol tool is minted with its prefix.
|
|
41
|
-
* - `unmintable_segment` — carries a protocol prefix, but
|
|
42
|
-
* the mint produces for any peer
|
|
43
|
-
* the minted spelling, so the name reaches none of them
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
41
|
+
* - `unmintable_segment` — carries a protocol prefix, but a segment of it is not one the mint produces: the peer segment
|
|
42
|
+
* (the server / remote agent) is empty or not a spelling the mint produces for any peer (the name is read as written —
|
|
43
|
+
* peers compare exactly — and every mounted tool carries the minted spelling, so the name reaches none of them), or the
|
|
44
|
+
* tool segment is EMPTY (a trailing separator, `mcp__srv__`: no tool is minted under an empty segment). `guidance` says
|
|
45
|
+
* so in one line; `currentName` is the whole name rewritten with the spelling the mint produces (`mcp__github.com__*` →
|
|
46
|
+
* `mcp__github_com__*`), absent when a segment is empty and there is nothing to rewrite. A tool segment that is not empty
|
|
47
|
+
* is never judged: it is a literal tool name or a glob over one, and an unmatched one is the audit's advisory.
|
|
47
48
|
*
|
|
48
49
|
* `roster` = the tool names (aliases included) the reader knows are mounted: a run's assembled roster at prepare, the
|
|
49
50
|
* names a deployment DECLARES it mounts its own tools under at a session-rule store (an empty set when it declares none).
|
|
@@ -67,8 +68,8 @@ export type RuleToolNameVerdict = {
|
|
|
67
68
|
* `undefined` for a name that is not namespaced (no protocol prefix). The peer is the first segment — EMPTY for `mcp____x` and
|
|
68
69
|
* for the bare prefix, which name no peer; the tool segment is the REST of the split rejoined, so a tool whose minted segment
|
|
69
70
|
* itself carries the separator (charset normalization maps `get..issue` → `get__issue`) decomposes back to one tool name, and
|
|
70
|
-
* is absent when the name stops at the peer (`mcp__srv`)
|
|
71
|
-
* allow-entry grammar all read a namespaced name through it.
|
|
71
|
+
* is absent when the name stops at the peer (`mcp__srv`) and EMPTY when it ends in a trailing separator (`mcp__srv__`). The
|
|
72
|
+
* one decomposition: the judgment, the covering matcher and the allow-entry grammar all read a namespaced name through it.
|
|
72
73
|
*/
|
|
73
74
|
export declare function splitNamespacedName(name: string): {
|
|
74
75
|
readonly ns: ProtocolNamespace;
|
|
@@ -56,6 +56,8 @@ export function splitNamespacedName(name) {
|
|
|
56
56
|
function unmintableSegmentOf({ ns, peer, tool }) {
|
|
57
57
|
if (peer === "")
|
|
58
58
|
return { kind: "unmintable_segment", guidance: `the ${ns.id} name has an empty peer segment — no tool is minted under it` };
|
|
59
|
+
if (tool === "")
|
|
60
|
+
return { kind: "unmintable_segment", guidance: `the ${ns.id} name has an empty tool segment — no tool is minted under it; write ${ns.prefix}<peer> to cover every tool of a peer, or ${ns.prefix}<peer>${NAMESPACE_SEPARATOR}<tool> for one tool` };
|
|
59
61
|
const minted = mintNamespacePrefix(ns, peer).slice(ns.prefix.length, -NAMESPACE_SEPARATOR.length);
|
|
60
62
|
if (minted === peer)
|
|
61
63
|
return undefined;
|
package/dist/core/tool-policy.js
CHANGED
|
@@ -725,7 +725,7 @@ function assignmentTokenSpan(tokens, i) {
|
|
|
725
725
|
return 1;
|
|
726
726
|
}
|
|
727
727
|
function valueIsOpaque(value, assigned, safeVars) {
|
|
728
|
-
const v = value.replace(/["']/g, "");
|
|
728
|
+
const v = value.replace(/["']/g, "").replace(/\\(.)/g, "$1");
|
|
729
729
|
if (v.includes("$(") || v.includes("`"))
|
|
730
730
|
return true;
|
|
731
731
|
if (/\$[@*#!?0-9-]/.test(v))
|
|
@@ -734,11 +734,12 @@ function valueIsOpaque(value, assigned, safeVars) {
|
|
|
734
734
|
return true;
|
|
735
735
|
for (const vm of v.matchAll(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g)) {
|
|
736
736
|
const ref = vm[1];
|
|
737
|
-
if (!assigned.has(ref) && !safeVars.has(ref))
|
|
737
|
+
if (!assigned.has(ref) && !(safeVars.has(ref) && safeRootBelow(v.slice((vm.index ?? 0) + vm[0].length))))
|
|
738
738
|
return true;
|
|
739
739
|
}
|
|
740
740
|
return false;
|
|
741
741
|
}
|
|
742
|
+
const safeRootBelow = (after) => /^\/[^*?[\]]+$/.test(after) && !after.split("/").includes("..");
|
|
742
743
|
export function findUnverifiableRecursiveDelete(command, safeVars = new Set(UNVERIFIABLE_DELETE_SAFE_VARS)) {
|
|
743
744
|
const assigned = new Set();
|
|
744
745
|
const assignedEscapesRoot = new Set();
|
|
@@ -887,9 +888,7 @@ export function findUnverifiableRecursiveDelete(command, safeVars = new Set(UNVE
|
|
|
887
888
|
return `\`rm\` recursive+force target ${raw} resolves through ${name}, whose in-window value climbs above a known-safe root (\`..\`) — the deleted path cannot be verified from this command`;
|
|
888
889
|
}
|
|
889
890
|
if (safeVars.has(name)) {
|
|
890
|
-
|
|
891
|
-
const escapesRoot = after.split("/").includes("..");
|
|
892
|
-
if (/^\/[^*?[\]]+$/.test(after) && !escapesRoot)
|
|
891
|
+
if (safeRootBelow(targetNorm.slice((vm.index ?? 0) + vm[0].length)))
|
|
893
892
|
continue;
|
|
894
893
|
}
|
|
895
894
|
return `\`rm\` recursive+force target ${raw} references $${name}, which has no assignment in this command — an empty or unexpected value could make this a home-directory or filesystem-root wipe`;
|
|
@@ -16,7 +16,7 @@ import { isWorkflowRunActive, closeWorkflowChannel, markWorkflowActive, publishW
|
|
|
16
16
|
import { isDurablePause } from "../agents/suspend-guard.js";
|
|
17
17
|
import { RUNNING_AGENT_OBSERVE_EVERY_BEATS } from "../config/defaults.js";
|
|
18
18
|
import { boundInputHashOf } from "../core/canonical-json.js";
|
|
19
|
-
import { boundedRedactedSummary } from "../core/untrusted-egress.js";
|
|
19
|
+
import { boundedRedactedSummary, redactSecrets } from "../core/untrusted-egress.js";
|
|
20
20
|
import { delimitUntrusted } from "../core/untrusted-text.js";
|
|
21
21
|
import { OUTPUT_TOOL_NAME } from "../core/runner/synthetic-tools.js";
|
|
22
22
|
import { deniedToolNamesOf } from "../core/runner/sub-agent-deny-lists.js";
|
|
@@ -2420,7 +2420,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
2420
2420
|
run.endedAt = now();
|
|
2421
2421
|
if (parkedLeg !== undefined && !thrownIs(err, WorkflowAgentParkedError))
|
|
2422
2422
|
err = parkedError();
|
|
2423
|
-
run.error = thrownText(err);
|
|
2423
|
+
run.error = redactSecrets(thrownText(err));
|
|
2424
2424
|
closeAbandonedAgents(run.endedAt);
|
|
2425
2425
|
{
|
|
2426
2426
|
let thrownCode;
|
|
@@ -146,7 +146,7 @@ export interface PackSectionDeclaration {
|
|
|
146
146
|
/**
|
|
147
147
|
* design/147 epoch declaration axis (ruling a): `"provider"` marks a section whose text came
|
|
148
148
|
* from the typed `stableBlocks` lane (incl. the provider-assembled pass-through role) — the SELECTOR
|
|
149
|
-
* for the epoch artifactDigest's declaration input, so a
|
|
149
|
+
* for the epoch artifactDigest's declaration input, so a web-admin publish (declaration bytes
|
|
150
150
|
* change) re-pins sessions at the next legal boundary. NOT a digest-disclosure gate
|
|
151
151
|
* (that stays {@link declaredContentHash}-only): epoch digests are one-way hashes over the payload.
|
|
152
152
|
*/
|
package/dist/shell/lex.js
CHANGED
|
@@ -541,10 +541,24 @@ class WordBuilder {
|
|
|
541
541
|
return at;
|
|
542
542
|
};
|
|
543
543
|
const unitCloses = new Map([":", "=", "."].map((d) => [d, unitClose(d)]));
|
|
544
|
+
const lettersEnd = new Array(nf + 1).fill(nf);
|
|
545
|
+
for (let f = nf - 1; f >= 0; f--) {
|
|
546
|
+
const lower = text.charCodeAt(f) | 0x20;
|
|
547
|
+
lettersEnd[f] = lower >= 0x61 && lower <= 0x7a && readableAt(f) ? lettersEnd[f + 1] : f;
|
|
548
|
+
}
|
|
549
|
+
const unitEndAt = (f) => {
|
|
550
|
+
if (!(readableAt(f) && text[f] === "[" && readableAt(f + 1)))
|
|
551
|
+
return -1;
|
|
552
|
+
const d = text[f + 1];
|
|
553
|
+
const end = unitCloses.get(d)?.[f + 2] ?? -1;
|
|
554
|
+
if (end < 0)
|
|
555
|
+
return -1;
|
|
556
|
+
return d === ":" ? (end > f + 2 && lettersEnd[f + 2] === end ? end : -1) : end === f + 3 ? end : -1;
|
|
557
|
+
};
|
|
544
558
|
const nextBracketClose = new Array(nf + 2).fill(-1);
|
|
545
559
|
const nextSlash = new Array(nf + 1).fill(nf);
|
|
546
560
|
for (let f = nf - 1; f >= 0; f--) {
|
|
547
|
-
const unitEnd =
|
|
561
|
+
const unitEnd = unitEndAt(f);
|
|
548
562
|
nextBracketClose[f] = readableAt(f) && text[f] === "]" ? f : unitEnd >= 0 ? nextBracketClose[unitEnd + 2] : nextBracketClose[f + 1];
|
|
549
563
|
nextSlash[f] = text[f] === "/" ? f : nextSlash[f + 1];
|
|
550
564
|
}
|
|
@@ -584,7 +598,7 @@ class WordBuilder {
|
|
|
584
598
|
for (let m = k + 1; m < close && !widened; m++)
|
|
585
599
|
widened = this.rawQuoted[m] !== undefined;
|
|
586
600
|
for (let f = j; f < closeAt && !widened; f++)
|
|
587
|
-
widened = readableAt(f) && text[f] === "[" && readableAt(f + 1) && (
|
|
601
|
+
widened = readableAt(f) && text[f] === "[" && readableAt(f + 1) && (text[f + 1] === ":" || text[f + 1] === "=" || text[f + 1] === ".");
|
|
588
602
|
const from = this.foldedOf(k);
|
|
589
603
|
const to = this.foldedOf(close + 1);
|
|
590
604
|
if (!expands && widened)
|
|
@@ -748,6 +762,7 @@ function scan(source, connectorsAreText) {
|
|
|
748
762
|
let acceptable = true;
|
|
749
763
|
let timeGrammar;
|
|
750
764
|
let definitionName = false;
|
|
765
|
+
let coprocWord;
|
|
751
766
|
let afterLeadingRedirect = false;
|
|
752
767
|
let assignmentSeen = false;
|
|
753
768
|
let subscriptsClosed = false;
|
|
@@ -761,7 +776,7 @@ function scan(source, connectorsAreText) {
|
|
|
761
776
|
w.opaque(source.slice(from, to), SUBSTITUTION_PLACEHOLDER, by, from, to, { kind, yields: kind === "command" && by === undefined ? "many" : "one", body });
|
|
762
777
|
current().substitutions.push(body);
|
|
763
778
|
};
|
|
764
|
-
const resetPosition = () => { acceptable = true; commandWord = undefined; afterLeadingRedirect = false; assignmentSeen = false; subscriptsClosed = false; timeGrammar = undefined; definitionName = false; };
|
|
779
|
+
const resetPosition = () => { acceptable = true; commandWord = undefined; afterLeadingRedirect = false; assignmentSeen = false; subscriptsClosed = false; timeGrammar = undefined; definitionName = false; coprocWord = undefined; };
|
|
765
780
|
const endWord = () => {
|
|
766
781
|
if (!w.started)
|
|
767
782
|
return undefined;
|
|
@@ -816,6 +831,11 @@ function scan(source, connectorsAreText) {
|
|
|
816
831
|
}
|
|
817
832
|
if (acceptable && bare && (SHELL_CONTROL_PREFIXES.has(word.folded) || GROUP_WORDS.has(word.folded))) {
|
|
818
833
|
timeGrammar = word.folded === "time" ? "opt" : undefined;
|
|
834
|
+
coprocWord = undefined;
|
|
835
|
+
return undefined;
|
|
836
|
+
}
|
|
837
|
+
if (acceptable && bare && word.folded === "coproc") {
|
|
838
|
+
coprocWord = "head";
|
|
819
839
|
return undefined;
|
|
820
840
|
}
|
|
821
841
|
if (acceptable && bare && timeGrammar === "opt" && word.folded === "-p") {
|
|
@@ -829,10 +849,16 @@ function scan(source, connectorsAreText) {
|
|
|
829
849
|
timeGrammar = undefined;
|
|
830
850
|
if (word.assignment !== undefined && word.assignment.bare && acceptable) {
|
|
831
851
|
assignmentSeen = true;
|
|
852
|
+
coprocWord = undefined;
|
|
832
853
|
return undefined;
|
|
833
854
|
}
|
|
834
|
-
if (acceptable)
|
|
855
|
+
if (acceptable && coprocWord !== "name")
|
|
835
856
|
commandWord = bare ? word.folded : undefined;
|
|
857
|
+
if (coprocWord === "head") {
|
|
858
|
+
coprocWord = "name";
|
|
859
|
+
return undefined;
|
|
860
|
+
}
|
|
861
|
+
coprocWord = undefined;
|
|
836
862
|
acceptable = false;
|
|
837
863
|
return undefined;
|
|
838
864
|
};
|
|
@@ -34,6 +34,13 @@ export { SHELL_CONTROL_PREFIXES };
|
|
|
34
34
|
/** A control word is a keyword only when bash sees it BARE: `'if'` / `"do"` / `\\!` are command names (a program
|
|
35
35
|
* looked up on PATH, whose failure changes nothing), so a quoted or escaped spelling never counts as one. */
|
|
36
36
|
export declare function isBareControlWord(words: readonly LexedWord[], i: number, set: ReadonlySet<string>): boolean;
|
|
37
|
+
/** Does the shell read this word as syntax — a reserved word, a control word, `{`, `coproc` — of `set`? Only a word
|
|
38
|
+
* written BARE where bash reads a command's first word (the lexer's {@link LexedWord.atCommandPosition}): a quoted or
|
|
39
|
+
* escaped spelling is a command name, and a word an expansion produced (a value, a split field) is never syntax —
|
|
40
|
+
* bash parses the line before it expands it. The one reading every syntax reader of a command's words asks. */
|
|
41
|
+
export declare function isShellSyntaxWord(w: LexedWord | undefined, set: ReadonlySet<string>): boolean;
|
|
42
|
+
/** The words that head a loop over a list (`for NAME in …`, `select NAME in …`). */
|
|
43
|
+
export declare const LOOP_LIST_HEADS: ReadonlySet<string>;
|
|
37
44
|
/**
|
|
38
45
|
* A word bash strips as an ASSIGNMENT before it chooses the program: the lexer's assignment fact, written BARE
|
|
39
46
|
* (the name, the brackets and the operator unquoted — a quoted VALUE still assigns, a quoted operator is a
|
|
@@ -16,9 +16,12 @@ export function writtenAssignmentPrefix(token) {
|
|
|
16
16
|
}
|
|
17
17
|
export { SHELL_CONTROL_PREFIXES };
|
|
18
18
|
export function isBareControlWord(words, i, set) {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
return isShellSyntaxWord(words[i], set);
|
|
20
|
+
}
|
|
21
|
+
export function isShellSyntaxWord(w, set) {
|
|
22
|
+
return w !== undefined && w.atCommandPosition && w.raw === w.folded && set.has(w.folded);
|
|
21
23
|
}
|
|
24
|
+
export const LOOP_LIST_HEADS = new Set(["for", "select"]);
|
|
22
25
|
export function isLeadingAssignmentWord(words, i) {
|
|
23
26
|
const w = words[i];
|
|
24
27
|
return w !== undefined && w.assignment !== undefined && w.assignment.bare && w.atCommandPosition;
|
package/dist/tools/fs/index.d.ts
CHANGED
|
@@ -187,8 +187,9 @@ export interface HandsToolkitOptions {
|
|
|
187
187
|
* under a short bound of its own, and a probe of it that fails costs that entry alone. The public shell judgment takes the
|
|
188
188
|
* same list as {@link import("./shell-read-check.js").ShellReadJudgeInput.readDenyHomes}. */
|
|
189
189
|
readDenyHomes?: readonly string[];
|
|
190
|
-
/** Where each home-anchored row's literal head leads beyond the home's spellings
|
|
191
|
-
* ({@link import("./read-deny-home.js").readDenyHomeLinksOf}
|
|
190
|
+
/** Where each home-anchored row's literal head leads beyond the home's spellings, and each rooted row's (`/…`) beyond its own
|
|
191
|
+
* spelling ({@link import("./read-deny-home.js").readDenyHomeLinksOf} / {@link import("./read-deny-home.js").readDenyRootLinksOf},
|
|
192
|
+
* keyed by the row's pattern) — the Runner passes the
|
|
192
193
|
* locations its own judge was compiled with, so the band and the gate judge one placement.
|
|
193
194
|
* Unless a mount carries both, the band resolves the placement itself the way the Runner does (the homes it carries
|
|
194
195
|
* are its home set — the account is not asked and no home is added;
|
package/dist/tools/fs/index.js
CHANGED
|
@@ -14,7 +14,7 @@ export * from "./read-deny.js";
|
|
|
14
14
|
export { readDenyHomesOf } from "./read-deny-home.js";
|
|
15
15
|
import { BASH_READONLY_DEFAULT_ALLOW } from "./bash-readonly-classifier.js";
|
|
16
16
|
export { BASH_CLASSIFY_EXTRA_ALLOW, bashClassifyDefaultAllow } from "./bash-readonly-classifier.js";
|
|
17
|
-
import { checkedReadDenyHomes, compileReadDeny, compileWriteDeny, readDenyBuiltinConfigOf, readDenyPatternRun } from "./read-deny.js";
|
|
17
|
+
import { checkedReadDenyHomes, compileReadDeny, compileWriteDeny, isRootedReadDenyPattern, readDenyBuiltinConfigOf, readDenyPatternRun } from "./read-deny.js";
|
|
18
18
|
import { withGateTimeout } from "../../core/gate-bounded-await.js";
|
|
19
19
|
import { carryHomeViews, homeViewsAt, placeReadDenyAtHome, placementViewsOf, readDenyPlacedLater, sayPlacementLost } from "./read-deny-home.js";
|
|
20
20
|
import { deploymentFaceClampNotice, resolveContainmentFace } from "./containment-face.js";
|
|
@@ -51,7 +51,7 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
|
|
|
51
51
|
const carriedViews = carried ? placementViewsOf(opts.readDenyHomes) : undefined;
|
|
52
52
|
if (carried && carriedViews === undefined)
|
|
53
53
|
sayPlacementLost();
|
|
54
|
-
const placesNothing = !unplaced.entries.some((e) => readDenyPatternRun(e.pattern).homeAnchored);
|
|
54
|
+
const placesNothing = !unplaced.entries.some((e) => readDenyPatternRun(e.pattern).homeAnchored || isRootedReadDenyPattern(e.pattern));
|
|
55
55
|
const late = carried || placesNothing ? undefined : readDenyPlacedLater(carryHomeViews(unplaced, homeViewsAt(env, [])), () => placeReadDenyAtHome(env, compileAt, readDenyHomes));
|
|
56
56
|
const readDeny = late?.matcher ??
|
|
57
57
|
(carried
|