@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 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). When the replacements
66
- * cannot be mapped back exactly (a later pass matched across an earlier pass's inserted text), the
67
- * output is the redacted view itself — every format character dropped: the failure direction is
68
- * over-stripping, never a leak.
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
  /**
@@ -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 enclosingHits(hits) {
83
- const sorted = [...hits].sort((x, y) => x.start - y.start || y.end - x.end);
84
- const outer = new Set();
85
- let reach;
86
- for (const r of sorted) {
87
- if (reach !== undefined && reach.pass !== r.pass && reach.end >= r.end && reach.end - reach.start > r.end - r.start)
88
- outer.add(reach);
89
- if (reach === undefined || r.end > reach.end)
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 [...outer];
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 outer = enclosingHits(soloHits(input, passes));
158
- const whole = outer.length === 0 ? undefined : renderRemovals(input, [...sequentialRemovals(plain.batches), ...outer]);
159
- if (whole === undefined || whole.out === plain.out) {
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 { out, batches } = applyPasses(view, passes, report, toOriginal);
273
+ const { batches } = applyPasses(view, passes, report, toOriginal, true);
237
274
  const viewSpans = replacedSpans(batches);
238
275
  if (viewSpans === undefined)
239
- return out;
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 overlapping = spans.find((v) => r.start < v.end && v.start < r.end);
276
- if (overlapping !== undefined) {
277
- if (r.start < overlapping.start)
278
- overlapping.start = r.start;
279
- if (r.end > overlapping.end)
280
- overlapping.end = r.end;
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.push({ start: r.start, end: r.end, inserted: r.inserted });
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; the model is asked to re-issue them);
313
- * `unnamed` — calls that arrived with no tool name, dropped the same way; `bad_frame` — streamed response frames
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; `toolCallIds` on
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.
@@ -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 = Array.isArray(content) && content.some((c) => c?.type === "toolCall");
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 ToolTurnIncompleteCause, type ToolTurnRepair } from "../../internal/llm.js";
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 `count` items of `cause` (and, for a missing result, the calls) unless this run already said it. */
11
- announce(cause: ToolTurnIncompleteCause, count: number, toolCallIds?: readonly string[]): void;
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: (n) => `${n} tool call(s) the model made were cut off before their arguments were complete, so they did not run; the model was asked to re-issue them`,
14
- unnamed: (n) => `${n} tool call(s) the model made arrived with no tool name, so they did not run; the model was asked to re-issue them`,
15
- bad_frame: (n) => `${n} 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`,
16
- missing_result_synthesized: (n) => `${n} earlier tool call(s) have no recorded result, so the model was told their outcome is unknown`,
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 = (cause, count, toolCallIds) => {
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 = (toolCallIds ?? []).filter((id) => isProviderEchoShape(id));
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](count)}. Announced once per run for this 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
- for (const drop of streamDropsOf(message))
35
- announce(drop.cause, drop.count);
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 or the read was refused. */
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
- for (let j = pastLauncherOptions(basename, folded, i + 1, moved); j < folded.length; j++) {
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 disposition = decideShellRead(fold.finding, seat);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.49.0",
3
+ "version": "7.49.1",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",