@sema-agent/core 7.46.0 → 7.47.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 +52 -0
- package/dist/core/allow-entry.d.ts +1 -1
- package/dist/core/allow-entry.js +3 -4
- package/dist/core/ask-origin.d.ts +35 -12
- package/dist/core/ask-origin.js +23 -6
- package/dist/core/engine-notice.d.ts +7 -4
- package/dist/core/gate-fold.js +2 -2
- package/dist/core/gate-lanes.js +2 -1
- package/dist/core/gate-outcome.d.ts +4 -2
- package/dist/core/gate-outcome.js +2 -2
- package/dist/core/permission-rule-model.d.ts +12 -6
- package/dist/core/permission-rule-model.js +10 -2
- package/dist/core/permission-rules.d.ts +4 -5
- package/dist/core/permission-rules.js +28 -15
- package/dist/core/runner/gate-exit.d.ts +4 -1
- package/dist/core/runner/gate-exit.js +5 -1
- package/dist/core/runner/prepare-hands-readface.js +5 -5
- package/dist/core/runner/prepare-policy-chain.js +2 -1
- package/dist/core/shell-wrapper-projection.js +1 -1
- package/dist/core/shell-wrapper-table.d.ts +6 -6
- package/dist/core/shell-wrapper-table.js +2 -2
- package/dist/core/tool-catalog-entries.js +3 -2
- package/dist/core/tool-face.d.ts +10 -1
- package/dist/core/tool-name-readings.d.ts +8 -0
- package/dist/core/tool-name-readings.js +2 -0
- package/dist/core/tool-policy.d.ts +10 -9
- package/dist/core/tool-policy.js +2 -2
- package/dist/core/tool-registry.d.ts +9 -0
- package/dist/core/tool-registry.js +10 -0
- package/dist/core/write-protect.d.ts +10 -3
- package/dist/core/write-protect.js +11 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/shell/lex.d.ts +15 -0
- package/dist/shell/lex.js +58 -13
- package/dist/tools/fs/bash-program-position.d.ts +9 -6
- package/dist/tools/fs/bash-program-position.js +13 -10
- package/dist/tools/fs/program-read-model.d.ts +24 -7
- package/dist/tools/fs/program-read-model.js +7 -0
- package/dist/tools/fs/program-read-model.rows.js +18 -7
- package/dist/tools/fs/removal-targets.js +14 -4
- package/dist/tools/fs/shell-read-argv.d.ts +5 -4
- package/dist/tools/fs/shell-read-argv.js +5 -3
- package/dist/tools/fs/shell-read-bindings.d.ts +46 -19
- package/dist/tools/fs/shell-read-bindings.js +38 -29
- package/dist/tools/fs/shell-read-establish.js +3 -3
- package/dist/tools/fs/shell-read-plan.d.ts +2 -1
- package/dist/tools/fs/shell-read-plan.js +32 -36
- package/dist/tools/fs/shell-read-word.d.ts +38 -7
- package/dist/tools/fs/shell-read-word.js +26 -8
- package/dist/tools/fs/shell-read-write-targets.js +4 -3
- package/dist/tools/fs/shell-reentry-text.d.ts +98 -0
- package/dist/tools/fs/shell-reentry-text.js +115 -2
- package/dist/tools/monitor.d.ts +3 -3
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +9 -3
- package/wire-changes/core@7.47.0.json +412 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
+
## 7.47.0 — 2026-10-09
|
|
2
|
+
|
|
3
|
+
**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] ④).
|
|
4
|
+
|
|
5
|
+
**#1402 —** hard-breaking: one (a type) — `COARSE_SHELL_TOOLS` is now the frozen literal `readonly ["Bash", "Monitor"]` (was `string[]` — a literal in v7.46.0; the catalog-derived form existed only on the unreleased stage): code that assigns it to a mutable `string[]` or pushes to it stops compiling, and a push throws at runtime; read it as `readonly string[]` (server does; the catalog's shell face stays the truth, kept one with the literal by the lockstep pin); closed sets: no registered set changes — the new catalog-only member `ToolCatalogEntry.face.ruleFace.grammar` is ONE optional literal (`"command"`), a presence mark rather than a set of words (`docs/CLOSED-SETS.md` registers sets of two or more; a second grammar word makes it one); `PermissionRuleIssue.code` keeps its members (the refusal below is `invalid.command_rule`); exports: none added or removed (`COARSE_SHELL_TOOLS` keeps its name and its members, `["Bash", "Monitor"]`, so a copy a downstream layer holds keeps its meaning; it stays a spelled-out list, frozen, typed `readonly ["Bash", "Monitor"]` where v7.46.x typed it `string[]` — reading it is unchanged, while assigning it to a mutable `string[]` no longer compiles and pushing to it throws; the catalog's shell face is the truth, and a pin keeps the list one with it); `ToolCatalogEntry.face.ruleFace` gains one optional catalog-only member (`grammar: "command"`; `ToolSpec.ruleFace` is unchanged); `Monitor`'s roster row gains `ruleFace.primaryParams: ["command"]`. **Behavior narrowing:** two — `Bash(...)` command rules now judge `Monitor` calls (deny / ask / allow, every source); `Monitor(<command>)` rule text is refused at compile time. **Safety loosening, vetoable (C-R275 form):** one — the allow half of the same reading: a `Bash(...)` allow (an allow rule, an `allowedTools` / `RunnerDeps.allowedTools` entry, a person's stored allow row, a skill grant) now clears a `Monitor` call's ordinary question exactly as it clears `Bash`'s, under `dontAsk` too (the allow answers before the mode refuses); upstream judges a `Monitor` command by the `Bash` rules (`cli291.js:245292`).
|
|
6
|
+
|
|
7
|
+
**#1423 —** hard-breaking: one — the export `RELEASABLE_ASK_CLASSES` is removed (read `releasableAskClassesFor(askFaceOf(toolName))`); closed sets: `MODE_RELEASE_REASONS` +1 (`write_protection_unresolved`), one new (`ASK_FACES`: `shell`, `other`); exports: +4 / −1 (`ASK_FACES`, `AskFace`, `askFaceOf`, `releasableAskClassesFor`; −`RELEASABLE_ASK_CLASSES`); `foldArbitrationRow` gains one optional trailing parameter (`askFace`) and `AskClassFacts` one optional member (`askFace`) — a caller passing neither reads the v7.46.x fold. **Safety loosening, vetoable (C-R275):** under `bypassPermissions` a shell-face call carrying the class `write_protection_unresolved` is released instead of refused by the mode (file tools unchanged); and on the shell face a standing ALLOW over a write target the judge did not resolve becomes that class's question instead of the tighten's refusal — released under `bypassPermissions`, a card under `default` / `auto` / `acceptEdits` / `plan`, refused by the mode under `dontAsk` (file tools unchanged, byte for byte). Both are one loosening and are vetoed together; the reach is stated below, and it is narrow.
|
|
8
|
+
|
|
9
|
+
### `Bash(...)` command rules judge every tool that runs a shell command — `Monitor` included (#1402; behavior narrowing; one loosening open to veto) (@server @cli @client-core @test)
|
|
10
|
+
|
|
11
|
+
- **Before (v7.46.x).** A `Bash(<command>)` / `Bash(<prefix>:*)` rule was compiled under the literal tool name `Bash` and matched only calls named `Bash`. `Monitor` runs its `command` argument as a shell command line through the same execution seam, so under `deny Bash(touch:*)` a `Monitor` `touch k1` asked an `ordinary` question under `default` (and under `default` with `allowedTools: ["Bash"]`) and **ran with no question under `bypassPermissions`**; `ask Bash(git push:*)` did not reach it (an `ordinary` card, released by bypass); `allow Bash(npm test:*)` did not clear its question. The same held for a person's stored rows, the org partition and the allow lists. `Monitor(touch:*)` compiled clean and matched nothing.
|
|
12
|
+
- **Now.** The tool catalog declares a **shell face**: a definition whose `command` argument is a shell command line declares `ruleFace.grammar: "command"` (the two `Bash` definitions and `Monitor`). One reading — `laneToolNamesOf`, the rule words a call is judged under — maps every shell-face name a call answers to (aliases included) to the command word `Bash`, and every lane reads it: the policy compiler (`createPermissionRulePolicy`), a person's stored rows, the org partition and the allow lists (`allowedTools`, `RunnerDeps.allowedTools`, skill grants). So a `Bash(...)` command rule — deny, ask or allow — judges a `Monitor` call by its `command` exactly as it judges a `Bash` call: under `deny Bash(touch:*)` a `Monitor` `touch k1` is refused with no card in every mode (`deniedBy: "policy"`, the refusal naming the rule); `ask Bash(git push:*)` is an `ask_rule` card that bypass does not release; `allow Bash(npm test:*)` clears its ordinary question. `COARSE_SHELL_TOOLS` (the coarse command-name gate's and the removal / transcript guards' default tool list), the shell read station and the shell seat are the same set — the catalog's shell face, which `COARSE_SHELL_TOOLS` spells out for the wire (a pin holds the two one, both ways).
|
|
13
|
+
- **`Monitor(<command>)` is refused** (`invalid.command_rule`, the sentence naming the `Bash(<command>)` spelling to write instead): a command rule is written in the command word. Where the text is a parameter rule on an argument `Monitor` does not declare but `Bash` does (`Monitor(timeout:5)`), the sentence names no `Bash(timeout:5)` — that is a rule on `Bash`'s own argument and does not reach `Monitor` — and says a parameter rule reaches only the tool that declares the parameter, pointing at `Bash(<command>)` for the command. Upstream (2.1.291) judges a `Monitor` command by the `Bash` rules only (`cli291.js:245292` — Monitor's `checkPermissions` runs the shell checker; `:241246` — that checker reads the `Bash` rules) and silently accepts a `Monitor(…)` rule it never reads (`:228564`); here the dead rule is loud. A parameter rule on one of Monitor's own other arguments (`Monitor(description:nightly*)`) still compiles and matches; `Monitor(command:…)` is the primary-field refusal (`invalid.primary_field`).
|
|
14
|
+
- **A caller tool wearing the `Monitor` name** (`aliases: ["Monitor"]`) reaches `Bash(...)` rules — grants included — exactly as a caller tool wearing `Bash` already did (an alias is one name at the gate); the reach of grants to such a wearer itself is #1444.
|
|
15
|
+
- **A bare `Bash` rule stays a tool-name rule**: `deny: ["Bash"]` / `allowedTools: ["Bash"]` reach the tool named `Bash` only, as upstream's whole-tool matcher does (`cli291.js:233892`, Monitor declares no family parent). A deployment that wants no shell at all denies both names (or excludes them).
|
|
16
|
+
- **Who is hurt.** (1) A deployment that relied on `Monitor` running a command its `Bash(...)` deny or ask rules cover — now refused or asked. (2) A rule set holding a `Monitor(<command>)` entry now fails to compile (`config.invalid_permission_rules`; under `onInvalidRule: "skip"` the entry is dropped and reported) instead of compiling into a rule that matched nothing; `Monitor(command:…)` likewise (`invalid.primary_field`, opt in with `primaryFieldGeneric: "allow-lexical"`). (3) A host reading `Monitor`'s roster `ruleFace.primaryParams` sees `["command"]` instead of `[]`. (4) **Safety loosening, vetoable (C-R275 form):** a deployment whose `Bash(...)` allow — an allow rule, an allow-list entry, a stored allow row or a skill grant — matches a command a `Monitor` call runs: that call's ordinary question is now cleared, under `dontAsk` too (`allowedTools: ["Bash(tail:*)"]` runs Monitor `tail -f app.log` with no card under `default` and under `dontAsk`, where v7.46.x asked under `default` and refused under `dontAsk`, `deniedBy: "mode"`). A question an allow never clears (a dangerous removal, an ask rule, a read-boundary deny) is untouched, as for `Bash`. Reverting is reading the allow lane under the call's own name for `Monitor`; the deny and ask halves stand.
|
|
17
|
+
- **Compensation.** Write command rules as `Bash(<command>)`: they reach `Monitor` too. A host that kept its own copy of the shell tool list reads `COARSE_SHELL_TOOLS` (or the catalog rows declaring `ruleFace.grammar: "command"`) instead.
|
|
18
|
+
- **Consumers.** server — a governance layer that mirrors `COARSE_SHELL_TOOLS` keeps reading it (same members; server reads it as `readonly string[]` and into a `Set`, `src/runtime-governance.ts:112`, `src/router/route-orchestration.ts:172`, which the narrowed type fits); a settings layer that writes `Monitor(...)` rules gets the compile refusal; cli — a `Monitor` call under a `Bash(...)` deny now shows the policy refusal instead of an approval card, and under bypass it is refused rather than run; test — PA-11 (deny on the equivalent execution face, bypass included) flips at this version.
|
|
19
|
+
- **Known limits.** KL-1402-a — three clearing arms are still keyed on the `Bash` tool name: the read-only shell lane (a command the read station leaves an ordinary question — `tail -f app.log`, `git status` — runs on `Bash` and asks on `Monitor`; `ls`, `cat`, `grep`, `tail -n` run with no card on both), the `acceptEdits` file commands and the card's standing-rule offer; every one errs toward asking; #1443 carries the shell face to those three.
|
|
20
|
+
- Pins: `test/backlog1402-monitor-shell-rules.test.ts`; `test/backlog7470-fix.test.ts` ⑥ (the parameter-shaped refusal sentence; 2 cells, 1 red on `648d56b58`, the control green).
|
|
21
|
+
|
|
22
|
+
**#1421 (with #1445 merged) —** hard-breaking: none; closed sets: 0; exports: 0 added / 0 removed; `ReadCandidate` (exported type) gains one optional member (`pattern`: the spelling a pattern's names are matched by where it is not `spelled` — below); `SHELL_WRAPPER_TABLE` (exported constant): the `su` and `runuser` rows gain their option sets (`valueFlags` / `flags` / `commandString`, the projection of their new grammar rows — the peel still stops at both, `viaShell: true`). **Behavior narrowing:** two — the second, for every command and not only a launcher's, a character quoted or escaped inside a bracket class widens that class for the path reader (below) — with four loosenings open to veto (below).
|
|
23
|
+
|
|
24
|
+
### A launcher that runs a program named by no word of its own hands it an argv, read as that program reads its own (#1421, #1445 merged; behavior narrowing) (@test @cli @server)
|
|
25
|
+
|
|
26
|
+
- **Before (v7.46.x).**
|
|
27
|
+
- **`sudo -s` / `-i` / `--shell` / `--login` with a command** stepped over the switch and read the command as the program sudo runs: `sudo -s rm -rf '$HOME'` read a literal operand `$HOME` (a file in the root), and the only card was the text reader's `unverifiable_delete` ("references $HOME, which has no assignment in this command") — under every mode, but not naming the home; `sudo -s rm -r '$HOME'` (no `-f`) and `sudo -s rmdir '$HOME'` raised no card under a `Bash` allow or `bypassPermissions` — while sudo hands the shell `rm -r $HOME` and the shell removes the home.
|
|
28
|
+
- **`su` / `runuser`** handed every operand to the shell as text with the option words dropped: the user name was the text's first word (`su root -c 'rm -rf ~'` planned `root rm -rf ~`, `su root <<'EOF'` planned `root`), so neither raised `dangerous_delete` (a `probe` question, released by `bypassPermissions`); `runuser -u USER -- CMD` was read as a shell too; `su -s /bin/rm root -- -rf ~` was `dangerous_delete` only because the joined text happened to start with `/bin/rm`.
|
|
29
|
+
- **Now.** One rule: a launcher that runs a program named by no word of its own hands it an argv its row composes, and the argv is read as that program reads its own — a shell's `-c` text a nested plan, its first operand a script file, nothing at all its standard input (#1417); any other program its own row.
|
|
30
|
+
- **`sudo` (`shellCommand: "escaped"`).** With words after its options, a shell switch hands the shell `-c` and the words joined by a space, every character but a letter, a digit, `_`, `-` and `$` escaped with a backslash (sudo(8) `-s` / `-i`; sudo `parse_args.c`, sudo-rs `escaped`) — `$NAME` stays an expansion, every other character is literal. `sudo -s rm -rf '$HOME'` is `dangerous_delete` (the home); `sudo -s 'rm -rf $HOME'` is one word to the shell (a command named `rm -rf <home>`), so no removal; a bare `sudo -s` runs its standard input as before. The rule is about the FIELDS the command line's shell hands sudo, whatever spelled them: a word's literal value, or each value the shell established for a word it rewrites first — `X='#'; sudo -s rm -r "$X" DIR` hands `rm -r \# DIR` (an operand, so DIR is still the removal), a value carrying `;` or a space stays one argument (`X='; rm -rf /'; sudo -s echo "$X"` is one `echo`), `$` in a value stays an expansion (`X='$HOME'; sudo -s rm -rf "$X"` removes the home), a pattern word keeps its pattern — the value's characters escaped, the unquoted pattern characters bare, as written (`X=<root>; sudo -s rm -rf "$X"/*` hands `rm -rf \/<root>\/*`, a `*` inside the quoted value escaped; `$X/[^\.]*` keeps `[^\.]`), a pattern a `for` list binds included (`for X in '#'*; do sudo -s rm -rf "$X" DIR; done` hands `rm -rf \#* DIR`, so DIR is still the removal) — and words with several established values are read in every combination of them, the same word spelled twice taking one value per reading (past 64 combinations, reading *i* hands each word its *i*-th value — every value is still read, not every combination (KL-1421-e, #1460: `sudo -s "$P" -rf "$X"` with nine values each has no `rm -rf <root>` reading where `rm` and `<root>` sit at different positions — a `probe` question, released by `bypassPermissions`), and the readings stay as many as one word's values however long the command). A word whose value the reader does not know is handed as written, so the launched shell re-reads the expansion with the command line's quoting and each word keeps its own argument (`sudo -s rm -r "#$Y" DIR`). `doas -s CMD` is refused by doas (OpenBSD / OpenDoas) and is read as before.
|
|
31
|
+
- **A glob piece is read by the pattern the lexer hands it; a quoted character widens its class; a value's leading `~` is the literal tilde it is.** Before (v7.46.x), a value word's glob piece was cut out of the word's spelling by the written width of the pieces before it, so the cut drifted wherever the spelling and the written text differ: a word that opens with a quote read its pattern one character early (`X=/w; rm -rf "$X"/*` read `/w//`, the workspace itself), a bracket holding a quote or a backslash was read with them (`X=<home>; rm -rf $X/[^\.]*` read `<home>/[^\.]*`, a pattern the matcher does not expand), and a value that starts with an escaped or quoted `~` read the home (`X=/w; rm -rf \~$X/*` read `~/w/*`, `X='~/a'; rm -rf "$X"` read `~/a`). Now the lexer hands each glob piece its pattern three times — as the program receives it where it matches nothing (quotes removed: `[^.]`), as written (`[^\.]`, what a shell re-reading it matches by), and as a path reader judges it — and every value, a `for` list's pattern included, is read by one walk over the pieces: `"$X"/*` reads `/w/*` (same `dangerous_delete` class; the card names `<root>/*` where it named `<root>`), `$X/[^\.]*` reads `<home>/[^.]*`, and a value that starts with `~`, read directly as a path, is spelled `./~…` as a literal word is (`rm -rf \~/w/*` already read `./~/w/*`), the home spelling beside it for the deny judge; a shell that re-parses a word (`eval`, `sh -c`, a launcher's line) is handed the program's text (`X='~'; eval rm -rf "$X"` re-parses `rm -rf ~`). **Narrowing:** a character quoted or escaped inside a bracket class is a literal member, which the quote-removed text may read as a negation, a range, the end of the class or a `[:…:]` / `[=…=]` unit's opener (`["!"a]`, `[a"-"z]`, `[a\]b]`, `["["=a=]]`): the read candidate's names are matched by its `pattern`, every class holding such a character — or a `[:…:]` / `[=…=]` / `[.….]` unit, which the matcher cannot model — read as any one character (`?`, as the matcher reads a POSIX class) — so every name the shell may match is judged, except where a part is cut out of a word (an attached option value, `dd if=`'s value, an assignment's value): the part is matched by its own quote-removed text while bash matches the whole word, a live miss (KL-7470-c, #1466): with a plain `--file=apple` and a link `apple` to a denied file in the working directory, `grep --file=["!"a]* x` opens `apple` and runs on every arm, as on v7.46.x (`grep -f["!"a]* x` likewise); a well-formed class is read as bash forms it — it ends where bash ends it (a `[:…:]` / `[=…=]` / `[.….]` unit skipped whole, a quoted `]` closing nothing: `rm -rf ~/[[:alpha:]"]"]*` was closed at `[:alpha:]`'s `]`, a `probe` question released by `bypassPermissions`, and is now `dangerous_delete`), and a `/` inside the brackets forms no class (`["/"a]*` is the directory `[` and the pattern `a]*` in it: with `<root>/[/a]key` a link to a denied file, `cat <root>/["/"a]*` is refused as on v7.46.x, and its value form `X=<root>; cat $X/["/"a]*` now too, where v7.46.x asked an `ordinary` question under `default` and ran under an allow or `bypassPermissions`); a malformed unit is not (`[[:alpha]]`, `[[:a]x/y:]`, `[a[=b]c=]`: the judge reads a literal `[` where bash reads a member, so a link bash opens — `a]`, `ax/y:]`, `ac=]` — runs on every arm, as on v7.46.x; KL-7470-d, #1466); where the pattern matches nothing the program opens the name the word spells with quotes removed, the candidate's `spelled` (`cat <root>/["!"a]` opens `<root>/[!a]`), and that name is judged, for a written word and a value alike — every class the matcher widens — a quoted character or a `[:…:]` / `[=…=]` / `[.….]` unit alike — carries a `pattern`, whose matches prove none of the shell's, so the name it spells is judged beside whatever it matches (`cat <root>/[[:alpha:]]` with only `<root>/1` beside a link `<root>/[[:alpha:]]` to a denied file: v7.46.x matched `1` by the matcher's own widening and ran on every arm, the value form likewise under an allow and `bypassPermissions`; both are now refused) (`cat <root>/["a"]` with a plain `<root>/b` and `<root>/[a]` a link to a denied file: refused on every arm, as on v7.46.x; the value form `X=<root>; cat $X/["a"]` is now refused too, where v7.46.x asked an `ordinary` question under `default` and ran under an allow or `bypassPermissions`). v7.46.x read `cat <root>/["!"a]*` by `[!a]*` (not `a`): a link `<root>/apple` to a denied file ran with no question on every arm; it is now refused by the deny table on every arm, the value word `X=<root>; cat $X/["!"a]*` likewise (v7.46.x refused it: its quotes were read as members), and `[a"-"z]*` over a link `-key` the same (v7.46.x: the written word ran, the value word an `ordinary` question under `default` and ran under an allow or `bypassPermissions`). Where nothing matches and `<root>/[!a]` is the link, `X=<root>; cat $X/["!"a]` is now refused on every arm (v7.46.x read the value's no-match name with its quotes: an `ordinary` question under `default`, run under an allow or `bypassPermissions`); the written `cat <root>/["!"a]` is refused as on v7.46.x. A quoted or escaped `[` / `:` opens no unit: `["["=a=]]` is the class {`[`, `=`, `a`} and a literal `]`, matching `a]` — with `<root>/a]` the link, `cat <root>/["["=a=]]`, `cat <root>/[[":"alpha:]]` and `cat <root>/[\[=a=]]` ran on every arm on v7.46.x and are now refused; their value forms were refused on v7.46.x (`[\[=a=]]`: a `probe` question, released by `bypassPermissions`) and are refused now. **Who is hurt (the superset):** a name the class cannot match is judged too — under `bypassPermissions` `cat <root>/["a"]*` with a link `<root>/bkey` to a denied file is refused (v7.46.x ran it; bash opens no `bkey`), and `rm -rf <home>/["a"]*` raises a `dangerous_delete` card naming the workspace's parent `work`, which bash would not remove (v7.46.x: no card); and where the class does match (`<root>/a` exists) the name it spells is judged as well — `cat <root>/["a"]` beside a link `<root>/[a]` to a denied file is refused, though bash opens `a` (v7.46.x ran it); likewise `cat <root>/[[:alpha:]]` with `<root>/a` present beside a link `<root>/[[:alpha:]]` to a denied file (v7.46.x ran it).
|
|
32
|
+
- **`su` / `runuser` (`shellText: "user"`).** The rows carry util-linux su's options and macOS su's; the first operand past a lone `-` names the user, and the program is handed `-c TEXT` for su's own `-c` / `--command` / `--session-command` and the words after the user — per implementation (util-linux reads its options anywhere, macOS hands everything after the user on). `su root -c 'rm -rf ~'`, `su -l root -c …`, `su root <<'EOF'`, `su - root <<'EOF'` are `dangerous_delete`; `su root ls` hands its shell the script file `ls`. `runuser -u USER [--] CMD` runs CMD (util-linux `execvp`): CMD is the program the line names.
|
|
33
|
+
- **The program (#1445 merged).** su's `-s PROGRAM` / `--shell=PROGRAM` names the program run in the user's shell's place (util-linux su(1)): a shell's row reads its argv as a shell's (`su -s /bin/bash root -c 'rm -rf ~'`), any other program is read as the command line it is (`su -s /bin/rm root -- -rf ~` is `/bin/rm -rf ~`: `dangerous_delete`). A program the text does not spell — `-s "$X"`, or `-m` / `-p` with no `-s` (the caller's `$SHELL`) — may be any program, a shell among them: its command line is read (an unknown program: a question on both faces) and its argv is read as a shell's too (`su -m operator -c 'rm -rf ~'` is `dangerous_delete`).
|
|
34
|
+
- **Option terminators.** sudo's and doas's grammars step `--` as getopt does: `sudo -s -- rm -rf '$HOME'` is the shell's text (`dangerous_delete`), `sudo -- CMD` / `doas -- CMD` read CMD by its own row (they declined before). An option value attached to its option word is read by its value, however the option word is quoted (`su "-s"/bin/rm …`), and a shell-text value attached to `script`'s option word (`script -c'CMD'`, `--command=CMD`) is a `-c` text of its own (it was dropped with the option words).
|
|
35
|
+
- The record: the program position names the launcher and the first word it hands on; the plan reads a launched shell and a named one through one path, and the removal guard's script-built-at-run-time arm reads the launched argv the same way (a value `sudo` escapes is a value, not script text).
|
|
36
|
+
- **Who is hurt.** (1) A run under `bypassPermissions` or a `Bash` allow that removes a protected location through `sudo -s` / `-i CMD`, `su USER -c CMD`, `su USER <<EOF`, `su -s PROGRAM …`, `runuser USER -c CMD` or `runuser -u USER CMD` now stops on a `dangerous_delete` card (no mode or allow clears it); for the `'$HOME'` forms the card was `unverifiable_delete` and is now `dangerous_delete` naming the home. (2) Every `sudo -s` / `-i` with a command is now a shell re-entry, as `bash -c` is: under `default` and under a `Bash` allow the read station asks (`probe`, the read set is not established) where it walked the command's operands before (`sudo -s ls`, `sudo -s cat '$X'` were allowed under a `Bash` allow); the read-only classifier refuses it with the control-syntax sentence. (3) Loosenings open to veto: `runuser -u USER CMD` is judged as CMD (a reader's operands read, not a shell text's words); a quoted word in su's user slot (`su 'rm -r x'`) is a user name and plans nothing (su refuses an unknown user); a value that starts with a quoted or escaped `~`, read directly as a path, is read in the working directory, as the shell hands it, where v7.46.x read it as the home (a shell that re-parses the value — `eval`, `sh -c`, a launcher's line — still reads `~`, the home, as bash does) — so a read that home reading placed outside the roots no longer asks under a `Bash` allow (`X=<root>; rm -rf \~$X/*`: a `probe` question under `default` and under a `Bash` allow, released by `bypassPermissions`; now an `ordinary` question under `default`, no card under the allow, released by `bypassPermissions`); the deny judge still reads the home spelling, and `X='~'; rm -rf "$X"` is still `dangerous_delete` (the home); a bracket's extent follows bash over the text its quotes leave, so `read H[""]ME <<< …; cat "$HOME/key.txt"` — `[""]ME` is `[]ME`, a `]` first and no close, the `[` a literal, `H[]ME` no pattern and no name `read` binds — no longer asks whether `read` rebound `HOME` (v7.46.x asked: the bracket was closed at the written `]`, a fail-closed reading of a wrong extent); `cat "$HOME/key.txt"` is judged at the real home.
|
|
37
|
+
- **Compensation.** None for a removal that was never safe; a command that must run as another user without a question names its targets literally (a re-entry asks under `default` as `bash -c` does).
|
|
38
|
+
- **Consumers.** test — the cells below (dangerous_delete under every arm and face; the read face; the controls); cli — `dangerous_delete` cards appear for these forms under bypass, with the existing wording; server — no wire shape changes, `judgeShellRead` answers differently for these commands.
|
|
39
|
+
- **Known limits.** KL-1421-a (a launched shell's `$HOME` / `~` read as the declared home) · KL-1421-c (`watch` / `script` text is the station's non-option-word reading, not their grammar) · KL-1421-d (a launcher option cluster ending in a value letter — `sudo -iu root CMD` — is refused at the program position while the second lexer peels it; on the base too; #1446). KL-1417-a / KL-1417-b retired. · KL-1421-e (launcher values past 64 combinations fold by position — every value is still read, not every combination; the off-diagonal (program, target) pair is a probe card and released under `bypassPermissions`, as on v7.46.x; #1460) · KL-7470-e (`bash -c` inline text is still a probe card on a guarded face) · KL-7470-f (a `Read(...)` deny rule does not reach a traversal's members — `grep -r` / `git grep` run; the built-in deny tiers do block them; #1470)
|
|
40
|
+
- Pins: `test/backlog7470-fix.test.ts` ①–④ (the fields rule above, ①–④b; 54 cells — lanes three arms × one or two faces, and the plan; 34 red on the stage tree `648d56b58`, where an established value was handed unescaped, an unknown word by its quote-stripped spelling and a name's first value only; the 20 green are ④ (the `$HOME` control) and ④b's pattern lanes and controls, which that tree carded through the unescaped value — those twelve lanes are red on this change's earlier cut `ace36acb2`, where a pattern word was handed as written) and ④c (the one value walk, the widened class, the no-match name and the literal tilde above; 116 cells — 105 lanes and 11 plan / lane-parity / card cells; 88 red on this change's earlier cut `4e201964b`, where a `for` list pattern was handed whole as pattern text, a bracket holding a quote or a backslash drifted and `\~$X/*` read `~/w/X`; 58 red on its cut `8030dc983`, which judged a quoted class member by the quote-removed class and handed a re-parsing shell the path spelling; 40 red on its cut `308801be9`, which placed a pattern's no-match name at the judged spelling; 33 red on its cut `eefe944b9`, which widened only a quoted `!` `^` `-` `]` and closed a class at a POSIX unit's `]`; 22 red on its cut `f6c35411e`, which judged a widened pattern's name only where nothing matched and folded a bracket holding a `/` into one class; 15 red on its cut `351a9aedd`, which gave a POSIX unit's class no pattern and still folded a `/`; 64 red on the stage tree; 70 on v7.46.0; the POSIX real-match cell red on every one of them; the three `[^.]` control lanes green on every tree) and ⑦ (a run of `[` with a `/` after it forms no class: the reading pinned, the lexing time linear in the run — red on this change's cut `8782e634d`, which rescanned the rest of the word for a `/` after every `[`: 7.7 s for a run of a million); `test/backlog1421-launcher-reread.test.ts` (140 cells; 103 red on `57c798d13`, the 37 green are the forms the base already carded — `sudo -i rm -rf ~`, `sudo --shell rm -rf "$HOME"`, `doas -s rm -rf ~`, `su -s /bin/rm …` (by accident) — and the controls and read-face cells the base already refused or asked).
|
|
41
|
+
|
|
42
|
+
### The mode reads the unresolved-write class by the face the question was put on; one releasable-class reader per face replaces the face-less constant (#1423; table and reader aligned to the reference form; safety loosening, vetoable; exports −1 +4; closed set +1) (@client-core @cli @server @test)
|
|
43
|
+
- **Before (v7.46.x).** The arbitration row of `write_protection_unresolved` (the write-protection judge did not answer for the target) was bypass-immune for every call, and `arbitrateByMode` refused a set holding it under `bypassPermissions` whatever tool the call was made under (`deniedBy: "mode"`). The only reader of "which classes can a mode release" was one face-less list, `RELEASABLE_ASK_CLASSES` (ten classes), so a consumer that mirrors the engine's table (client-core's generated copy; a shell's bypass-posture lane) had one answer for a `Write` and for a `Bash` and kept a local exception for the shell face where the reference form releases it (a second reading of one fact).
|
|
44
|
+
- **Now.** One fold, one reader per face. The question's tool face enters the fold once, where the read face already does: `foldArbitrationRow(classes, readFace?, askFace?)`; `classifyAsk` reads `AskClassFacts.askFace`, and every classification the engine makes stamps `askFaceOf(<the call's wire name>)` (the live gate and the inherited-ancestor station). `askFaceOf` is the catalog's shell face (the rows declaring the command grammar — `Bash`, `Monitor`) or `other`. The face is read off the call's WIRE name, not off every name the call answers to: it widens a mode's release, and a release keyed on an engine name reaches the tool of that name, not a tool that merely wears it as an alias (the two new rows `gate.askFace` — `passAskFace`, the one reading the lanes' classification and the write-protection tighten both make — and `inherited.askFace` of the tool-name readings table are `wire`, like the read-only shell lane's). `releasableAskClassesFor(face)` lists the classes a mode can release for a call on that face, derived from the same table and the same per-face reading the fold uses (no second table): the `other` face lists the ten v7.46.x classes, the `shell` face the same ten plus `write_protection_unresolved`. `RELEASABLE_ASK_CLASSES` is removed. `MODE_RELEASE_REASONS` gains `write_protection_unresolved`: a release names the first member of `MODE_RELEASE_REASONS` its class set holds — `rule_unreadable` before `write_protection_unresolved` — so a release of this class alone carries `reason: "write_protection_unresolved"`, and one beside an unreadable rule carries `reason: "rule_unreadable"` (both words in `released.classes`); the I7 outcome screen accepts a release whose class SOME face releases (the record carries no face). The bypass refusal arm now reads the folded row: a set holding the class beside an immune member (an explicit ask rule, a hook) is refused exactly as before; on the shell face a set whose members are all releasable there is released (the class alone, or beside `rule_unreadable`).
|
|
45
|
+
- **On the shell face a standing allow reads as a standing ask.** The write-protection tighten's word for a target the judge could not resolve is the class's own reading on the call's face (`writeProtectionTightenDecision(toolName, verdict, face)` reads `releasableAskClassesFor(face)`): on the file face the refusal (`deniedBy: "write_protection"`, the v7.46.x sentence byte for byte); on the shell face the class's question — `tool "<T>" writes to a target that could not be resolved on this tree, so the write-protection judge cannot tell whether it lands on a protected path (<cause>) — explicit approval required` (a symlink whose real target could not be resolved has its own clause) — which the mode answers as it answers the same record over a standing ask. Before this, on the shell face an allow rule made the call stricter than no rule: a standing ask was released under `bypassPermissions` and a standing allow refused.
|
|
46
|
+
- **Reach, stated plainly.** The engine's own `Bash` and `Monitor` cannot carry this class today: its one setter (`gate-fold.ts:442`) is fed by `writeProtectionCheck`, which answers null for a call whose face declares no write path target (`write-protect.ts:1036-1038`; the two shell definitions declare none); the shell read station records only the matched-table class `write_protection` (its judge is reduced to a hit / no-hit, `write-protection-station.ts:113`); and the `unresolvable` verdict has one minter, the gate's bounded wait on a judge that never answers (`gate-bounded-await.ts:122`). On a real `Runner`, `Bash` and `Monitor` × `touch .claude/settings.json` read the class `write_protection` — a card under `default` (an allow list does not clear it), released under `bypassPermissions` — identical before and after this change. This change is therefore the arbitration table and its reader aligned to the reference form, not a fix for a card the engine's shell face was showing; the live reach is a caller tool whose wire name IS a shell-face name (it replaces the engine's own `Bash` / `Monitor`; a tool that wears the name only as an alias stays on the `other` face) and that declares a write path target, plus every consumer that reads the table. No tri-state shell judge is added (it would be a new question on shell commands).
|
|
47
|
+
- **Who is hurt.** (1) A host or shell that imports `RELEASABLE_ASK_CLASSES` (it is removed): read `releasableAskClassesFor(askFaceOf(req.toolName))`; the `other` list is the old list, member for member. (2) A deployment running a caller tool whose wire name is `Bash` or `Monitor` and which declares a write path target, under `bypassPermissions`, whose write target the judge never answered for: that call is now released (disclosed on `disposition.released`, `reason: "write_protection_unresolved"`) instead of refused by the mode. (3) A consumer that mirrors the closed set `MODE_RELEASE_REASONS` (client-core's generated copy; server's `isModeReleaseReason` reads the package's own predicate and follows) sees one new word. (4) Shell face: a standing allow with an unresolved write judge now asks instead of refusing under `default` / `auto` (and `acceptEdits` / `plan`) — where the card is answered no or nobody can answer it, the refusal reads `deniedBy: "ask_resolution"` instead of `deniedBy: "write_protection"`; `dontAsk` refuses it by mode (`deniedBy: "mode"`, the class named); `bypassPermissions` releases it; file tools unchanged (an allow there is still refused, `deniedBy: "write_protection"`, while an ask stays a card in the modes that ask — `dontAsk` and `bypassPermissions` refuse it by mode, `deniedBy: "mode"`). The reach is (2)'s: a caller tool whose wire name is `Bash` or `Monitor` and which declares a write path target. **Safety loosening, vetoable (C-R275):** the loosening is one class on one face; reverting is removing the class from the shell face's released set and its word from `MODE_RELEASE_REASONS` — the tighten then refuses on every face again, since it reads the same per-face list; the readers, the fold parameter and the closed set keep working.
|
|
48
|
+
- **Compensation.** Where a consumer keeps a local shell-face exception for this class (client-core / cli's bypass-posture lane), delete it and read `releasableAskClassesFor(askFaceOf(toolName))` — a card the engine would release for that call is the card the consumer may answer. Where a consumer needs the old face-less list, the `other` face's list is it.
|
|
49
|
+
- **Consumers.** client-core — the generated engine fact table keeps its five columns per class (`src/generated/engineFactTables.ts:215`, the class's row) and the mirrored release-reason set gains the word; the posture verdict must read the per-face list for the shell face: today its rule 5 answers `must_ask` for the mandate word `write_protection_unresolved` on every face (`src/hitl/automaticAnswerVerdict.ts:20`, `MANDATE_WORD_IMPLIES_CLASS` `:106-109`) before any class row is read, and its in-package fold `foldModeArbitrationRow` (`:116`) takes no face — both need the face before the shell-face row can answer; cli — CC-436's Bash-face exception (`src/sema/engineGateSyncAllow.ts:1278-1280` `isBashFace`, exact name `Bash`, `Monitor` not included; the view at `:1294`) retires into `releasableAskClassesFor(askFaceOf(toolName))` only AFTER client-core's rule 5 reads the face — retiring it first sends the shell-face card back to `must_ask`; server — the removed constant is imported by three tests (`test/core741-requires-user-interaction.test.ts:16`, `test/unattended-permission-mode.test.ts:25`, `test/read-face-e2e.test.ts:46`, the last asserting at `:1795`), and `src/unattended-permission-mode.ts:21/24/50` states its invariant against it (comments): re-point all four at `releasableAskClassesFor("other")`; sdk — four wire tests import it (`packages/sdk/test/server-7115-wire.test.ts:41`, `server-7116-wire.test.ts:34`, `server-7118-wire.test.ts:29`, `server-7119-wire.test.ts:19`; `:261` / `:313` / `:378` assert ten classes — the `other` face's count; the shell face's is eleven) and `packages/sdk/src/types.ts:2413` names it in a doc comment; test — G46 / G47 in `docs/release-criteria/7.47.0.json`.
|
|
50
|
+
- **Known limits.** KL-1423-a — the release record (`ModeRelease`) carries no face, so the outcome screen checks that SOME face releases the class, not that the call's own face does.
|
|
51
|
+
- Pins: `test/backlog7470-fix.test.ts` ⑤ / ⑤b / ⑤c (the standing allow on its face; 17 cells, 8 red on the stage tree `648d56b58` — the shell-face allow under every mode and the shell face's question words; the 9 green are the standing-ask cells and the file face, its refusal byte for byte and its standing ask in every mode — a card where the mode asks, refused by `dontAsk` and `bypassPermissions`); `test/backlog1423-wpu-by-face.test.ts` (24 cells; 10 red on the unchanged stage tree `998bfc44b` — the shell-face release (two tools), the wire-name `Monitor` cell, the per-face readers, the retired constant, the closed-set word and the fold's third parameter; the 14 green are the file-face cells, the default-mode cards, the alias-only wearer, the other-ask controls and the six real-lane cells). The sweep pin `test/backlog1212-mode-release-reasons.test.ts` now folds each class set on every face; `test/name-readings-oracle.test.ts` carries the two new `wire` rows.
|
|
52
|
+
|
|
1
53
|
## 7.46.0 — 2026-10-08
|
|
2
54
|
|
|
3
55
|
**Hard-breaking changes (the whole release):** two — ① `ToolPolicy.pathDenyRules` and the root export `toolPolicyPathDenyRules` are removed; a policy's path rules are `ToolPolicy.pathRules(anchors)` and `toolPolicyPathRules(policies, anchors)` (rows bound at the anchors of the leg the policy is installed on; `PathRuleDenyRow` stays the bound row's type, and `HandsToolkitOptions.pathDenyRules` — the library-direct deny table — is unchanged). ② `adjudicatePersistedPathRules(rules, call, views, anchorsOf, leads)`: the call's target is its views (`TargetViews`: `{ spellings, unreadable? }`, read by `callTargetViewsOf`), a row's anchors come from `anchorsOf(row)` (the person's rows: `persistedRowAnchors(scope, chainTop)`), and `leads` is where row heads lead in the judging leg (an empty map judges rows as spelled). **Who breaks:** a host that reads `policy.pathDenyRules` or calls `toolPolicyPathDenyRules` / `adjudicatePersistedPathRules` directly (three repos' code reads none of them — server only names `pathDenyRules` in comments and passes the member through `wrapToolPolicy`). **How to migrate:** a wrapper built with `wrapToolPolicy` needs nothing (it carries `pathRules`); a direct reader calls `toolPolicyPathRules(policies, { root, home })` with the anchors of the leg it builds a table for and takes the `deny` rows whose `faces` are non-empty; a direct `adjudicatePersistedPathRules` caller passes `{ tighten: { spellings: [target] } }`, `(row) => persistedRowAnchors(row.scope, { root, home })` and `new Map()`.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type RuleToolNameVerdict } from "./tool-name-aliases.js";
|
|
2
|
-
import {
|
|
2
|
+
import type { ToolCallRequest } from "./tool-policy.js";
|
|
3
3
|
import { type AllowRuleMember, type AllowRuleShadow } from "./allow-rule-shadow.js";
|
|
4
4
|
import { type PathRuleBases, type PersistedRule } from "./permission-rule-model.js";
|
|
5
5
|
import { type AskClass, type AssertAllKeysHandled, type PermissionMode, type PersistedRuleMandate } from "./ask-origin.js";
|
package/dist/core/allow-entry.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { heldBy } from "./tool-name-readings.js";
|
|
2
2
|
import { isNamespacedCoveringRuleName, namespacedRuleNameCovers, parsePermissionRule } from "./permission-rules.js";
|
|
3
3
|
import { judgeRuleToolName, splitNamespacedName } from "./tool-name-aliases.js";
|
|
4
|
-
import { catalogToolNames } from "./tool-registry.js";
|
|
5
|
-
import { COARSE_SHELL_TOOLS } from "./tool-policy.js";
|
|
4
|
+
import { catalogToolNames, isShellFaceToolName } from "./tool-registry.js";
|
|
6
5
|
import { ALLOW_RULE_MEMBERS } from "./allow-rule-shadow.js";
|
|
7
6
|
import { COMMAND_RULE_TOOL, READ_RULE_TOOL, findAdmittingRule, laneToolNamesOf, parseRuleText, programRunReachOf, ruleToolGrammarOf } from "./permission-rule-model.js";
|
|
8
7
|
import { pathRuleLaneAnswer } from "./runner/permission-rule-lanes.js";
|
|
@@ -68,7 +67,7 @@ function readAllowEntry(raw, behavior, roster) {
|
|
|
68
67
|
return row({ kind: "namespace", pattern: name });
|
|
69
68
|
return row({ kind: "tool", name });
|
|
70
69
|
}
|
|
71
|
-
return row(
|
|
70
|
+
return row(isShellFaceToolName(name) ? { kind: "shell", name } : { kind: "tool", name });
|
|
72
71
|
}
|
|
73
72
|
if (name === COMMAND_RULE_TOOL) {
|
|
74
73
|
const allowRead = parseRuleText(raw, behavior);
|
|
@@ -147,7 +146,7 @@ export function autoStripped(compiled, call) {
|
|
|
147
146
|
case "shell":
|
|
148
147
|
return true;
|
|
149
148
|
case "tool":
|
|
150
|
-
return call !== undefined && answersTo(call,
|
|
149
|
+
return call !== undefined && answersTo(call, isShellFaceToolName);
|
|
151
150
|
case "command":
|
|
152
151
|
return shellContentStripped(shellRuleContent(compiled.rule));
|
|
153
152
|
case "namespace":
|
|
@@ -488,6 +488,9 @@ export interface ModeArbitrationRow {
|
|
|
488
488
|
* tool's effect lands at a remote peer, outside any containment the leg declares). `hookAskFloor` marks the classes the reference's rule / safety check answers
|
|
489
489
|
* `ask` for — an ask rule, a question the tool needs a person for, a dangerous removal, a protected or unresolvable write
|
|
490
490
|
* target, the session transcript, another's shared artifact.
|
|
491
|
+
* A row is a class's reading on every face but the ones {@link RELEASED_ON_SHELL_FACE} names: `write_protection_unresolved`'s
|
|
492
|
+
* `bypassImmune` is the file face's (and every other face's) reading, and the shell face reads it released — folded where the
|
|
493
|
+
* ask's row is ({@link foldArbitrationRow}), listed per face by {@link releasableAskClassesFor}.
|
|
491
494
|
*/
|
|
492
495
|
export declare const MODE_ARBITRATION: {
|
|
493
496
|
readonly content_question: {
|
|
@@ -668,23 +671,37 @@ export declare const MODE_ARBITRATION: {
|
|
|
668
671
|
};
|
|
669
672
|
/** The fence over the arbitration table: `never` while every class has a row. */
|
|
670
673
|
export type ArbitrationTableCoversEveryClass = AssertAllKeysHandled<Exclude<AskClass, keyof typeof MODE_ARBITRATION>>;
|
|
674
|
+
/**
|
|
675
|
+
* The FACE a question was put on — the second axis the permission mode reads a class under, beside the leg's read face
|
|
676
|
+
* ({@link ContainmentFace}). `shell`: the call's `command` argument is a shell command line the command grammar judges (the
|
|
677
|
+
* tool catalog's shell face — Bash, Monitor); `other`: every other call (the file tools, MCP, A2A, a caller's own tool). The set
|
|
678
|
+
* is closed; {@link askFaceOf} is the one derivation of a face from a tool name.
|
|
679
|
+
*/
|
|
680
|
+
export declare const ASK_FACES: readonly ["shell", "other"];
|
|
681
|
+
export type AskFace = (typeof ASK_FACES)[number];
|
|
682
|
+
/** The face of a call, by its WIRE name (the name the gate resolved the call to — a release widens, so an alias a tool merely
|
|
683
|
+
* wears does not count): the catalog's shell face for exactly the names that declare the command grammar, `other` for every
|
|
684
|
+
* other name. */
|
|
685
|
+
export declare function askFaceOf(toolName: string): AskFace;
|
|
671
686
|
/** The classes whose own mint puts the `requiresRealApproval` bit on the ask — a bit one of them explains is not
|
|
672
687
|
* the bare bit `policy_rra` names. The mark classes (probe / write protection / tool marks / operator forced / the
|
|
673
688
|
* shell floor) mint no bit and explain none — except `requires_user_interaction`, whose declaration the origin stamp
|
|
674
689
|
* turns into the bit on every ask of the tool. */
|
|
675
690
|
export declare const RRA_EXPLAINERS: ReadonlySet<AskClass>;
|
|
676
|
-
/** The classes a permission mode can RELEASE
|
|
677
|
-
*
|
|
678
|
-
|
|
691
|
+
/** The classes a permission mode can RELEASE for a call on `face`, in {@link ASK_CLASSES} order. The same list on every face
|
|
692
|
+
* but one class — {@link RELEASED_ON_SHELL_FACE}. A frozen list: a caller holding it cannot change the next read. A word
|
|
693
|
+
* outside {@link ASK_FACES} throws. */
|
|
694
|
+
export declare function releasableAskClassesFor(face: AskFace): readonly AskClass[];
|
|
679
695
|
/**
|
|
680
696
|
* The closed set of `reason` words a mode release carries (`GateDisposition.released.reason`, the `released` arm of
|
|
681
697
|
* {@link Arbitration}): each is a releasable class the release record names on its own — `rule_unreadable`, a release
|
|
682
|
-
* over a person's rule the call could not be read against
|
|
698
|
+
* over a person's rule the call could not be read against; `write_protection_unresolved`, a release (the shell face only,
|
|
699
|
+
* {@link RELEASED_ON_SHELL_FACE}) over a write target the judge could not resolve. A release carries the first member (array order) its class
|
|
683
700
|
* set holds, and none when it holds no member. {@link arbitrateByMode} and {@link isModeReleaseReason} read one copy
|
|
684
701
|
* of this list taken at load (a caller that writes into the exported array changes neither), so a new reason is one
|
|
685
702
|
* word here.
|
|
686
703
|
*/
|
|
687
|
-
export declare const MODE_RELEASE_REASONS: readonly ["rule_unreadable"];
|
|
704
|
+
export declare const MODE_RELEASE_REASONS: readonly ["rule_unreadable", "write_protection_unresolved"];
|
|
688
705
|
export type ModeReleaseReason = (typeof MODE_RELEASE_REASONS)[number];
|
|
689
706
|
/** Membership test for {@link ModeReleaseReason} — the word crosses the wire on `tool_end.gate.disposition.released.reason`. */
|
|
690
707
|
export declare function isModeReleaseReason(v: unknown): v is ModeReleaseReason;
|
|
@@ -700,6 +717,8 @@ export interface AskClassFacts extends AskOriginFacts {
|
|
|
700
717
|
readonly ancestorClasses?: ReadonlySet<AskClass>;
|
|
701
718
|
/** The read face the leg this ask was raised on resolved ({@link ContainmentFace}); absent ⇒ none resolved (a leg with no hands). */
|
|
702
719
|
readonly readFace?: ContainmentFace;
|
|
720
|
+
/** The tool face the question was put on ({@link askFaceOf} of the call's tool name); absent ⇒ the face-less reading, which is the `other` face's. */
|
|
721
|
+
readonly askFace?: AskFace;
|
|
703
722
|
}
|
|
704
723
|
/** The classification the arbitration reads: the whole set, the disclosed word (the set's first member in
|
|
705
724
|
* {@link ASK_CLASSES} order) and the row folded across the set. */
|
|
@@ -735,10 +754,11 @@ export declare function decisionAskClassesOf(d: Extract<PermissionResult, {
|
|
|
735
754
|
}>): Set<AskClass>;
|
|
736
755
|
/** Fold a class set onto one row: `bypassImmune` and `hookAskFloor` by OR (any immune member keeps the ask; any floored
|
|
737
756
|
* member floors a hook's ask), the three permissive switches by AND (one refusing member withholds the lane). An empty
|
|
738
|
-
* set is `ordinary`.
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
|
|
757
|
+
* set is `ordinary`. The two faces the ask was raised under enter HERE and nowhere else, so every reader of the folded row
|
|
758
|
+
* reads it already folded: `readFace` — `roots` takes the classifier's lane from a set that holds a class the narrowed face
|
|
759
|
+
* withholds from it; `askFace` — `shell` reads a class {@link RELEASED_ON_SHELL_FACE} holds as not bypass-immune. Absent
|
|
760
|
+
* faces read the table's own rows. */
|
|
761
|
+
export declare function foldArbitrationRow(classes: ReadonlySet<AskClass>, readFace?: ContainmentFace, askFace?: AskFace): ModeArbitrationRow;
|
|
742
762
|
/**
|
|
743
763
|
* The ONE class derivation: the decision half ({@link decisionAskClassesOf}) joined with the gate's own facts —
|
|
744
764
|
* the question tool, an ancestor's mark, the org layer's two arms, the persisted lane's unreadable call, the tool
|
|
@@ -814,9 +834,12 @@ export type Arbitration = {
|
|
|
814
834
|
* The ONE mode read point. `dontAsk` refuses every ask — one value for every class — but a hook's that no member of the
|
|
815
835
|
* set floors (`hookAskFloor`: a hook's ask beside a rule's or a safety check's ask is refused, as the reference's full
|
|
816
836
|
* pipeline refuses it; a hook's ask alone is the reference's forced decision, which never reaches that conversion);
|
|
817
|
-
* `bypassPermissions` refuses a write whose protection could not be judged
|
|
818
|
-
* releasing would be looser than the allow the
|
|
819
|
-
*
|
|
837
|
+
* `bypassPermissions` refuses a write whose protection could not be judged where that class stays immune (the file
|
|
838
|
+
* face: a person cannot be asked, and releasing would be looser than the allow the write-protection tighten refuses there —
|
|
839
|
+
* the folded row says it, {@link foldArbitrationRow}), keeps every immune class and releases the rest: the shell face
|
|
840
|
+
* releases that class ({@link RELEASED_ON_SHELL_FACE}), and there the tighten turns an allow into the same question rather
|
|
841
|
+
* than a refusal (`writeProtectionTightenDecision` reads the face), so on neither face is the release looser than an allow;
|
|
842
|
+
* `acceptEdits` answers an ORDINARY ask itself on a call the caller vouches is one of the mode's file commands
|
|
820
843
|
* (`call.fileCommand`, `accepted`); `plan`, `default` and `auto` hand every ask on (plan's own questions were raised by
|
|
821
844
|
* its write gate; the reference implementation refuses nothing at this point under plan — auto's classifier reads the
|
|
822
845
|
* row at its own station). Reads the class SET and the folded row — never the disclosed word.
|
package/dist/core/ask-origin.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isShellFaceToolName } from "./tool-registry.js";
|
|
1
2
|
export const ASK_ORIGINS = Object.freeze(["content_question", "ancestor_marked", "org_unavailable", "org_rule", "rule_store_unavailable", "hook", "ask_rule", "denial_limit_fallback", "shell_gate_tighten", "safety_tighten", "policy"]);
|
|
2
3
|
const ASK_ORIGIN_SET = new Set(ASK_ORIGINS);
|
|
3
4
|
export function isAskOrigin(v) {
|
|
@@ -224,10 +225,26 @@ export const MODE_ARBITRATION = {
|
|
|
224
225
|
unstated: { bypassImmune: false, classifierEligible: true, sandboxAdmission: false, ruleMayClear: true, hookAskFloor: false },
|
|
225
226
|
ordinary: { bypassImmune: false, classifierEligible: true, sandboxAdmission: true, ruleMayClear: true, hookAskFloor: false },
|
|
226
227
|
};
|
|
228
|
+
export const ASK_FACES = Object.freeze(["shell", "other"]);
|
|
229
|
+
export function askFaceOf(toolName) {
|
|
230
|
+
return isShellFaceToolName(toolName) ? "shell" : "other";
|
|
231
|
+
}
|
|
232
|
+
const RELEASED_ON_SHELL_FACE = new Set(["write_protection_unresolved"]);
|
|
233
|
+
function bypassImmuneOn(c, face) {
|
|
234
|
+
return MODE_ARBITRATION[c].bypassImmune && !(face === "shell" && RELEASED_ON_SHELL_FACE.has(c));
|
|
235
|
+
}
|
|
227
236
|
const CLASSIFIER_WITHHELD_ON_ROOTS = new Set(["probe"]);
|
|
228
237
|
export const RRA_EXPLAINERS = new Set(["dangerous_delete", "unverifiable_delete", "transcript_write", "org_rule", "governance_unavailable", "rule_unreadable", "denial_limit_fallback", "classifier_no_ruling", "ancestor", "artifact_shared_read", "requires_user_interaction"]);
|
|
229
|
-
|
|
230
|
-
|
|
238
|
+
const RELEASABLE_BY_FACE = Object.freeze({
|
|
239
|
+
shell: Object.freeze(ASK_CLASSES.filter((c) => !bypassImmuneOn(c, "shell"))),
|
|
240
|
+
other: Object.freeze(ASK_CLASSES.filter((c) => !bypassImmuneOn(c, "other"))),
|
|
241
|
+
});
|
|
242
|
+
export function releasableAskClassesFor(face) {
|
|
243
|
+
if (!ASK_FACES.includes(face))
|
|
244
|
+
throw new TypeError(`releasableAskClassesFor: "${String(face)}" is not an ask face (${ASK_FACES.join(", ")})`);
|
|
245
|
+
return RELEASABLE_BY_FACE[face];
|
|
246
|
+
}
|
|
247
|
+
export const MODE_RELEASE_REASONS = Object.freeze(["rule_unreadable", "write_protection_unresolved"]);
|
|
231
248
|
const MODE_RELEASE_REASON_SET = new Set(MODE_RELEASE_REASONS);
|
|
232
249
|
export function isModeReleaseReason(v) {
|
|
233
250
|
return MODE_RELEASE_REASON_SET.has(v);
|
|
@@ -278,14 +295,14 @@ export function decisionAskClassesOf(d) {
|
|
|
278
295
|
out.add(c);
|
|
279
296
|
return out;
|
|
280
297
|
}
|
|
281
|
-
export function foldArbitrationRow(classes, readFace) {
|
|
298
|
+
export function foldArbitrationRow(classes, readFace, askFace) {
|
|
282
299
|
if (classes.size === 0)
|
|
283
300
|
return MODE_ARBITRATION.ordinary;
|
|
284
301
|
const withheld = readFace === "roots" && [...classes].some((c) => CLASSIFIER_WITHHELD_ON_ROOTS.has(c));
|
|
285
302
|
let bypassImmune = false, classifierEligible = !withheld, sandboxAdmission = true, ruleMayClear = true, hookAskFloor = false;
|
|
286
303
|
for (const c of classes) {
|
|
287
304
|
const row = MODE_ARBITRATION[c];
|
|
288
|
-
bypassImmune ||=
|
|
305
|
+
bypassImmune ||= bypassImmuneOn(c, askFace);
|
|
289
306
|
classifierEligible &&= row.classifierEligible;
|
|
290
307
|
sandboxAdmission &&= row.sandboxAdmission;
|
|
291
308
|
ruleMayClear &&= row.ruleMayClear;
|
|
@@ -340,7 +357,7 @@ export function classifyAsk(d, facts) {
|
|
|
340
357
|
if (classes.size === 0)
|
|
341
358
|
classes.add("ordinary");
|
|
342
359
|
const askClass = ASK_CLASSES.find((c) => classes.has(c));
|
|
343
|
-
return { classes, askClass, row: foldArbitrationRow(classes, facts.readFace) };
|
|
360
|
+
return { classes, askClass, row: foldArbitrationRow(classes, facts.readFace, facts.askFace) };
|
|
344
361
|
}
|
|
345
362
|
export function headlessDenialMessage(toolName, askMessage) {
|
|
346
363
|
return `Permission for this tool use was denied: it requires interactive approval, and permission prompts are not available in this session (nobody can be asked to approve "${toolName}" in this run — headless auto-deny). The action was NOT performed. Do not claim it succeeded, and do not retry it in this session — report the limitation to the user, or suggest an alternative. What was requested: ${askMessage}`;
|
|
@@ -379,7 +396,7 @@ export function arbitrateByMode(mode, c, disclosure, call) {
|
|
|
379
396
|
case "dontAsk":
|
|
380
397
|
return has("hook") && !c.row.hookAskFloor ? { kind: "ask" } : { kind: "denied_by_mode", mode, askClass: c.askClass, classes };
|
|
381
398
|
case "bypassPermissions":
|
|
382
|
-
if (has("write_protection_unresolved"))
|
|
399
|
+
if (has("write_protection_unresolved") && c.row.bypassImmune)
|
|
383
400
|
return { kind: "denied_by_mode", mode, askClass: c.askClass, classes };
|
|
384
401
|
if (c.row.bypassImmune)
|
|
385
402
|
return { kind: "ask" };
|
|
@@ -834,11 +834,14 @@ export interface EngineNotice {
|
|
|
834
834
|
* boot; deletes in it will fail until the operator restores the bit. Audience `"operator"`; `detail: { layers,
|
|
835
835
|
* remedy }` — layer directory names and the one command. No session. Once per boot.
|
|
836
836
|
* - `"server.model_unreachable"` — a model call of this leg ended in error and its last request failed to
|
|
837
|
-
* connect. Audience `"user"`; `detail: { code, targetHost, remedy, sessionId, proxyInUse? }` — `code`
|
|
838
|
-
* `UND_ERR_CONNECT_TIMEOUT` / `ECONNREFUSED` / `ENOTFOUND` / `EAI_AGAIN` / `EHOSTUNREACH` / `ENETUNREACH`;
|
|
837
|
+
* connect. Audience `"user"`; `detail: { code, targetHost, remedy, sessionId, taskId?, proxyInUse? }` — `code`
|
|
838
|
+
* one of `UND_ERR_CONNECT_TIMEOUT` / `ECONNREFUSED` / `ENOTFOUND` / `EAI_AGAIN` / `EHOSTUNREACH` / `ENETUNREACH`;
|
|
839
839
|
* `targetHost` the model endpoint's `host:port`; `remedy` the constant `"check VPN / proxy; restart if the
|
|
840
|
-
* proxy changed since boot"`; `
|
|
841
|
-
*
|
|
840
|
+
* proxy changed since boot"`; `taskId` (server 7.121.0+) the run resource id of the leg this notice belongs to —
|
|
841
|
+
* absent on a streaming leg that has no run store, and the SAME value on a resumed leg and its original (a
|
|
842
|
+
* per-leg dedup folds the two into one line); `proxyInUse` present only when that request went through a proxy,
|
|
843
|
+
* its value the variable's name (`HTTP_PROXY` or `HTTPS_PROXY`), never the proxy address. No `runId`. Once per
|
|
844
|
+
* leg per target.
|
|
842
845
|
*
|
|
843
846
|
* Deliberately NOT a notice family: brain retry/reconnect liveness (a rate limit, a 5xx, a
|
|
844
847
|
* transient network failure being retried). Those are per-attempt liveness frames with their own
|
package/dist/core/gate-fold.js
CHANGED
|
@@ -2,7 +2,7 @@ import { inlineUntrusted } from "./untrusted-text.js";
|
|
|
2
2
|
import { markTightenMessage } from "./safety-axis-vocab.js";
|
|
3
3
|
import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
|
|
4
4
|
import { normalizeReadRootCandidate } from "./read-root-candidate.js";
|
|
5
|
-
import {} from "./runner/gate-exit.js";
|
|
5
|
+
import { passAskFace } from "./runner/gate-exit.js";
|
|
6
6
|
import { traceHookCrash } from "./gate-crash-trace.js";
|
|
7
7
|
import { writeProtectionMandated, writeProtectionTightenDecision, writeProtectionUnresolved } from "./write-protect.js";
|
|
8
8
|
import { createGateDeadline, awaitWriteProtectionJudgment, gateWaitFailureOf, withGateTimeout } from "./gate-bounded-await.js";
|
|
@@ -167,7 +167,7 @@ export async function runGateFold(pass) {
|
|
|
167
167
|
pass.writeProtected = true;
|
|
168
168
|
if (verdict !== undefined && writeProtectionUnresolved(verdict))
|
|
169
169
|
pass.writeProtectionUnresolved = true;
|
|
170
|
-
const decision = verdict === undefined ? undefined : writeProtectionTightenDecision(toolName, verdict);
|
|
170
|
+
const decision = verdict === undefined ? undefined : writeProtectionTightenDecision(toolName, verdict, passAskFace(pass));
|
|
171
171
|
if (decision !== undefined && writeProtectionStanding === "allow") {
|
|
172
172
|
pass.decision = decision;
|
|
173
173
|
if (decision.action === "ask")
|
package/dist/core/gate-lanes.js
CHANGED
|
@@ -17,7 +17,7 @@ import { callFaceSeat } from "./tool-roster.js";
|
|
|
17
17
|
import { heldBy, wireOf } from "./tool-name-readings.js";
|
|
18
18
|
import { asksUnstated, modeAcceptsFileCommand, permissionSeatAt, unstatedAskMessage } from "./mode-policy.js";
|
|
19
19
|
import { ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON } from "./permission-rule-org.js";
|
|
20
|
-
import { exitGate } from "./runner/gate-exit.js";
|
|
20
|
+
import { exitGate, passAskFace } from "./runner/gate-exit.js";
|
|
21
21
|
import { readOrgRuleLane, readPersistedRuleLane } from "./rule-lane-reads.js";
|
|
22
22
|
import { traceHookCrash } from "./gate-crash-trace.js";
|
|
23
23
|
import { createGateDeadline, awaitWriteProtectionJudgment, withGateTimeout } from "./gate-bounded-await.js";
|
|
@@ -52,6 +52,7 @@ export async function runGateLanes(pass) {
|
|
|
52
52
|
...(input.toolMarkSources !== undefined ? { toolMarkSources: input.toolMarkSources } : {}),
|
|
53
53
|
...(input.a2aIsolatePeers === true ? { a2aIsolatePeers: true } : {}),
|
|
54
54
|
...(readFaceNow !== undefined ? { readFace: readFaceNow } : {}),
|
|
55
|
+
askFace: passAskFace(pass),
|
|
55
56
|
});
|
|
56
57
|
const classifyStanding = (unstamped, facts) => (pass.askClassification = classifyAsk(unstamped, facts));
|
|
57
58
|
let orgTightenCount = 0;
|
|
@@ -343,9 +343,11 @@ export type GateDisposition = {
|
|
|
343
343
|
/**
|
|
344
344
|
* A permission MODE released a surviving ask — the allowed disposition's account of the question it did not put
|
|
345
345
|
* to anyone: the mode, the disclosed class word (`askClass`, the first member of the folded set in `ASK_CLASSES`
|
|
346
|
-
* order — one of {@link
|
|
346
|
+
* order — one of {@link releasableAskClassesFor} on the call's face), the whole set, and the origin / mandate words the card would
|
|
347
347
|
* have shown. `reason` is a member of {@link import("./ask-origin.js").MODE_RELEASE_REASONS} (`isModeReleaseReason`):
|
|
348
|
-
* `"rule_unreadable"` marks a release over a rule the call could not be read against
|
|
348
|
+
* `"rule_unreadable"` marks a release over a rule the call could not be read against, `"write_protection_unresolved"` a release (the
|
|
349
|
+
* shell face) over a write target the judge could not resolve; absent, the release names none. The record carries no face, so the
|
|
350
|
+
* screen asks that SOME face releases the class.
|
|
349
351
|
*/
|
|
350
352
|
export interface ModeRelease {
|
|
351
353
|
mode: PermissionMode;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isAskClass, isAskOrigin, isModeReleaseReason, isPermissionMode,
|
|
1
|
+
import { isAskClass, isAskOrigin, isModeReleaseReason, isPermissionMode, ASK_FACES, releasableAskClassesFor } from "./ask-origin.js";
|
|
2
2
|
import { isAutoModeFallbackCause } from "./auto-mode.js";
|
|
3
3
|
export const SETTLEMENT_KINDS = Object.freeze([
|
|
4
4
|
"human_allowed",
|
|
@@ -139,7 +139,7 @@ export function screenGateOutcome(g) {
|
|
|
139
139
|
defects.push("I7: a mode release carries no settlement — nobody answered the ask it released");
|
|
140
140
|
if (r.mode !== "bypassPermissions")
|
|
141
141
|
defects.push(`I7: a mode release names mode "${String(r.mode)}" — only bypassPermissions releases`);
|
|
142
|
-
if (!isAskClass(r.askClass) || !
|
|
142
|
+
if (!isAskClass(r.askClass) || !ASK_FACES.some((face) => releasableAskClassesFor(face).includes(r.askClass)))
|
|
143
143
|
defects.push(`I7: released.askClass "${String(r.askClass)}" is not a releasable class`);
|
|
144
144
|
if (!Array.isArray(r.classes) || r.classes.length === 0 || !r.classes.every((c) => isAskClass(c)))
|
|
145
145
|
defects.push("I7: released.classes must be a non-empty list of ask classes");
|
|
@@ -131,7 +131,9 @@ export declare function readRowBehavior(v: unknown): RuleBehavior | undefined;
|
|
|
131
131
|
* clears it. The org/hook/matchedAskRule immunities stay: those conjuncts live in the gate's
|
|
132
132
|
* persisted-rule lane and do not loosen with this set. */
|
|
133
133
|
export type PersistedRuleTool = string;
|
|
134
|
-
/** The
|
|
134
|
+
/** The command grammar's ONE rule word — the spelling a shell command rule is written in (`Bash(<command>)`, upstream's).
|
|
135
|
+
* A rule word, not a tool list: the calls a command rule reaches are the SHELL FACE's — every catalog definition whose
|
|
136
|
+
* `command` the command grammar judges ({@link laneToolNamesOf}). */
|
|
135
137
|
export declare const COMMAND_RULE_TOOL = "Bash";
|
|
136
138
|
/** The one tool whose path family has an ALLOW form (the `Read(//abs-dir/**)` directory grant). */
|
|
137
139
|
export declare const READ_RULE_TOOL = "Read";
|
|
@@ -167,8 +169,9 @@ export declare const NAME_RULE_FACES: Readonly<Record<string, {
|
|
|
167
169
|
readonly params: readonly string[];
|
|
168
170
|
}>>;
|
|
169
171
|
/**
|
|
170
|
-
* Which tools a rule may name — the model's tool set is DERIVED, never a hand-kept list: the
|
|
171
|
-
* takes the COMMAND grammar
|
|
172
|
+
* Which tools a rule may name — the model's tool set is DERIVED, never a hand-kept list: the command rule word
|
|
173
|
+
* ({@link COMMAND_RULE_TOOL}) takes the COMMAND grammar — the other shell-face tools take none of their own, their command is
|
|
174
|
+
* judged by the `Bash(…)` rules ({@link laneToolNamesOf}); every tool the catalog declares a path target for (Read / Edit / Write /
|
|
172
175
|
* Glob / NotebookEdit / …, `pathTargetOf`) takes the PATH grammar; the tools of {@link NAME_RULE_FACES} take the NAME grammar.
|
|
173
176
|
* A rule on any other tool has no content this model can judge and is refused (`unsupported.tool`). The tool set therefore
|
|
174
177
|
* moves with the catalog's declarations, exactly as the protective fences' covered set does.
|
|
@@ -177,9 +180,12 @@ export declare function ruleToolGrammarOf(toolName: string): "command" | "path"
|
|
|
177
180
|
/** The value a name-grammar tool's call carries in its name argument — the string every NAME rule is read against, the one a
|
|
178
181
|
* `Skill` call is resolved by — or undefined (not a name-grammar tool; no such argument; not a string). */
|
|
179
182
|
export declare function nameRuleSubjectOf(toolName: string, args: unknown): string | undefined;
|
|
180
|
-
/** The
|
|
181
|
-
* name, the other aliases that resolve to it) the
|
|
182
|
-
*
|
|
183
|
+
/** The rule WORDS a call is judged under, once each: for every name the call answers to (the model's spelling, the wire
|
|
184
|
+
* name, the other aliases that resolve to it), the word its rules are written in — a shell-face name's is the command
|
|
185
|
+
* grammar's {@link COMMAND_RULE_TOOL} (a `Bash(<command>)` rule reaches `Monitor` as it reaches `Bash`), a path or name
|
|
186
|
+
* tool's its own name — so a stored `Bash(…)` or `Edit(…)` row reaches a tool that answers to that name under another wire
|
|
187
|
+
* name, as a policy's rule does. Every lane that judges a call by a rule's word reads it here: the policy compiler, the
|
|
188
|
+
* person's stored rows, the org partition, the allow lists. Empty ⇒ no grammar speaks for the call. */
|
|
183
189
|
export declare function laneToolNamesOf(req: Pick<import("./tool-policy.js").ToolCallRequest, "toolName" | "face">): readonly string[];
|
|
184
190
|
/**
|
|
185
191
|
* Match forms. Four members, two of them one predicate:
|
|
@@ -3,7 +3,7 @@ import { parsePermissionRule } from "./permission-rule-syntax.js";
|
|
|
3
3
|
import { compileReadDeny } from "../tools/fs/read-deny.js";
|
|
4
4
|
import { carriesShellRedirection, parseLeadingCommandName, splitShellCompoundSegments } from "../tools/fs/bash-readonly-classifier.js";
|
|
5
5
|
import { inlineUntrusted } from "./untrusted-text.js";
|
|
6
|
-
import { pathTargetOf, protectivePathTargetOf } from "./tool-registry.js";
|
|
6
|
+
import { isShellFaceToolName, pathTargetOf, protectivePathTargetOf } from "./tool-registry.js";
|
|
7
7
|
import { heldValues } from "./tool-name-readings.js";
|
|
8
8
|
import { carriesDotSegment, isAbsoluteForFamily, isIdentityDir, isPathIdentity, isShellRootedSpellingUnmapped, isUncIdentity, pathFamilyOf, pathIdentityOf } from "../tools/fs/safety.js";
|
|
9
9
|
import { bindPathRules, pathRowsReach, rowsWhereTheyLead } from "./path-rule-binding.js";
|
|
@@ -26,6 +26,8 @@ export function readRowBehavior(v) {
|
|
|
26
26
|
return isRuleBehavior(v) ? v : undefined;
|
|
27
27
|
}
|
|
28
28
|
export const COMMAND_RULE_TOOL = "Bash";
|
|
29
|
+
if (!isShellFaceToolName(COMMAND_RULE_TOOL))
|
|
30
|
+
throw new Error(`permission rules: the command rule word ${JSON.stringify(COMMAND_RULE_TOOL)} names no definition on the catalog's shell face`);
|
|
29
31
|
export const READ_RULE_TOOL = "Read";
|
|
30
32
|
export const PATH_RULE_FACES = Object.freeze({
|
|
31
33
|
[READ_RULE_TOOL]: Object.freeze({ deny: Object.freeze(["read", "write"]), ask: Object.freeze(["read"]) }),
|
|
@@ -56,7 +58,13 @@ export function nameRuleSubjectOf(toolName, args) {
|
|
|
56
58
|
return typeof value === "string" ? value : undefined;
|
|
57
59
|
}
|
|
58
60
|
export function laneToolNamesOf(req) {
|
|
59
|
-
|
|
61
|
+
const words = heldValues("policy.persistedGrammar", { get: ruleWordOf }, req);
|
|
62
|
+
return words.length <= 1 ? words : [...new Set(words)];
|
|
63
|
+
}
|
|
64
|
+
function ruleWordOf(name) {
|
|
65
|
+
if (isShellFaceToolName(name))
|
|
66
|
+
return COMMAND_RULE_TOOL;
|
|
67
|
+
return ruleToolGrammarOf(name) !== undefined ? name : undefined;
|
|
60
68
|
}
|
|
61
69
|
function isCommandMatch(match) {
|
|
62
70
|
return match === "exact" || match === "prefix" || match === "wildcard";
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
* (`programRunReachOf`: the whole command line or any program run in a connector chain — upstream's
|
|
23
23
|
* per-subcommand deny, the same instrument the org partition and the personal store's deny/ask rows use;
|
|
24
24
|
* a call the lexer cannot read against a rule is a fail-closed ask, never silence).
|
|
25
|
+
* The rule is written in the command word (`Bash`) and reaches every call on the catalog's SHELL FACE — `Bash`, and
|
|
26
|
+
* `Monitor`, which runs its `command` through the same execution seam — under any name the call answers to; a command
|
|
27
|
+
* spelling on another shell-face name (`Monitor(touch:*)`) is refused (`invalid.command_rule`, naming the `Bash(…)` form):
|
|
28
|
+
* upstream reads no such rule. A bare `Bash` rule is a tool-name rule and reaches the tool of that name only.
|
|
25
29
|
* It sits in the field-level tier of the evaluation order — right after the bare deny (resp. ask) — which
|
|
26
30
|
* is upstream's placement (`bs()` whole-tool deny → `AN(…,"deny")` field-level deny → `ph()` whole-tool
|
|
27
31
|
* ask → `AN(…,"ask")` field-level ask).
|
|
@@ -57,11 +61,6 @@ import type { NamedToolPolicy } from "./tool-policy.js";
|
|
|
57
61
|
import { parsePermissionRule, type ParsedPermissionRule } from "./permission-rule-syntax.js";
|
|
58
62
|
import { type PathRuleBases } from "./permission-rule-model.js";
|
|
59
63
|
export { parsePermissionRule, type ParsedPermissionRule };
|
|
60
|
-
/** [K-PLATFORM-SWEEP] MINOR#11: Bash's REAL non-primary arg names, accepted as generic param rules
|
|
61
|
-
* (`Bash(timeout:...)`). Exported SOLELY so a lockstep test can assert it against the live Bash tool
|
|
62
|
-
* schema — a schema param added without updating this set would compile to silently-never-matching
|
|
63
|
-
* rules (the exact drift class the Opus 复审 m note warned about). */
|
|
64
|
-
export declare const BASH_GENERIC_PARAMS: ReadonlySet<string>;
|
|
65
64
|
export interface PermissionRule {
|
|
66
65
|
/** CC 语法:`"Tool"` 或 `"Tool(param:pattern)"`。`Tool()`/`Tool(*)` 归一为裸规则;括号可 `\(`/`\)` 转义。 */
|
|
67
66
|
rule: string;
|