@sema-agent/core 7.49.0 → 7.49.1
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 +39 -0
- package/dist/core/arg-summary.d.ts +4 -4
- package/dist/core/arg-summary.js +83 -37
- package/dist/core/engine-notice.d.ts +11 -4
- package/dist/core/hooks.d.ts +17 -0
- package/dist/core/runner/run-harness-handlers.js +2 -2
- package/dist/core/runner/tool-turn-repair-seat.d.ts +28 -4
- package/dist/core/runner/tool-turn-repair-seat.js +36 -13
- package/dist/core/shell-wrapper-projection.js +1 -1
- package/dist/tools/fs/bash-program-position.d.ts +2 -1
- package/dist/tools/fs/bash-program-position.js +15 -4
- package/dist/tools/fs/program-read-model.d.ts +7 -2
- package/dist/tools/fs/program-read-model.js +21 -1
- package/dist/tools/fs/program-read-model.rows.js +11 -10
- package/dist/tools/fs/shell-read-check.js +17 -2
- package/dist/tools/fs/shell-read-judge.d.ts +3 -0
- package/dist/tools/fs/shell-read-judge.js +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
+
## 7.49.1 — 2026-10-12
|
|
2
|
+
|
|
3
|
+
**Hard-breaking changes:** none. **Closed sets:** `OptionMode` +1 (`queries`; internal to the program read model, not exported). **Package exports:** +0 / −0 names, no member changes (`SHELL_WRAPPER_TABLE` is byte-equal: a lookup switch is no peel flag; the two types #1583 / #1640 add live in a runner-internal module). **Behavior:** #1583 removes a value that was left in clear next to a credential-shaped name and #1640 makes a line say what already happened — nothing a run could do before is refused; #1643 and #1644 restore two readings 7.49.0 made too strict to the shell's and the reference's own — both are loosenings of 7.49.0 toward 7.48.0 and the reference implementation (CC 2.1.296), each open to veto. **Known limits added** (`docs/KNOWN-LIMITS.md`): KL-1643-a / -b, KL-1644-a / -b / -c (and the rows #1583 / #1640 carry).
|
|
4
|
+
|
|
5
|
+
### A name an earlier cut swallowed no longer frees its value (#1583; fix) (@server @cli @client-core @test)
|
|
6
|
+
|
|
7
|
+
- **Before (7.49.0).** The vendor-prefixed pass runs before the keyword pass, and a prefixed token's body takes `_` and `-` (a GitLab token does by construction): `glpat-<22>_password=<value>` read `[redacted]=<value>` — the cut took the name `password` with the token, and the keyword pass, reading the text the cut left, found no name for the value. 7.48.0 had no `glpat-` row, so the keyword pass saw the name and removed the value (its `ghp_` and `sk-` rows swallowed the name the same way). On a 2 700-case constructed probe (seeded: five prefixes × nine names × five value separators × six wrappers) 7.49.0 left at least 6 characters of the value visible in 416 cases where 7.48.0 did not — all of them `glpat-`; the token itself was cut better (7.48.0 left it visible in 1 080 cases, 7.49.0 in none).
|
|
8
|
+
- **Now.** The pass engine (`runRedactionPasses`, behind `scrubSecrets`, `redactSecrets`, `redactHostLeaks` and the activity summary) reads every pass's hits on the original text as well, and adds every hit the sequential run did not remove whole to what it removes: a value that holds another pass's hit (a bearer value, a URL, a host path — the former "strictly encloses another pass's hit" rule) and an assignment whose name an earlier cut took are one reading, not two. The name of a hit is read on the original text, never on what a cut left. No token row, character class or vendor case changed. The added hit is reported beside the token's (kind `keyword-value`, span in the original text). The union is rendered from the replacements' templates — a label a replacement keeps is the template's group, never characters it happens to share with the match — so a marker is never cut or doubled; the format-character path uses the same disjoint rendering for the view and for every boundary scan (an overlapping scan is no longer dropped, so a token a format character hid from the view is still found; a format character a matched value ran over goes with it instead of being left after the marker) and merges the scans' spans in one pass. On the probe the value axis goes 1 192 (7.48.0) / 1 608 (7.49.0) → 360 and the token axis stays at 0; the 360 left are one shape — a name behind an escaped quote inside a JSON string (`\"password\":\"…\"`) — which 7.48.0 reads the same way and which prefixes that never take a name (`AKIA`, `ASIA`) leak on identically (KL-958-a).
|
|
9
|
+
- **Who is hurt.** Nobody who relied on a value surviving: text that holds a prefixed token glued to a credential name by `_` or `-` and then `=` or `:` loses the value too — over-redaction is this face's cheap direction. A host that counts redaction findings sees one more `keyword-value` finding on those texts.
|
|
10
|
+
- **Consumers.** None needs to act.
|
|
11
|
+
- Pins: `test/backlog1583-prefix-adjacent-value.test.ts` (four prefixes × name separators `_` `-` `.` × value separators `=` `: ` JSON key × bare / nested, on four faces, each row beside its recorded v7.48.0 reading; the engine rule on synthetic passes, with the three shapes the union path made reachable — a kept label read from the template not from shared characters, format-character boundary scans that keep what they found, a linear merge of their spans; the seeded probe against 7.48.0's recorded counts); `test/backlog958-credential-vocabulary.test.ts` D5 unchanged and green.
|
|
12
|
+
|
|
13
|
+
### The dropped-call line says what became of the turn (#1640; fix; +1 optional detail key) (@cli @server @client-core @test)
|
|
14
|
+
|
|
15
|
+
- **Before (7.49.0).** `task.tool_turn_incomplete` ended every dropped-call sentence (`truncated`, `unnamed`) with "the model was asked to re-issue them". On a turn whose only content was the dropped call (a call cut at the output cap, no text, no valid call) the lane fails the turn and the run ends with `terminal.detail.kind` `model_output_invalid` after one request; a provider refusal / content filter ends the run the same way, and a turn that completed with text and nothing to run ends it with the text — in none of them was anything asked.
|
|
16
|
+
- **Now.** The line says what became of the run only where the turn's own message states it, and is silent everywhere else. (1) Where the lane's dropped-calls account is on the failed turn (`modelOutputInvalid`, built once by `modelOutputInvalidOf` and read back by `modelOutputInvalidOfMessage`, the admission the terminal's `detail` is minted from) the run did end: "…so they did not run; the run ended with this turn (model_output_invalid)", `outcome: "terminal"`. (2) Where the turn goes on by the loop's own turn rules — it did not fail, and it carries a tool call (a tool-calling turn guarantees a next request), or stopped to call a tool (the malformed-tool-use retry asks again), or stopped at the output cap (the truncated-output continue asks again) — the sentence is unchanged, "…so they did not run; the model was asked to re-issue them", `outcome: "reissued"`. (3) Any other turn — a provider refusal / content filter, an abort, a turn that completed with text and nothing to run, a torn stream the loop may resume — says only "…so they did not run" and carries no `outcome`: whether a request follows is the loop's decision, not the message's. New optional key `detail.outcome` (`"terminal"` | `"reissued"`): new optional key, readers need no action; present only when `cause` is `truncated` or `unnamed` and the message states it — its absence is a fact, not missing data; absent on `bad_frame` and `missing_result_synthesized`. Once per run per cause stands — the first turn that showed the cause decides. A `reissued` turn is the turn, not the run: a recovery budget already spent, a turn limit or an interrupt that follows it is not read (KL-1598-b; the exact reading, announced after the loop decides, is #1666).
|
|
17
|
+
- **Who is hurt.** A consumer that matches the sentence text for the cases above (a refusal, an abort, a text-only completion and the failed turn no longer say "re-issue"); everything else is read from `detail`, which only gained a key.
|
|
18
|
+
- **Consumers.** cli may show `outcome`; nothing needs to change. The harness's own continuation gate (`lastTurnHadToolCalls`) now reads the same exported predicate (`turnCarriesToolCall`), one spelling.
|
|
19
|
+
- Pins: `test/backlog1640-incomplete-outcome.test.ts` (three lanes × two causes × the dropped call alone / beside a valid call / beside text only / a provider refusal, each against the run's own terminal and request count; a torn stream the loop resumes, said nothing about; the seat on hostile and mixed messages; the one shared predicate); `test/backlog1230-tool-turn-incomplete.test.ts` ③ and `test/backlog1598-truncated-args.test.ts` ③ ⑧ re-pinned for the new key.
|
|
20
|
+
|
|
21
|
+
### A launcher's bare `--` and `command -v` / `-V` are read as the launcher reads them: neither is text the engine could not read (#1643; fix; loosening of 7.49.0 toward 7.48.0; hard-breaking: none; closed sets: `OptionMode` +1; exports: +0 / −0) (@test @cli @server @client-core)
|
|
22
|
+
|
|
23
|
+
- **Before (v7.49.0).** The launcher rows (`command`, `env`, `nice`, `nohup`, `builtin`, `exec`, `stdbuf`, `timeout`, `setsid`, `flock`) carried neither a bare `--` (end of options: the next word is the program) nor `command -v` / `-V` (a lookup: the words after it are names, nothing is run). The program-position walk refused both as "an option no row explains", and 7.49.0's removal guard asks a refused program position as text the engine did not read — `unverifiable_shell_text`, a release row no stored allow and no `bypassPermissions` clears, which `dontAsk` refuses. So `command -v git`, `if command -v node >/dev/null; then …`, `test -x "$(command -v git)"`, `command -- ls`, `env -- ls`, `nice -- ls`, `nohup -- ls` and `timeout -- 5 ls` drew an uncleared question whose card said "`command` runs shell text this guard cannot read (command -v git)" about a command whose text was plain; `command -- rm -rf "$X"` was asked as unreadable text instead of the removal guard's own unverifiable-delete question. 7.48.0 asked these in `default` (an ordinary "the read boundary could not establish what is read") and ran them under a whole-tool `Bash` allow and under bypass.
|
|
24
|
+
- **Now.** One walk, rows carry the fact. A bare `--` is stepped by every launcher row the binary gate witnesses against its real program (`command`, `builtin`, `exec`, `env`, `nice`, `nohup`, `stdbuf`, `timeout`, `setsid`, `flock`; `time`, `sudo`, `doas`, `su`, `runuser`, `watch` already did) and it ENDS the options: the next word is the program (or the launcher's required operand — `timeout -- 5 ls`), whatever it spells, and a word spelled like an option after it is not stepped (`env -- -i ls` stays unread: bash runs a program named `-i`). `command -v` / `-V` (also `-pv`, `-p -v`, behind another launcher: `env command -V x`) is a new option mode, `queries`: the chain names no program, so nothing is read as run — `command -v rm -rf "$X"` looks up three names and removes nothing, and asks no removal question. `command -- rm -rf "$X"` reads as `rm -rf "$X"` (`unverifiable_delete`, as without the launcher). An option no row explains (`command -x ls`, `env --frobnicate ls`, `nice -z ls`) is still unread text. The card says "cannot read" only for a text that was not read: the finding and its sentence are one source.
|
|
25
|
+
- **Who needs it.** Any session whose shell checks for a tool before using it (`command -v`) or ends a launcher's options with `--`: both were uncleared questions under a whole-tool allow and under bypass. **Who is hurt.** Nobody is newly asked; this is looser than 7.49.0 by exactly the two spellings, and each word after `--` is judged as the program it is (a removal behind `command --` is the removal guard's). `default` / `acceptEdits` still ask a lookup the ordinary question (KL-1643-a: the lookup is no read-only program, as in 7.48.0 and the reference).
|
|
26
|
+
- **Divergence from the reference (open to veto).** CC 2.1.296's wrapper strip steps `--` for `command`, `builtin`, `nice`, `nohup`, `time` and `timeout` (`bL` @18513380, `R3e` @18541453) — aligned — but not for `env` (`cvr` @18513144 returns −1 on it) or `stdbuf` (`uvr`), which stay the program there; sema reads them by the real grammar (GNU and BSD `env`, `stdbuf`: getopt ends options at `--`). `exec`, `setsid`, `flock` are not in the reference's wrapper set at all.
|
|
27
|
+
- **Compensation.** None needed. **If unchanged.** A plain presence check or `--` form keeps drawing an uncleared card that says it cannot read text it read.
|
|
28
|
+
- Pins: `test/backlog1643-launcher-options.test.ts` (① lookups × five arms × two read faces, ② `--` per launcher, ③ the word after `--` is the program, `-- -i` stays unread, ④ unexplained options stay unread, ⑤ the card's sentence, ⑥ controls; 7.48.0 / 7.49.0 / this tree columns in the receipt); `test/program-read-model.binary-gate.cells.ts` (a `-- <program>` cell for every row that steps `--`, a `-v <names>` cell for the lookup — run against the real programs); re-pinned where `env --` / `command --` stood for "an option no row explains": `backlog780-program-position-reader`, `backlog891-seat-keyed-declines`, `backlog945-plan`, `backlog769-sed-script-slot`, `backlog1498-retire`, `backlog1505-streamfed-shell`, the two program-read-model equivalence allowed-lists (classes `1643-launcher-end-of-options`, `1643-launcher-lookup`) and the #994 S1 dual-run attribution `launcher_end_of_options`.
|
|
29
|
+
- Consumers: test — the G10 forms (`command -v`, `command --`, `env --`, `nice --`) answer as the table above; cli / server / client-core — wording only (the card no longer says "cannot read" for these), no new field.
|
|
30
|
+
|
|
31
|
+
### An output redirection outside the write roots is the reference's ordinary redirection ask again — `> /dev/stdout`, `2>/dev/stderr`, `> /dev/tty`, `> /dev/fd/N` ask the ordinary question, bypass and a pre-approval release them; `> /dev/null` stays exempt (#1644; fix; loosening of 7.49.0 toward 7.48.0 and the reference; hard-breaking: none; closed sets: +0; exports: +0 / −0) (@test @cli @server @client-core)
|
|
32
|
+
|
|
33
|
+
- **Before (v7.49.0).** #1427 judged every write target a shell command names against the write roots and made an outside finding the write face's MANDATED probe — the same row a read outside the roots has. The reference's redirection check does not: CC 2.1.296 `lvr` (cli296.js @18502854, called @18508831) exempts `/dev/null` alone and asks every other target outside the working directories as a plain permission ask (`> /dev/stdout` too: `sR` @13116941 has no device case), which bypass (`$pn` @18716980) and a whole-tool allow rule (`@18717070`) release and a prefix allow does not (the ask is not `bashAllowRuleOverridable`; `vX`'s caller returns it before the allow rules, @18567915). So 7.49.0 was stricter than the reference on exactly this cell, disclosed with #1427: `echo x > /dev/stdout`, `echo x 2>/dev/stderr`, `> /dev/tty`, `> /dev/fd/N` and `> <outside file>` drew a probe card that nothing but bypass released — a spec pre-approval (`allowedTools: ["Bash"]`) included, which 7.48.0's ordinary question had accepted.
|
|
34
|
+
- **Now.** One cell, read where the reference's check reads it. A command whose every outside-the-roots finding is an OUTPUT REDIRECTION (`>` `>>` `>|` `&>` `N>`, `exec >`, a here-document into a file) reads the redirection cell of the decision table — `ordinary` on the full shell, `refuse` on the read-only face — instead of the table's `mandated`: `default` and `acceptEdits` ask the question (the card still says "writes outside the directories allowed for this session"), `bypassPermissions` and a spec pre-approval release it, `dontAsk` refuses it by the mode; a stored whole-tool `Bash` rule and a prefix allow keep asking it (the rule-silencing principle, unchanged: they did in 7.48.0, and the reference's prefix allow does not release it either). `/dev/null` stays exempt. A program OPERAND written outside the roots (`tee`, `cp`, `mv`, `rm`, `touch`, `sed -i`, a `sed w` file), a redirection beside a read outside the roots, beside such an operand or beside any word the station could not place (`echo x > <outside>; cat "$UNKNOWN"`), and a redirection target the shell rewrites (`> "$OUT"`) keep the mandated probe; the table's row for `outside_roots` is unchanged and still the read face's.
|
|
35
|
+
- **Who needs it.** Any session whose commands print to a standard stream by name or redirect outside the workspace under bypass or a pre-approval. **Who is hurt.** Nobody is asked more than in 7.49.0; a session that relied on the 7.49.0 mandate to stop a redirected write under `allowedTools: ["Bash"]` or bypass no longer gets it (7.48.0 and the reference never did) — the roots still ask under `default` / `acceptEdits`. The 7.49.0 divergence row (stricter than the reference on this cell) is retired.
|
|
36
|
+
- **Compensation.** None needed. A deployment that wants the mandate back for redirections declares a write deny row or runs `default` without a pre-approval. **If unchanged.** A command that only prints to its own output stays unrunnable under a pre-approval.
|
|
37
|
+
- **For the record (not done, awaiting the owner's ruling).** The device spellings the reference's predicate `wwe` (cli296.js @18511591: `/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`, `/dev/tty`, `/dev/fd/<digits>`, `/proc/self/fd/{0,1,2}`) drops from its write list, git-diff operands, read-path list and input-redirection check could be exempt from the containment question too — `> /dev/stdout` would then run unasked in `default` (7.48.0's behavior), one step looser than the reference's redirection check. Not implemented: it departs from the reference and needs an explicit ruling; its cost is that a descriptor spelling is exempt whatever the same command backs it with (`exec 3< <file in a read-only grant>; echo x > /dev/fd/3` writes the file on Linux), and it would also settle KL-1644-a (`/dev/fd/9`).
|
|
38
|
+
- Pins: `test/backlog1644-device-targets.test.ts` (① the real Runner × seven arms × two read faces over outside files and seven device spellings, ② `/dev/null`, ③ what keeps the mandate — operands, a read beside, a rewritten target, ④ the judge's cell and the unchanged table row); re-pinned: `backlog1427-write-targets-roots` (① split into redirections and operands, ⑦ lanes, ⑨).
|
|
39
|
+
|
|
1
40
|
## 7.49.0 — 2026-10-12
|
|
2
41
|
|
|
3
42
|
**Retirement and unification wave — BREAKING.** One reading per fact across the path tables, the tool selector, the removal guard, the checkpoint row, the terminal detail and the decide record; the orchestration families with no reference counterpart leave the package. Every entry below states Before / Now / who is hurt / compensation; this head lists only the hard-breaking items, each open to veto (rulings in parentheses).
|
|
@@ -62,10 +62,10 @@ export interface RedactionPass {
|
|
|
62
62
|
* only the first (or neither) matches. The OUTPUT keeps every format character that sits OUTSIDE a
|
|
63
63
|
* replaced span — a joiner in an emoji sequence, a non-joiner in Persian text, a soft hyphen in a word
|
|
64
64
|
* — and drops only those INSIDE the span the marker replaces (they were part of the secret). A
|
|
65
|
-
* format-free input takes the exact historical path (byte-identical output).
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
65
|
+
* format-free input takes the exact historical path (byte-identical output). The view's replacements and
|
|
66
|
+
* every boundary scan's are taken as disjoint spans of their input (a later pass that matched across an
|
|
67
|
+
* earlier pass's inserted text is rendered from the removals instead), so the spans always map back
|
|
68
|
+
* exactly and no scan is skipped.
|
|
69
69
|
*/
|
|
70
70
|
export declare function runRedactionPasses(input: string, passes: readonly RedactionPass[], report?: RedactionReport): string;
|
|
71
71
|
/**
|
package/dist/core/arg-summary.js
CHANGED
|
@@ -53,7 +53,28 @@ function insertedFor(pass, match, groups) {
|
|
|
53
53
|
? pass.replace.replace(/\$(\d)/g, (_m, d) => groups[Number(d) - 1] ?? "")
|
|
54
54
|
: pass.replace(match, ...groups);
|
|
55
55
|
}
|
|
56
|
-
function removedRegion(match, inserted) {
|
|
56
|
+
function removedRegion(pass, match, groups, inserted) {
|
|
57
|
+
if (typeof pass.replace === "string") {
|
|
58
|
+
const parts = pass.replace.split(/(\$\d)/).filter((p) => p !== "");
|
|
59
|
+
const isRef = (p) => /^\$\d$/.test(p);
|
|
60
|
+
const refText = (p) => groups[Number(p[1]) - 1] ?? "";
|
|
61
|
+
let head = "";
|
|
62
|
+
for (const p of parts) {
|
|
63
|
+
if (!isRef(p))
|
|
64
|
+
break;
|
|
65
|
+
head += refText(p);
|
|
66
|
+
}
|
|
67
|
+
let tail = "";
|
|
68
|
+
for (let i = parts.length - 1; i >= 0 && isRef(parts[i]); i--)
|
|
69
|
+
tail = refText(parts[i]) + tail;
|
|
70
|
+
if (parts.every(isRef))
|
|
71
|
+
return [0, match.length];
|
|
72
|
+
if (!match.startsWith(head))
|
|
73
|
+
head = "";
|
|
74
|
+
if (!match.endsWith(tail) || head.length + tail.length > match.length)
|
|
75
|
+
tail = "";
|
|
76
|
+
return [head.length, match.length - tail.length];
|
|
77
|
+
}
|
|
57
78
|
let head = 0;
|
|
58
79
|
while (head < match.length && head < inserted.length && match[head] === inserted[head])
|
|
59
80
|
head++;
|
|
@@ -69,7 +90,7 @@ function soloHits(input, passes) {
|
|
|
69
90
|
const inserted = insertedFor(pass, m[0], m.slice(1));
|
|
70
91
|
if (inserted === m[0])
|
|
71
92
|
continue;
|
|
72
|
-
const [a, b] = removedRegion(m[0], inserted);
|
|
93
|
+
const [a, b] = removedRegion(pass, m[0], m.slice(1), inserted);
|
|
73
94
|
if (b <= a)
|
|
74
95
|
continue;
|
|
75
96
|
const at = m.index;
|
|
@@ -79,17 +100,30 @@ function soloHits(input, passes) {
|
|
|
79
100
|
});
|
|
80
101
|
return hits;
|
|
81
102
|
}
|
|
82
|
-
function
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
reach = r;
|
|
103
|
+
function unremovedHits(hits, removals) {
|
|
104
|
+
const gone = [];
|
|
105
|
+
for (const r of [...removals].filter((x) => x.end > x.start).sort((x, y) => x.start - y.start || y.end - x.end)) {
|
|
106
|
+
const last = gone[gone.length - 1];
|
|
107
|
+
if (last !== undefined && r.start <= last[1])
|
|
108
|
+
last[1] = Math.max(last[1], r.end);
|
|
109
|
+
else
|
|
110
|
+
gone.push([r.start, r.end]);
|
|
91
111
|
}
|
|
92
|
-
return
|
|
112
|
+
return hits.filter((h) => {
|
|
113
|
+
let lo = 0;
|
|
114
|
+
let hi = gone.length - 1;
|
|
115
|
+
let at = -1;
|
|
116
|
+
while (lo <= hi) {
|
|
117
|
+
const mid = (lo + hi) >> 1;
|
|
118
|
+
if (gone[mid][0] <= h.start) {
|
|
119
|
+
at = mid;
|
|
120
|
+
lo = mid + 1;
|
|
121
|
+
}
|
|
122
|
+
else
|
|
123
|
+
hi = mid - 1;
|
|
124
|
+
}
|
|
125
|
+
return at === -1 || gone[at][1] < h.end;
|
|
126
|
+
});
|
|
93
127
|
}
|
|
94
128
|
function sequentialRemovals(batches) {
|
|
95
129
|
const removals = [];
|
|
@@ -143,7 +177,7 @@ function runPasses(input, passes, findings, toOriginal) {
|
|
|
143
177
|
const family = typeof pass.family === "function" ? pass.family(passInput, offset) : pass.family;
|
|
144
178
|
if (findings !== undefined)
|
|
145
179
|
findings.push({ pass: passIndex, f: finding(pass, [toOriginal(toPassZero(offset)), toOriginal(toPassZero(offset + match.length))], family) });
|
|
146
|
-
const [a, b] = removedRegion(match, inserted);
|
|
180
|
+
const [a, b] = removedRegion(pass, match, groups, inserted);
|
|
147
181
|
edits.push({ at: offset, removedLen: match.length, insertedLen: inserted.length, inserted, family, head: a, tail: match.length - b, pass: passIndex });
|
|
148
182
|
return inserted;
|
|
149
183
|
});
|
|
@@ -151,12 +185,15 @@ function runPasses(input, passes, findings, toOriginal) {
|
|
|
151
185
|
});
|
|
152
186
|
return { out: cur, batches };
|
|
153
187
|
}
|
|
154
|
-
function applyPasses(input, passes, report, toOriginal) {
|
|
188
|
+
function applyPasses(input, passes, report, toOriginal, disjoint = false) {
|
|
155
189
|
const found = report === undefined ? undefined : [];
|
|
156
190
|
const plain = runPasses(input, passes, found, toOriginal);
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
191
|
+
const solo = soloHits(input, passes);
|
|
192
|
+
const sequential = solo.length === 0 ? [] : sequentialRemovals(plain.batches);
|
|
193
|
+
const outer = unremovedHits(solo, sequential);
|
|
194
|
+
const mappable = !disjoint || replacedSpans(plain.batches) !== undefined;
|
|
195
|
+
const whole = outer.length === 0 && mappable ? undefined : renderRemovals(input, [...sequential, ...outer]);
|
|
196
|
+
if (whole === undefined || (whole.out === plain.out && mappable)) {
|
|
160
197
|
if (report !== undefined)
|
|
161
198
|
report.findings.push(...found.map((x) => x.f));
|
|
162
199
|
return plain;
|
|
@@ -233,10 +270,10 @@ export function runRedactionPasses(input, passes, report) {
|
|
|
233
270
|
const idx = offsetToCharIndex.get(pos);
|
|
234
271
|
return idx === undefined ? input.length : viewToOriginal[idx];
|
|
235
272
|
};
|
|
236
|
-
const {
|
|
273
|
+
const { batches } = applyPasses(view, passes, report, toOriginal, true);
|
|
237
274
|
const viewSpans = replacedSpans(batches);
|
|
238
275
|
if (viewSpans === undefined)
|
|
239
|
-
return
|
|
276
|
+
return REDACTED_WHOLE;
|
|
240
277
|
const spans = viewSpans.map((sp) => {
|
|
241
278
|
const startIdx = offsetToCharIndex.get(sp.start) ?? viewChars.length;
|
|
242
279
|
const endIdx = offsetToCharIndex.get(sp.end) ?? viewChars.length;
|
|
@@ -258,11 +295,11 @@ export function runRedactionPasses(input, passes, report) {
|
|
|
258
295
|
if (cutOffsets.length > MAX_FORMAT_SLOT_SCANS)
|
|
259
296
|
return REDACTED_WHOLE;
|
|
260
297
|
const extra = [];
|
|
261
|
-
const rawSpans = replacedSpans(applyPasses(input, passes, undefined, (pos) => pos).batches);
|
|
298
|
+
const rawSpans = replacedSpans(applyPasses(input, passes, undefined, (pos) => pos, true).batches);
|
|
262
299
|
if (rawSpans !== undefined)
|
|
263
300
|
extra.push(...rawSpans);
|
|
264
301
|
for (const cut of cutOffsets) {
|
|
265
|
-
const cutSpans = replacedSpans(applyPasses(view.slice(cut), passes, undefined, (pos) => pos).batches);
|
|
302
|
+
const cutSpans = replacedSpans(applyPasses(view.slice(cut), passes, undefined, (pos) => pos, true).batches);
|
|
266
303
|
if (cutSpans === undefined)
|
|
267
304
|
continue;
|
|
268
305
|
for (const c of cutSpans) {
|
|
@@ -271,27 +308,36 @@ export function runRedactionPasses(input, passes, report) {
|
|
|
271
308
|
}
|
|
272
309
|
}
|
|
273
310
|
extra.sort((a, b) => a.start - b.start);
|
|
311
|
+
const firstEndingAfter = (pos) => {
|
|
312
|
+
let lo = 0;
|
|
313
|
+
let hi = spans.length;
|
|
314
|
+
while (lo < hi) {
|
|
315
|
+
const mid = (lo + hi) >> 1;
|
|
316
|
+
if (spans[mid].end > pos)
|
|
317
|
+
hi = mid;
|
|
318
|
+
else
|
|
319
|
+
lo = mid + 1;
|
|
320
|
+
}
|
|
321
|
+
return lo;
|
|
322
|
+
};
|
|
274
323
|
for (const r of extra) {
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
324
|
+
const at = firstEndingAfter(r.start);
|
|
325
|
+
const hit = spans[at];
|
|
326
|
+
if (hit !== undefined && hit.start < r.end) {
|
|
327
|
+
if (r.start < hit.start)
|
|
328
|
+
hit.start = r.start;
|
|
329
|
+
if (r.end > hit.end)
|
|
330
|
+
hit.end = r.end;
|
|
331
|
+
let next = at + 1;
|
|
332
|
+
while (next < spans.length && spans[next].start < hit.end)
|
|
333
|
+
hit.end = Math.max(hit.end, spans[next++].end);
|
|
334
|
+
if (next > at + 1)
|
|
335
|
+
spans.splice(at + 1, next - at - 1);
|
|
281
336
|
continue;
|
|
282
337
|
}
|
|
283
|
-
spans.
|
|
338
|
+
spans.splice(at, 0, { start: r.start, end: r.end, inserted: r.inserted });
|
|
284
339
|
if (report !== undefined)
|
|
285
340
|
report.findings.push(finding(passes[r.pass], [r.start, r.end], r.family));
|
|
286
|
-
spans.sort((a, b) => a.start - b.start);
|
|
287
|
-
}
|
|
288
|
-
for (let i = 1; i < spans.length;) {
|
|
289
|
-
if (spans[i].start < spans[i - 1].end) {
|
|
290
|
-
spans[i - 1].end = Math.max(spans[i - 1].end, spans[i].end);
|
|
291
|
-
spans.splice(i, 1);
|
|
292
|
-
}
|
|
293
|
-
else
|
|
294
|
-
i++;
|
|
295
341
|
}
|
|
296
342
|
let result = "";
|
|
297
343
|
let cursor = 0;
|
|
@@ -309,13 +309,20 @@ export interface EngineNotice {
|
|
|
309
309
|
* the run and announced on the first. Absent ⇒ every request of the run already had the structure.
|
|
310
310
|
* - `"task.tool_turn_incomplete"` (#1230) — the person's line for a tool turn the model was handed incomplete, beside
|
|
311
311
|
* the note the model itself reads. `detail.cause` (closed `TOOL_TURN_INCOMPLETE_CAUSES`): `truncated` — calls whose
|
|
312
|
-
* arguments were cut before they parsed, dropped (they did not run;
|
|
313
|
-
*
|
|
312
|
+
* arguments were cut before they parsed, dropped (they did not run); `unnamed` — calls that arrived with no tool
|
|
313
|
+
* name, dropped the same way; `bad_frame` — streamed response frames
|
|
314
314
|
* that could not be read, dropped (the response may be missing content); `missing_result_synthesized` — a
|
|
315
315
|
* call of this run with no recorded result, which the request view closed as interrupted, outcome unknown (the
|
|
316
316
|
* `missing_result` repair above; a call that went unanswered before the run began is the transcript's history and
|
|
317
|
-
* is on that operator line only). Audience `"user"`; `detail: { cause, count, toolCallIds?, sessionId, runId }` —
|
|
318
|
-
* `count` the calls (or frames) of that cause in the turn or request that first showed it; `
|
|
317
|
+
* is on that operator line only). Audience `"user"`; `detail: { cause, count, outcome?, toolCallIds?, sessionId, runId }` —
|
|
318
|
+
* `count` the calls (or frames) of that cause in the turn or request that first showed it; `outcome` (#1640) on
|
|
319
|
+
* `truncated` / `unnamed` only, and only where the turn's own message states it: `"terminal"` — the lane failed
|
|
320
|
+
* the run on those calls (its dropped-calls account is on the failed turn; the terminal's `detail.kind` is
|
|
321
|
+
* `model_output_invalid`), the sentence says the run ended; `"reissued"` — the turn goes on by the loop's turn
|
|
322
|
+
* rules (it carries a tool call, or stopped to call a tool, or stopped at the output cap) and the next request
|
|
323
|
+
* asks the model to re-issue them. ABSENT is a fact, not missing data: the message does not say what became of the
|
|
324
|
+
* run (a refusal, an abort, a completed turn with nothing to run, a torn stream the loop may resume), and the
|
|
325
|
+
* sentence then stops at "so they did not run". Once for the cause that first showed it; `toolCallIds` on
|
|
319
326
|
* `missing_result_synthesized` only, each id in the provider echo shape (a stored id outside it is not shown;
|
|
320
327
|
* `count` still counts it). Closed-set words, a count and call ids only: never a tool name, an argument or the
|
|
321
328
|
* note's text. ONCE PER RUN PER CAUSE. Absent ⇒ nothing of that cause reached the model.
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -856,6 +856,23 @@ export interface HookToolOutput {
|
|
|
856
856
|
* formerly APPEARED on the promoted ask object as an undeclared property, because that fold spread the
|
|
857
857
|
* whole hook result; the concurrent arm never carried it, so the two arms disagreed on a member neither
|
|
858
858
|
* of them reads. Nothing in the engine reads it off a decision.
|
|
859
|
+
*
|
|
860
|
+
* PreToolUse lane × settlement table (the gate's phase 1 in {@link runToolGate}; the delegated twin in
|
|
861
|
+
* {@link createPreToolUseConstraintPolicy} speaks the same words as a policy layer). "Held" = the ask survives to a person:
|
|
862
|
+
* neither an allow rule (`decisionReason: "hook"` is outside the allow layer) nor the classifier (hook asks are
|
|
863
|
+
* never classifier-answerable) clears it.
|
|
864
|
+
* · returns `allow` (± `updatedInput` / `additionalContext`) → the face's own word (`hookAllowed`); the policy
|
|
865
|
+
* still adjudicates the FINAL args; the allow layer's hook member may clear an unmandated ask. Not held.
|
|
866
|
+
* · returns `ask` (± `updatedInput`, ± `requiresRealApproval`) → folded as `hookAsk`: a policy deny still
|
|
867
|
+
* outranks it, a policy allow is PROMOTED to this ask with `"hook"` stamped by the engine; the rewrite rides
|
|
868
|
+
* into execution and the real-approval bit crosses delegation. Held.
|
|
869
|
+
* · returns `deny` → {@link hookDenyExit}: the hook's OWN refusal — the deny observer does NOT fire, the model
|
|
870
|
+
* reads the hook's sentence. Nothing executes.
|
|
871
|
+
* · returns `undefined` ("no opinion") or no screening face is installed → no word at all; the policy decides.
|
|
872
|
+
* · throws / times out / is aborted / returns an out-of-contract object (refused by {@link receiveHookResult}) →
|
|
873
|
+
* {@link engineFailClosedExit}: an ENGINE deny — the deny observer fires, a crash and a timeout also reach
|
|
874
|
+
* `onHookError`→`onError(phase:"hook")` (an abort does not), the model reads the engine's envelope. Nothing
|
|
875
|
+
* executes, nothing is held.
|
|
859
876
|
*/
|
|
860
877
|
export type PreToolUseResult = PermissionResult & {
|
|
861
878
|
additionalContext?: string;
|
|
@@ -13,7 +13,7 @@ import { announceToolCall, batchResponseDigest, emitSettled } from "./tool-end-b
|
|
|
13
13
|
import { createResponseModelSeat } from "./response-model-seat.js";
|
|
14
14
|
import { createToolCallIdFoldSeat } from "./tool-call-id-fold-seat.js";
|
|
15
15
|
import { createFinishReasonSeat } from "./finish-reason-seat.js";
|
|
16
|
-
import { createToolTurnIncompleteSeat } from "./tool-turn-repair-seat.js";
|
|
16
|
+
import { createToolTurnIncompleteSeat, turnCarriesToolCall } from "./tool-turn-repair-seat.js";
|
|
17
17
|
import { wireOf } from "../tool-name-readings.js";
|
|
18
18
|
import { rosterToolName } from "../tool-roster.js";
|
|
19
19
|
export function createHarnessHandlers(input) {
|
|
@@ -199,7 +199,7 @@ export function createHarnessHandlers(input) {
|
|
|
199
199
|
const content = m.content;
|
|
200
200
|
if (rs.attach.attachState !== undefined)
|
|
201
201
|
rs.counters.cadenceTurns = advanceCadenceClock(rs.attach.attachState, rs.counters.cadenceTurns, content, writeFamilyOf);
|
|
202
|
-
rs.turn.lastTurnHadToolCalls =
|
|
202
|
+
rs.turn.lastTurnHadToolCalls = turnCarriesToolCall(content);
|
|
203
203
|
}
|
|
204
204
|
rs.turn.callStartAt = undefined;
|
|
205
205
|
rs.turn.firstTokenAt = undefined;
|
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
import type { EngineNotice } from "../engine-notice.js";
|
|
2
|
-
import { type
|
|
2
|
+
import { type ToolTurnRepair } from "../../internal/llm.js";
|
|
3
3
|
/** The notice's attribution ids — the same pair, for the same reason, as the fold seat's. */
|
|
4
4
|
export interface ToolTurnRepairSeatIds {
|
|
5
5
|
sessionId: string;
|
|
6
6
|
runId: string;
|
|
7
7
|
}
|
|
8
|
+
/** What the message says became of a turn that dropped calls: the run ended on it (`model_output_invalid`), or the next
|
|
9
|
+
* request asks the model to re-issue them. */
|
|
10
|
+
export type ToolTurnOutcome = "terminal" | "reissued";
|
|
11
|
+
/** One thing the person is told, per cause. A dropped-call cause carries the outcome when its message states one (else
|
|
12
|
+
* `undefined`: nothing is said about the run's fate); the others have none. */
|
|
13
|
+
export type ToolTurnIncompleteItem = {
|
|
14
|
+
cause: "truncated";
|
|
15
|
+
count: number;
|
|
16
|
+
outcome: ToolTurnOutcome | undefined;
|
|
17
|
+
} | {
|
|
18
|
+
cause: "unnamed";
|
|
19
|
+
count: number;
|
|
20
|
+
outcome: ToolTurnOutcome | undefined;
|
|
21
|
+
} | {
|
|
22
|
+
cause: "bad_frame";
|
|
23
|
+
count: number;
|
|
24
|
+
} | {
|
|
25
|
+
cause: "missing_result_synthesized";
|
|
26
|
+
count: number;
|
|
27
|
+
toolCallIds: readonly string[];
|
|
28
|
+
};
|
|
29
|
+
/** Does a turn's content carry a tool call? A tool-calling turn GUARANTEES a next request (its results go back to the
|
|
30
|
+
* model): the engine's one continuation fact — the harness's boundary gates and the dropped-call line read it here. */
|
|
31
|
+
export declare function turnCarriesToolCall(content: unknown): boolean;
|
|
8
32
|
/** The person's line for one cause — once per run per cause, whichever observer saw it first. */
|
|
9
33
|
export interface ToolTurnIncompleteSeat {
|
|
10
|
-
/** Announce `
|
|
11
|
-
announce(
|
|
12
|
-
/** Observe one assistant message: announces each stream drop it reports (`streamDropsOf`). */
|
|
34
|
+
/** Announce `item` unless this run already said its cause. */
|
|
35
|
+
announce(item: ToolTurnIncompleteItem): void;
|
|
36
|
+
/** Observe one assistant message: announces each stream drop it reports (`streamDropsOf`), worded by what became of the turn. */
|
|
13
37
|
observe(message: {
|
|
14
38
|
readonly diagnostics?: unknown;
|
|
15
39
|
}): void;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { deliverEngineNotice } from "../engine-notice.js";
|
|
2
2
|
import { isProviderEchoShape } from "../../brain/errors.js";
|
|
3
3
|
import { streamDropsOf } from "../../internal/llm.js";
|
|
4
|
+
import { readMember } from "../thrown-value.js";
|
|
5
|
+
import { modelOutputInvalidOfMessage } from "./terminal-account.js";
|
|
4
6
|
const FORM_SENTENCE = {
|
|
5
7
|
trailing_text: "a block after an assistant's tool calls was moved ahead of them",
|
|
6
8
|
late_result: "a result recorded later was moved up behind its call",
|
|
@@ -9,30 +11,51 @@ const FORM_SENTENCE = {
|
|
|
9
11
|
duplicate_call: "a second call with the same id in one assistant message was left out (its results answer the first)",
|
|
10
12
|
orphan_result: "a result that answers no call before it was left out",
|
|
11
13
|
};
|
|
14
|
+
const afterDroppedCalls = (outcome) => outcome === "reissued" ? "; the model was asked to re-issue them" : outcome === "terminal" ? "; the run ended with this turn (model_output_invalid)" : "";
|
|
12
15
|
const INCOMPLETE_SENTENCE = {
|
|
13
|
-
truncated: (
|
|
14
|
-
unnamed: (
|
|
15
|
-
bad_frame: (
|
|
16
|
-
missing_result_synthesized: (
|
|
16
|
+
truncated: (i) => `${i.count} tool call(s) the model made were cut off before their arguments were complete, so they did not run${afterDroppedCalls(i.outcome)}`,
|
|
17
|
+
unnamed: (i) => `${i.count} tool call(s) the model made arrived with no tool name, so they did not run${afterDroppedCalls(i.outcome)}`,
|
|
18
|
+
bad_frame: (i) => `${i.count} frame(s) of the model's streamed response could not be read and were dropped, so the response may be missing content; the model was told`,
|
|
19
|
+
missing_result_synthesized: (i) => `${i.count} earlier tool call(s) have no recorded result, so the model was told their outcome is unknown`,
|
|
17
20
|
};
|
|
21
|
+
export function turnCarriesToolCall(content) {
|
|
22
|
+
try {
|
|
23
|
+
return Array.isArray(content) && content.some((c) => c?.type === "toolCall");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function outcomeOf(message) {
|
|
30
|
+
const stop = readMember(message, "stopReason");
|
|
31
|
+
if (stop === "error")
|
|
32
|
+
return modelOutputInvalidOfMessage(message) !== undefined ? "terminal" : undefined;
|
|
33
|
+
if (stop === "aborted")
|
|
34
|
+
return undefined;
|
|
35
|
+
return turnCarriesToolCall(readMember(message, "content")) || stop === "toolUse" || stop === "length" ? "reissued" : undefined;
|
|
36
|
+
}
|
|
18
37
|
export function createToolTurnIncompleteSeat(onNotice, ids) {
|
|
19
38
|
const announced = new Set();
|
|
20
|
-
const announce = (
|
|
21
|
-
if (announced.has(cause))
|
|
39
|
+
const announce = (item) => {
|
|
40
|
+
if (announced.has(item.cause))
|
|
22
41
|
return;
|
|
23
|
-
announced.add(cause);
|
|
24
|
-
const shown =
|
|
42
|
+
announced.add(item.cause);
|
|
43
|
+
const shown = "toolCallIds" in item ? item.toolCallIds.filter((id) => isProviderEchoShape(id)) : [];
|
|
25
44
|
deliverEngineNotice(onNotice, {
|
|
26
45
|
code: "task.tool_turn_incomplete",
|
|
27
|
-
message: `${INCOMPLETE_SENTENCE[cause](
|
|
28
|
-
detail: { cause, count, ...(shown.length > 0 ? { toolCallIds: shown } : {}), sessionId: ids.sessionId, runId: ids.runId },
|
|
46
|
+
message: `${INCOMPLETE_SENTENCE[item.cause](item)}. Announced once per run for this cause.`,
|
|
47
|
+
detail: { cause: item.cause, count: item.count, ...("outcome" in item && item.outcome !== undefined ? { outcome: item.outcome } : {}), ...(shown.length > 0 ? { toolCallIds: shown } : {}), sessionId: ids.sessionId, runId: ids.runId },
|
|
29
48
|
});
|
|
30
49
|
};
|
|
31
50
|
return {
|
|
32
51
|
announce,
|
|
33
52
|
observe(message) {
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
const drops = streamDropsOf(message);
|
|
54
|
+
if (drops.length === 0)
|
|
55
|
+
return;
|
|
56
|
+
const outcome = outcomeOf(message);
|
|
57
|
+
for (const drop of drops)
|
|
58
|
+
announce(drop.cause === "bad_frame" ? { cause: "bad_frame", count: drop.count } : { cause: drop.cause, count: drop.count, outcome });
|
|
36
59
|
},
|
|
37
60
|
};
|
|
38
61
|
}
|
|
@@ -42,7 +65,7 @@ export function createToolTurnRepairSeat(onNotice, ids) {
|
|
|
42
65
|
return (repairs) => {
|
|
43
66
|
const missing = repairs.filter((repair) => repair.form === "missing_result" && repair.beforeRun !== true).flatMap((repair) => repair.toolCallIds);
|
|
44
67
|
if (missing.length > 0)
|
|
45
|
-
incomplete.announce("missing_result_synthesized", missing.length, missing);
|
|
68
|
+
incomplete.announce({ cause: "missing_result_synthesized", count: missing.length, toolCallIds: missing });
|
|
46
69
|
const fresh = repairs.filter((repair) => {
|
|
47
70
|
const key = `${repair.form}\u0000${[...repair.toolCallIds].sort().join("\u0000")}`;
|
|
48
71
|
if (announced.has(key))
|
|
@@ -38,7 +38,7 @@ function projectRow(row) {
|
|
|
38
38
|
return executes.length > 0 ? { valueFlags: [], flags: [], commandString: [], executes } : undefined;
|
|
39
39
|
}
|
|
40
40
|
const commandString = sorted(all.filter((o) => o.spec.value?.kind === "shell-text").map((o) => o.spelling));
|
|
41
|
-
const values = all.filter((o) => o.spec.value?.kind !== "shell-text");
|
|
41
|
+
const values = all.filter((o) => o.spec.value?.kind !== "shell-text" && o.spec.mode !== "queries");
|
|
42
42
|
const attached = sorted(values.filter((o) => o.arity === "attached" && !o.spelling.startsWith("--")).map((o) => o.spelling));
|
|
43
43
|
const required = row.launcher.requiredOperands ?? 0;
|
|
44
44
|
const duration = required > 0 && row.launcher.requiredOperandSpelling === DURATION_SPELLING;
|
|
@@ -94,7 +94,8 @@ export interface ProgramPosition {
|
|
|
94
94
|
/** The program the stream-fed chain member runs when the chain names none (`xargs` runs `echo`). */
|
|
95
95
|
readonly defaultProgram?: string;
|
|
96
96
|
/** The program bash RUNS: the first word past the chain. `name` is the folded word as written, `basename` what
|
|
97
|
-
* bash resolves (`/usr/bin/cat` is `cat`). `undefined` when the chain names none
|
|
97
|
+
* bash resolves (`/usr/bin/cat` is `cat`). `undefined` when the chain names none (a lookup, `command -v git`, runs
|
|
98
|
+
* nothing) or the read was refused. */
|
|
98
99
|
readonly program: {
|
|
99
100
|
readonly index: number;
|
|
100
101
|
readonly name: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SHELL_CONTROL_PREFIXES } from "../../shell/lex.js";
|
|
2
2
|
import { shellWordsOf } from "../../shell/shell-words.js";
|
|
3
|
-
import { BLOCK_WORD_NAMES, LAUNCHER_NAMES, feedsArgvFromStdin, launcherOptionMovesBase, launcherOptionStep, launcherRunsShell, programRow, selectedOptions, streamDefaultProgram } from "./program-read-model.js";
|
|
3
|
+
import { BLOCK_WORD_NAMES, LAUNCHER_NAMES, feedsArgvFromStdin, launcherOptionMovesBase, launcherOptionQueries, launcherOptionStep, launcherRunsShell, programRow, selectedOptions, streamDefaultProgram } from "./program-read-model.js";
|
|
4
4
|
export const NOT_AUTO_ALLOWED = "— not auto-allowed";
|
|
5
5
|
export function programBasename(word) {
|
|
6
6
|
return word.includes("/") ? (word.split("/").pop() ?? word) : word;
|
|
@@ -38,7 +38,7 @@ function launcherRowOf(name) {
|
|
|
38
38
|
const row = programRow(name);
|
|
39
39
|
return row?.launcher !== undefined ? row : undefined;
|
|
40
40
|
}
|
|
41
|
-
function pastLauncherOptions(launcher, folded, i, moved) {
|
|
41
|
+
function pastLauncherOptions(launcher, folded, i, moved, queried) {
|
|
42
42
|
const row = launcherRowOf(launcher);
|
|
43
43
|
if (row === undefined)
|
|
44
44
|
return i;
|
|
@@ -57,7 +57,11 @@ function pastLauncherOptions(launcher, folded, i, moved) {
|
|
|
57
57
|
return i;
|
|
58
58
|
if (launcherOptionMovesBase(row, tok))
|
|
59
59
|
moved?.();
|
|
60
|
+
if (launcherOptionQueries(row, tok))
|
|
61
|
+
queried?.();
|
|
60
62
|
i += step;
|
|
63
|
+
if (tok === "--")
|
|
64
|
+
return i;
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
export const PROGRAM_POSITION_REFUSAL_KINDS = Object.freeze(["option_at_program_position", "control_syntax_at_program_position"]);
|
|
@@ -103,12 +107,17 @@ export function readProgramPosition(words) {
|
|
|
103
107
|
let reentryDefault = argvFromStdin ? streamDefaultProgram(basename) : undefined;
|
|
104
108
|
let launcherMovesBase = false;
|
|
105
109
|
const moved = () => { launcherMovesBase = true; };
|
|
110
|
+
let looksUp = false;
|
|
111
|
+
const queried = () => { looksUp = true; };
|
|
106
112
|
const chain = (program, refusal) => {
|
|
107
113
|
const runsDefaultProgram = lastReentry === folded.length - 1;
|
|
108
114
|
return { controlWords, written, assignments, launchers, argvFromStdin, runsDefaultProgram, ...(runsDefaultProgram && reentryDefault !== undefined ? { defaultProgram: reentryDefault } : {}), program, refusal, launcherMovesBase };
|
|
109
115
|
};
|
|
110
116
|
const selectsShell = (l, end) => selectedOptions(launcherRowOf(l.name), folded.slice(l.index + 1, end)).some((o) => o.mode === "interactive");
|
|
111
|
-
|
|
117
|
+
const first = pastLauncherOptions(basename, folded, i + 1, moved, queried);
|
|
118
|
+
if (looksUp)
|
|
119
|
+
return chain(undefined);
|
|
120
|
+
for (let j = first; j < folded.length; j++) {
|
|
112
121
|
const t = folded[j];
|
|
113
122
|
if (owedOperands > 0 && !t.startsWith("-")) {
|
|
114
123
|
owedOperands -= 1;
|
|
@@ -133,7 +142,9 @@ export function readProgramPosition(words) {
|
|
|
133
142
|
if (launcherRunsShell(launcherRowOf(wordBasename), folded.slice(j + 1)))
|
|
134
143
|
return chain(undefined, shellRefusal(j, j + 1));
|
|
135
144
|
owedOperands = lastRow?.requiredOperands ?? 0;
|
|
136
|
-
j = pastLauncherOptions(wordBasename, folded, j + 1, moved) - 1;
|
|
145
|
+
j = pastLauncherOptions(wordBasename, folded, j + 1, moved, queried) - 1;
|
|
146
|
+
if (looksUp)
|
|
147
|
+
return chain(undefined);
|
|
137
148
|
continue;
|
|
138
149
|
}
|
|
139
150
|
if (t.startsWith("-"))
|
|
@@ -29,7 +29,7 @@ export declare const PATH_VALUE_FORMS: readonly ["indirect", "form", "header", "
|
|
|
29
29
|
export type PathValueForm = (typeof PATH_VALUE_FORMS)[number];
|
|
30
30
|
/** What selecting an option does to how the program runs ({@link OptionSpec.mode}) — a CLOSED set (`docs/CLOSED-SETS.md`,
|
|
31
31
|
* `OptionMode`). */
|
|
32
|
-
export declare const OPTION_MODES: readonly ["banner", "interactive", "names-program", "follows", "repeats", "quiet", "forced"];
|
|
32
|
+
export declare const OPTION_MODES: readonly ["banner", "interactive", "names-program", "follows", "repeats", "quiet", "forced", "queries"];
|
|
33
33
|
export type OptionMode = (typeof OPTION_MODES)[number];
|
|
34
34
|
/**
|
|
35
35
|
* What one word is in this program's hands — a CLOSED set (`docs/CLOSED-SETS.md`, `ArgRoleKind`), the eleven members the
|
|
@@ -188,7 +188,8 @@ export interface OptionSpec {
|
|
|
188
188
|
* the program to run without a value (`node --test`; a launcher's, from the caller's environment: `su -m`); `follows`:
|
|
189
189
|
* follows its files as they grow, until killed (`tail -f`); `repeats`: repeats on an interval until killed (`free -s`);
|
|
190
190
|
* `quiet`: prints only what the program's own text tells it to (`sed -n`); `forced`: removes without asking, and passes
|
|
191
|
-
* over an operand that is not there (`rm -f`)
|
|
191
|
+
* over an operand that is not there (`rm -f`); `queries`: a LOOKUP — the operands are names it looks up and describes, and
|
|
192
|
+
* it runs none of them (`command -v`). */
|
|
192
193
|
readonly mode?: OptionMode;
|
|
193
194
|
/** This switch turns the program's own value glob off (`curl -g`): every {@link ArgRole} `programGlob` value of the
|
|
194
195
|
* invocation is read literally. */
|
|
@@ -717,6 +718,10 @@ export declare function launcherOptionPathRole(row: ProgramRow, tok: string): Ex
|
|
|
717
718
|
* written (`nsenter -w` bare takes the target process's directory)? In a cluster, a letter up to the first that takes a
|
|
718
719
|
* value. `false` for any word the walk does not step. */
|
|
719
720
|
export declare function launcherOptionMovesBase(row: ProgramRow, tok: string): boolean;
|
|
721
|
+
/** Does this launcher option word select a LOOKUP ({@link OptionSpec.mode} `queries`: `command -v`, `-V`, a cluster
|
|
722
|
+
* carrying one — `-pv`), so that the words after the options are names and no program is run? In a cluster, a letter up
|
|
723
|
+
* to the first that takes a value. `false` for any word the walk does not step. */
|
|
724
|
+
export declare function launcherOptionQueries(row: ProgramRow, tok: string): boolean;
|
|
720
725
|
/** How many words one launcher option occupies ({@link readLauncherOption}), or `undefined` where the row does not
|
|
721
726
|
* explain it. */
|
|
722
727
|
export declare function launcherOptionStep(row: ProgramRow, tok: string): 1 | 2 | undefined;
|
|
@@ -2,7 +2,7 @@ import { literalTildeSpelling } from "../../shell/lex.js";
|
|
|
2
2
|
import { PROGRAM_ROWS } from "./program-read-model.rows.js";
|
|
3
3
|
export const DATUM_FORMS = Object.freeze(["count", "pattern", "name-pattern", "label", "message", "delimiter"]);
|
|
4
4
|
export const PATH_VALUE_FORMS = Object.freeze(["indirect", "form", "header", "url"]);
|
|
5
|
-
export const OPTION_MODES = Object.freeze(["banner", "interactive", "names-program", "follows", "repeats", "quiet", "forced"]);
|
|
5
|
+
export const OPTION_MODES = Object.freeze(["banner", "interactive", "names-program", "follows", "repeats", "quiet", "forced", "queries"]);
|
|
6
6
|
export const ARG_ROLE_KINDS = Object.freeze(["path", "mount-source", "object-path", "program-text", "program-file", "shell-text", "command", "assignment", "binds-name", "entry-probe", "datum"]);
|
|
7
7
|
export const PATH_EFFECTS = Object.freeze(["read", "names", "write", "create", "edit-in-place"]);
|
|
8
8
|
export const WRITING_EFFECTS = new Set(["write", "create", "edit-in-place"]);
|
|
@@ -1091,6 +1091,26 @@ export function launcherOptionMovesBase(row, tok) {
|
|
|
1091
1091
|
}
|
|
1092
1092
|
return false;
|
|
1093
1093
|
}
|
|
1094
|
+
export function launcherOptionQueries(row, tok) {
|
|
1095
|
+
if (tok === "-" || tok === "--")
|
|
1096
|
+
return false;
|
|
1097
|
+
if (tok.startsWith("--")) {
|
|
1098
|
+
const eq = tok.indexOf("=");
|
|
1099
|
+
return steppableLauncherOption(row, eq > 2 ? tok.slice(0, eq) : tok)?.spec.mode === "queries";
|
|
1100
|
+
}
|
|
1101
|
+
if (!/^-[^-]/.test(tok))
|
|
1102
|
+
return false;
|
|
1103
|
+
for (let i = 1; i < tok.length; i++) {
|
|
1104
|
+
const m = steppableLauncherOption(row, `-${tok[i]}`);
|
|
1105
|
+
if (m === undefined)
|
|
1106
|
+
return false;
|
|
1107
|
+
if (m.spec.mode === "queries")
|
|
1108
|
+
return true;
|
|
1109
|
+
if (m.arity !== 0)
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
return false;
|
|
1113
|
+
}
|
|
1094
1114
|
export function launcherOptionStep(row, tok) {
|
|
1095
1115
|
return readLauncherOption(row, tok)?.step;
|
|
1096
1116
|
}
|
|
@@ -31,6 +31,7 @@ const OBJECT_PATH = { kind: "object-path" };
|
|
|
31
31
|
const text = (language) => ({ kind: "program-text", language });
|
|
32
32
|
const file = (language) => ({ kind: "program-file", language });
|
|
33
33
|
const SWITCH = { arity: 0 };
|
|
34
|
+
const QUERY = { arity: 0, mode: "queries" };
|
|
34
35
|
const val = (value, extra = {}) => ({ arity: 1, ...(value !== undefined ? { value } : {}), ...extra });
|
|
35
36
|
const optional = (value) => ({ arity: "attached", ...(value !== undefined ? { value } : {}) });
|
|
36
37
|
const switches = (letters, extra = {}) => Object.fromEntries([...letters].map((l) => [l, { arity: 0, ...extra }]));
|
|
@@ -380,10 +381,10 @@ const launchers = [
|
|
|
380
381
|
values: { "-u": LABEL, "--unset": LABEL, "-C": WORKDIR, "--chdir": WORKDIR, "-a": LABEL, "--argv0": LABEL, "-P": PROGRAM_SEARCH },
|
|
381
382
|
optional: { "--default-signal": LABEL, "--ignore-signal": LABEL },
|
|
382
383
|
shellText: ["-S", "--split-string"],
|
|
383
|
-
}), { lacks: ["-P"] }, { lacks: ["-a", "--ignore-environment", "--debug", "--list-signal-handling", "--help", "--version", "--unset", "--chdir", "--argv0", "--default-signal", "--ignore-signal", "--split-string"] }), launcher: { assignmentOperands: true, bareDash: true }, witness: BINARY },
|
|
384
|
-
launcher("command", {}, grammar({ p: SWITCH })),
|
|
385
|
-
launcher("builtin"),
|
|
386
|
-
launcher("exec", {}, launcherGrammar({ switches: ["-c", "-l"], values: { "-a": LABEL } })),
|
|
384
|
+
}, { stepsDashDash: true }), { lacks: ["-P"] }, { lacks: ["-a", "--ignore-environment", "--debug", "--list-signal-handling", "--help", "--version", "--unset", "--chdir", "--argv0", "--default-signal", "--ignore-signal", "--split-string"] }), launcher: { assignmentOperands: true, bareDash: true }, witness: BINARY },
|
|
385
|
+
launcher("command", {}, grammar({ p: SWITCH, v: QUERY, V: QUERY }, {}, { stepsDashDash: true })),
|
|
386
|
+
launcher("builtin", {}, grammar({}, {}, { stepsDashDash: true })),
|
|
387
|
+
launcher("exec", {}, launcherGrammar({ switches: ["-c", "-l"], values: { "-a": LABEL } }, { stepsDashDash: true })),
|
|
387
388
|
launcher("noglob", {}, undefined, { witness: { declared: "a zsh precommand modifier" } }),
|
|
388
389
|
launcher("nocorrect", {}, undefined, { witness: { declared: "a zsh precommand modifier" } }),
|
|
389
390
|
{ key: "xargs", grammars: implementations(launcherGrammar({
|
|
@@ -406,8 +407,8 @@ const launchers = [
|
|
|
406
407
|
{ key: "runuser", grammars: [launcherGrammar({ ...SU_OPTIONS, values: { ...SU_OPTIONS.values, "-u": LABEL, "--user": LABEL } }, { afterFirstOperand: "options", stepsDashDash: true })], launcher: { shellText: "user", programWith: ["-u", "--user"], asUser: { home: "target", loginWith: ["-l", "--login"] } }, witness: PRIVILEGED },
|
|
407
408
|
launcher("chroot", { requiredOperands: 1, whenNoProgram: "shell" }, undefined, { witness: PRIVILEGED }),
|
|
408
409
|
launcher("setpriv", {}, undefined, { witness: PRIVILEGED }),
|
|
409
|
-
launcher("nohup", {}, grammar({}, BANNERS)),
|
|
410
|
-
{ key: "nice", grammars: implementations(grammar({ n: val(COUNT) }, { adjustment: val(COUNT), ...BANNERS }), {}, { lacks: ["--adjustment", "--help", "--version"] }), launcher: { negativeIntegerIsOwnArgument: true }, witness: BINARY },
|
|
410
|
+
launcher("nohup", {}, grammar({}, BANNERS, { stepsDashDash: true })),
|
|
411
|
+
{ key: "nice", grammars: implementations(grammar({ n: val(COUNT) }, { adjustment: val(COUNT), ...BANNERS }, { stepsDashDash: true }), {}, { lacks: ["--adjustment", "--help", "--version"] }), launcher: { negativeIntegerIsOwnArgument: true }, witness: BINARY },
|
|
411
412
|
launcher("ionice", {}, launcherGrammar({
|
|
412
413
|
switches: ["-t", "--ignore", "-h", "--help", "-V", "--version"],
|
|
413
414
|
banners: ["-h", "-V"],
|
|
@@ -418,8 +419,8 @@ const launchers = [
|
|
|
418
419
|
values: { "-p": COUNT, "--pid": COUNT, "-T": COUNT, "-P": COUNT, "-D": COUNT, "--sched-runtime": COUNT, "--sched-period": COUNT, "--sched-deadline": COUNT },
|
|
419
420
|
}), { witness: LINUX_NAMESPACE }),
|
|
420
421
|
launcher("taskset", { requiredOperands: 1, requiredOperandSpelling: /^(?:0x)?[\da-f]+$|^[\d,:-]+$/i }, launcherGrammar({ switches: ["-a", "-p", "-c", "--all-tasks", "--pid", "--cpu-list", "--help", "--version"] }), { witness: LINUX_NAMESPACE }),
|
|
421
|
-
{ key: "stdbuf", grammars: implementations(grammar({ i: val(LABEL), o: val(LABEL), e: val(LABEL) }, { input: val(LABEL), output: val(LABEL), error: val(LABEL), ...BANNERS }), {}, { lacks: ["--input", "--output", "--error", "--help", "--version"] }), launcher: {}, witness: BINARY },
|
|
422
|
-
launcher("timeout", { requiredOperands: 1, requiredOperandSpelling: DURATION_SPELLING }, grammar({ v: SWITCH, k: val(COUNT), s: val(LABEL) }, { foreground: SWITCH, "preserve-status": SWITCH, verbose: SWITCH, "kill-after": val(COUNT), signal: val(LABEL), ...BANNERS })),
|
|
422
|
+
{ key: "stdbuf", grammars: implementations(grammar({ i: val(LABEL), o: val(LABEL), e: val(LABEL) }, { input: val(LABEL), output: val(LABEL), error: val(LABEL), ...BANNERS }, { stepsDashDash: true }), {}, { lacks: ["--input", "--output", "--error", "--help", "--version"] }), launcher: {}, witness: BINARY },
|
|
423
|
+
launcher("timeout", { requiredOperands: 1, requiredOperandSpelling: DURATION_SPELLING }, grammar({ v: SWITCH, k: val(COUNT), s: val(LABEL) }, { foreground: SWITCH, "preserve-status": SWITCH, verbose: SWITCH, "kill-after": val(COUNT), signal: val(LABEL), ...BANNERS }, { stepsDashDash: true })),
|
|
423
424
|
{ key: "time", grammars: implementations(grammar({ p: SWITCH, a: SWITCH, v: SWITCH, q: SWITCH, f: val(LABEL), o: val(WRITE) }, { portability: SWITCH, append: SWITCH, verbose: SWITCH, quiet: SWITCH, help: SWITCH, version: SWITCH, format: val(LABEL), output: val(WRITE) }, { stepsDashDash: true }), {}, { lacks: ["-v", "-q", "-f", "--portability", "--append", "--verbose", "--quiet", "--help", "--version", "--format", "--output"] }), launcher: {}, witness: BINARY },
|
|
424
425
|
{ key: "watch", grammars: [
|
|
425
426
|
launcherGrammar({
|
|
@@ -430,12 +431,12 @@ const launchers = [
|
|
|
430
431
|
}, { afterFirstOperand: "operands", stepsDashDash: true }),
|
|
431
432
|
launcherGrammar({ switches: ["-d", "-t"], values: { "-n": COUNT } }, { afterFirstOperand: "operands", stepsDashDash: true }),
|
|
432
433
|
], launcher: { shellText: "joined", programWith: ["-x", "--exec"] }, witness: SHELL_TEXT_LAUNCHER },
|
|
433
|
-
launcher("setsid", {}, launcherGrammar({ switches: ["-c", "-f", "-w", "--ctty", "--fork", "--wait", "--help", "--version"] })),
|
|
434
|
+
launcher("setsid", {}, launcherGrammar({ switches: ["-c", "-f", "-w", "--ctty", "--fork", "--wait", "--help", "--version"] }, { stepsDashDash: true })),
|
|
434
435
|
launcher("flock", { requiredOperands: 1 }, launcherGrammar({
|
|
435
436
|
switches: ["-s", "-x", "-u", "-n", "-o", "-F", "--shared", "--exclusive", "--unlock", "--nonblock", "--nb", "--close", "--no-fork", "--verbose", "--help", "--version"],
|
|
436
437
|
values: { "-w": COUNT, "-E": COUNT, "--timeout": COUNT, "--wait": COUNT, "--conflict-exit-code": COUNT },
|
|
437
438
|
shellText: ["-c", "--command"],
|
|
438
|
-
}), { operands: { rest: CREATED } }),
|
|
439
|
+
}, { stepsDashDash: true }), { operands: { rest: CREATED } }),
|
|
439
440
|
launcher("unshare", { whenNoProgram: "shell" }, launcherGrammar({
|
|
440
441
|
switches: ["-m", "-u", "-i", "-n", "-p", "-U", "-C", "-T", "-f", "-r", "-c", "--mount", "--uts", "--ipc", "--net", "--pid", "--user", "--cgroup", "--time", "--fork", "--map-root-user", "--map-current-user", "--kill-child", "--mount-proc", "--keep-caps", "--help", "--version"],
|
|
441
442
|
values: { "-R": ROOTDIR, "-w": WORKDIR, "-S": LABEL, "-G": LABEL, "--setuid": LABEL, "--setgid": LABEL, "--root": ROOTDIR, "--wd": WORKDIR, "--propagation": LABEL, "--setgroups": LABEL, "--monotonic": COUNT, "--boottime": COUNT, "--map-user": LABEL, "--map-group": LABEL },
|
|
@@ -9,7 +9,7 @@ import { createShellReadEstablisher, establishShellReadsLexically } from "./shel
|
|
|
9
9
|
import { declaredReadDenyHomes, homeViewsAt } from "./read-deny-home.js";
|
|
10
10
|
import { programArgs } from "./shell-read-write-targets.js";
|
|
11
11
|
import { programRow, selectedOptions } from "./program-read-model.js";
|
|
12
|
-
import { allFindings, anyFaceGuards, decideShellRead, denyRowAtOrBelow, foldShellReadFindings, judgeEstablishedPlan, readRootGrantCandidateOf, stationGuards, writeFaceGuards, } from "./shell-read-judge.js";
|
|
12
|
+
import { allFindings, anyFaceGuards, decideShellRead, decideShellReadForRedirects, denyRowAtOrBelow, foldShellReadFindings, judgeEstablishedPlan, readRootGrantCandidateOf, stationGuards, writeFaceGuards, } from "./shell-read-judge.js";
|
|
13
13
|
import { seatOf } from "../../core/tool-name-readings.js";
|
|
14
14
|
import { normalizeReadRootCandidate } from "../../core/read-root-candidate.js";
|
|
15
15
|
import { argvOf, lexShellText } from "../../shell/lex.js";
|
|
@@ -241,6 +241,20 @@ function outsidePathsOf(judged) {
|
|
|
241
241
|
}
|
|
242
242
|
return out;
|
|
243
243
|
}
|
|
244
|
+
function outsideFindingsAreRedirections(judged) {
|
|
245
|
+
const walk = (plan) => plan.segments.every((s) => {
|
|
246
|
+
const redirected = new Set(s.segment.redirects.flatMap((r) => (r.direction === "out" && r.target !== undefined ? [r.target.raw] : [])));
|
|
247
|
+
const slotWrites = new Set(s.segment.reads.filter((c) => c.provenance === "write_redirect_target").map((c) => c.raw));
|
|
248
|
+
for (const f of s.findings) {
|
|
249
|
+
if (f.kind !== "outside_roots")
|
|
250
|
+
continue;
|
|
251
|
+
if (f.read.written === undefined || f.read.candidate.provenance !== "write_redirect_target" || !redirected.has(f.read.candidate.raw) || slotWrites.has(f.read.candidate.raw))
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
return s.nested.every(walk);
|
|
255
|
+
});
|
|
256
|
+
return walk(judged);
|
|
257
|
+
}
|
|
244
258
|
function refusalOf(tool, fold, boundary, paths) {
|
|
245
259
|
const w = fold.witness;
|
|
246
260
|
if (fold.finding === "deny_hit" && w?.deny !== undefined) {
|
|
@@ -261,7 +275,8 @@ const DENY_REFUSAL_NOTE = "The read boundary judges the paths this command names
|
|
|
261
275
|
"a file the command reaches without naming it is judged only where a segment deny row (built in, or the deployment's " +
|
|
262
276
|
"own pattern) reaches into the tree a recursive read walks.";
|
|
263
277
|
function verdictOf(tool, seat, fold, judged, boundary) {
|
|
264
|
-
const
|
|
278
|
+
const redirectsOnly = fold.finding === "outside_roots" && fold.unestablished !== true && outsideFindingsAreRedirections(judged);
|
|
279
|
+
const disposition = decideShellReadForRedirects(fold.finding, seat, redirectsOnly);
|
|
265
280
|
if (disposition === "vouch")
|
|
266
281
|
return { disposition, finding: fold.finding };
|
|
267
282
|
const paths = outsidePathsOf(judged);
|
|
@@ -110,6 +110,9 @@ export type DecisionTableCoversEverySeat = AssertAllKeysHandled<Exclude<ShellRea
|
|
|
110
110
|
* {@link SHELL_READ_SEATS} (an untyped caller, or one still passing a retired word) is refused loudly: an
|
|
111
111
|
* absent disposition would read as "no verdict", which no caller can act on safely. */
|
|
112
112
|
export declare function decideShellRead(finding: ShellReadFinding, seat: ShellReadSeat): ShellReadDisposition;
|
|
113
|
+
/** {@link decideShellRead} for a command whose containment findings are all output redirections (`redirectsOnly`) or not — the
|
|
114
|
+
* cell {@link SHELL_REDIRECT_DECISION} states where it states one, else the table's. */
|
|
115
|
+
export declare function decideShellReadForRedirects(finding: ShellReadFinding, seat: ShellReadSeat, redirectsOnly: boolean): ShellReadDisposition;
|
|
113
116
|
/**
|
|
114
117
|
* The boundary a shell read is judged against: the declared roots (canonical), the working directory and
|
|
115
118
|
* home the plan's bases start from, the deny seat (the SAME `denyMatch` closure every read face mounts),
|
|
@@ -28,6 +28,13 @@ export function decideShellRead(finding, seat) {
|
|
|
28
28
|
}
|
|
29
29
|
return disposition;
|
|
30
30
|
}
|
|
31
|
+
const SHELL_REDIRECT_DECISION = {
|
|
32
|
+
outside_roots: { full_shell: "ordinary" },
|
|
33
|
+
};
|
|
34
|
+
export function decideShellReadForRedirects(finding, seat, redirectsOnly) {
|
|
35
|
+
const cell = redirectsOnly && Object.hasOwn(SHELL_REDIRECT_DECISION, finding) ? SHELL_REDIRECT_DECISION[finding]?.[seat] : undefined;
|
|
36
|
+
return cell ?? decideShellRead(finding, seat);
|
|
37
|
+
}
|
|
31
38
|
export function judgeRead(read, boundary) {
|
|
32
39
|
const writeTarget = read.candidate.face === "write";
|
|
33
40
|
const deny = writeTarget ? boundary.writeDenyMatch : boundary.denyMatch;
|