@sema-agent/core 7.30.0 → 7.30.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,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.30.1 — 2026-09-24
4
+
5
+ ### An unquoted extended-glob opener makes a shell text unestablishable (#1064 ⑨; patch; read boundary NARROWING; +0 exports; +0 wire keys; @cli @test)
6
+
7
+ - **What was wrong.** After `shopt -s extglob`, bash reads `pack@(age.json)` as one pattern and `<<@(x)` as the here-document delimiter `@(x)`; the lexer reads a split word (`pack@`, `age.json`) and the delimiter `@`. The read station judged the split words, found nothing, and vouched: `shopt -s extglob` on one line and `head -c 64 < pack@(age.json)` on the next read a deny-row file at zero cards on the real lane (v7.29.0 and v7.30.0 alike). The only extglob reading lived inside the HOME-rebinding check.
8
+ - **One rule.** An unquoted `?(` `*(` `+(` `@(` `!(` in an argv word, a redirection target or a here-document delimiter joins the plan's `unreadable` floor (`unreadableSentenceOf`, beside the unresolved-word sentence): the whole text is unestablishable — mandated on `bash_off` and `bash_classify`, refused on the read-only face, denied headless — and a deny hit anywhere in it still refuses. A quoted opener, a comment and a here-document body are the text they always were; an `$((…))` arithmetic body is never globbed and keeps its reading.
9
+ - **Review round (before release).** Two bypasses of the first cut are closed: an executable substitution whose text equals an arithmetic body (the exemption is granted only to a body that is one parenthesised group spanning the whole body — `$((cat x); :)` is a command substitution — and only where every substitution spelled so is such a body), and a here-document delimiter quoted in part (`<<'E'@(X)` — a delimiter quoted anywhere counts).
10
+ - **Pre-release batch 2.** The arithmetic test reads the lexer's structure (a quoted, escaped or commented parenthesis is text: `$((echo '('; …); (echo ')'))` is a command substitution and keeps the floor), and a here-document body keeps each substitution's kind (`cat <<EOF\n$((2*(3+4)))\nEOF` stays vouched on every seat, as on 7.30.0).
11
+ - **Who is hurt.** A text with an unquoted opener costs a card even when extglob is off (`ls !(x)` — a syntax error there — and the arithmetic command `(( 2*(3+4) ))`, whose `2*(` reads as an opener), and so do pattern positions bash never globs: `[[ $v =~ ^[0-9]+(\.[0-9]+)?$ ]]`, `[[ $f == *.@(js|ts) ]]`, `case $x in @(a|b))` (both full-shell seats ask, the read-only face refuses; ≈0 in the corpus). KL-994k.
12
+
13
+ ### The declared-home deny floor is established as a read — pattern, tree, canonical target (#1064 ⑬; patch; read boundary NARROWING; `ReadCandidate.denyOnly?` added (optional); +0 exports; +0 wire keys; @cli @test)
14
+
15
+ - **What was wrong.** 7.30.0 gave an unplaced home word (a text that may rebind `HOME`, a home word inside a value) its declared-home spelling as one more LITERAL spelling for the deny judge. A pattern (`~/k*.txt`), a traversal (`grep -r KEY ~`) or a symlink under the home never matched it, so those asked — and, approved, read the deny row — where v7.29.0 refused them.
16
+ - **One rule.** Each such word's `floorSpelled` becomes a SHADOW candidate (`denyFloorShadowsOf`, `shell-read-judge.ts`) established exactly as a read is — canonical target, a pattern's matches, a traversal root's tree — and judged for deny rows only (`ReadCandidate.denyOnly`): it never adds a containment finding and never vouches, so a read that is not a deny row still asks through its own unplaced candidate. The literal spelling the unresolvable arm used to carry is gone (one mechanism, not two).
17
+ - **Also on the floor.** A bare `cd` / `cd --` puts the declared home on the deny floor (`cd && cat <deny row under home>` refuses); a `[k]=` compound element is read as the value bash assigns, so its `~` expands and its `$HOME` is a home word (`A=([0]="$HOME/<deny>")`, `A=([0]=~/<deny>)` refuse).
18
+ - **Review round (before release).** A shadow is never an identity in the pre-spawn re-check (a real read retargeted onto a shadow's target is still refused), and a `[k]=` element is re-read as an assignment VALUE (a leading `#` stays literal — `A=([0]=#dir/".env")` refuses as on 7.30.0; a quoted `"[k]=…"` element keeps its tail's spelling too, as on 7.30.0).
19
+ - **Pre-release batch 2.** A `[k]=` element keeps its word's own reading (its folded tail, globbed as the word is) as a deny-only candidate beside the assignment value: an unquoted `${A[k]}` expands a stored `pack*`, and `A=([0]="pack*"); head ${A[0]}` refuses on disk as on 7.30.0.
20
+ - **Admission.** A deny-only read (a shadow, an element word's own reading) is its own identity class in the pre-spawn re-check: a real read retargeted onto a shadow's target is still caught, and a deny-only read retargeted onto a deny row between approval and spawn is re-judged and refused (`read_path_denied`), as a real read is.
21
+ - **Pre-release batch 4.** Candidate de-duplication keys on `denyOnly` too: a deny-only read never absorbs an ordinary read of the same spelling (a real match retargeted after judgment is refused at admission in either assignment order, as on 7.30.0); and the arithmetic test follows its group across the segments the lexer cuts at `&&` `||` `&` `|` and line breaks (`$((2*(3+4) && 1))`, a multi-line body stay vouched).
22
+ - **Pre-release batch 5.** The pre-spawn identity comparison is PER CANDIDATE (`shell-read-identity.ts`): each candidate's established targets are compared with its own re-establishment, keyed by where it stands in the plan and what it is. A pooled set let another word's pattern (`A=([0]="pair*") B=pair*; head linkx`) or a home shadow already reaching the new target hide a retargeted read, which was admitted; it is refused now (the `pair*` form was admitted on 7.30.0 too).
23
+ - **Consumer face.** `judgeShellRead().establishment` now lists the shadow reads too (`candidate.denyOnly: true`); a consumer that counts established reads sees them.
24
+ - **Not covered** (KL-994l): an option payload's home word (`--file="$HOME/…"`), `pushd ~`, parameter modifiers (`${HOME:-…}`), and a `~` assignment value no program opens (`A=~/<deny>; head "$A"` still asks — the #1004 ruling keeps `A=~/.env; true` vouched).
25
+
26
+ ### A language server's child process gets the tool child's environment scrub (#1073; patch; env face NARROWING; `NodeLspManagerOptions.env?` added (optional); +0 exports; @server)
27
+
28
+ - `defaultLspSpawn` passed no `env` to `child_process.spawn`, so a language server inherited the whole host `process.env` — credentials included — while a tool's child process got `scrubSecretEnv`. The default spawn now passes the same scrub (the one function, applied at each spawn). A host that wants another environment passes `NodeLspManagerOptions.env`, used verbatim; a host-supplied `spawn` owns its child's environment as before. The LSP scrub has no findings collector: what it removed is not reported (the tool child's `onScrub` channel has no LSP twin). **Who is hurt:** a language server that read a credential-shaped variable from the host environment no longer sees it; pass it through `env`.
29
+
30
+ ### Plural credential names are scrubbed from a spawned environment (#1072; patch; env face NARROWING; +0 exports; @server @web-admin)
31
+
32
+ - `SECRET_ENV_RE` reads `KEYS`, `TOKENS` and `PASSWORDS` as it reads their singulars (`SECRETS` / `CREDENTIALS` already were): `SERVICE_AUTH_TOKENS`, `API_KEYS`, `DB_PASSWORDS` no longer reach a child shell. The exact-name exemptions (`PARTITION_KEY`, …) still win.
33
+ - **A token COUNT is not a credential.** `TOKENS` is also a count word: right after `MAX` / `OUTPUT` / `INPUT` / `THINKING` / `NUM` / `TOTAL` (`TOKEN_COUNT_WORDS`, the reverse ledger in the same place) it is the count and is kept — `MODEL_MAX_TOKENS`, `MAX_THINKING_TOKENS`, `CLAUDE_CODE_MAX_OUTPUT_TOKENS`, `MAX_TOKENS` still reach an agent CLI run in the child shell. The singular `TOKEN` keeps its 7.30.0 reading.
34
+
3
35
  ## 7.30.0 — 2026-09-24
4
36
  ### A resumed leg with no shell is no longer told to run `git status` (#1046; prompt-face NARROWING; +0 exports; +0 wire keys; @server @test)
5
37
 
@@ -1,4 +1,5 @@
1
- const SECRET_ENV_RE = /(^|_)(KEY|TOKEN|SECRET|SECRETS|PASSWORD|PASSWD|CREDENTIAL|CREDENTIALS|PRIVATEKEY|APIKEY|ACCESS_KEY|SESSION_TOKEN)(?:_(?:\d+|ID))?$/i;
1
+ const TOKEN_COUNT_WORDS = ["MAX", "OUTPUT", "INPUT", "THINKING", "NUM", "TOTAL"];
2
+ const SECRET_ENV_RE = new RegExp(`(^|_)(KEY|KEYS|TOKEN|(?<!(?:^|_)(?:${TOKEN_COUNT_WORDS.join("|")})_)TOKENS|SECRET|SECRETS|PASSWORD|PASSWORDS|PASSWD|CREDENTIAL|CREDENTIALS|PRIVATEKEY|APIKEY|ACCESS_KEY|SESSION_TOKEN)(?:_(?:\\d+|ID))?$`, "i");
2
3
  const SECRET_ENV_EXACT_NAMES = new Set([
3
4
  "DATABASE_URL", "CONNECTION_STRING", "DSN", "NPM_AUTH",
4
5
  "PGPASSWORD", "MYSQL_PWD", "SSHPASS", "GPG_PASSPHRASE", "GH_PAT", "GITHUB_PAT",
@@ -19,6 +19,9 @@ export interface NodeLspManagerOptions {
19
19
  servers?: Record<string, string | null>;
20
20
  /** Injectable spawn (tests supply a fake server; default = {@link defaultLspSpawn} over `child_process.spawn`). */
21
21
  spawn?: LspSpawn;
22
+ /** The language server's environment for the default spawn, verbatim. Omitted ⇒ the host environment scrubbed of
23
+ * credential-shaped names (`scrubSecretEnv`, a tool child's scrub). Unread when {@link spawn} is supplied. */
24
+ env?: Record<string, string>;
22
25
  /** Read a file's CURRENT text for the session's didOpen/didChange re-sync (default = local `fs.readFile` utf8). */
23
26
  readText?: LspReadText;
24
27
  /** Resolve the server `cwd`/`rootUri` for a file (default = env.cwd ?? nearest project marker ?? file dir). */
@@ -9,6 +9,7 @@ import { TransportLspSession } from "../../core/lsp-session.js";
9
9
  import { StdioLspTransport } from "./stdio-lsp-transport.js";
10
10
  import { MAX_LSP_FILE_BYTES, SharedAbortScope, settleOnAbort } from "../../core/lsp.js";
11
11
  import { asThrownError, thrownText } from "../../core/thrown-value.js";
12
+ import { scrubSecretEnv } from "../../core/secret-env.js";
12
13
  export const DEFAULT_LSP_SERVERS = {
13
14
  typescript: "typescript-language-server --stdio",
14
15
  javascript: "typescript-language-server --stdio",
@@ -66,7 +67,7 @@ export class NodeLspManager {
66
67
  disposed = false;
67
68
  constructor(opts = {}) {
68
69
  this.servers = { ...DEFAULT_LSP_SERVERS, ...opts.servers };
69
- this.spawnFn = opts.spawn ?? defaultLspSpawn;
70
+ this.spawnFn = opts.spawn ?? (opts.env !== undefined ? lspSpawnWithEnv(opts.env) : defaultLspSpawn);
70
71
  this.readTextFn = opts.readText ?? defaultLspReadText;
71
72
  this.resolveRootFn = opts.resolveRoot ?? defaultResolveRoot;
72
73
  this.log = opts.log ?? (() => { });
@@ -328,10 +329,11 @@ export function lspSpawnOptions(cwd) {
328
329
  ? { cwd, stdio: ["pipe", "pipe", "ignore"], shell: true }
329
330
  : { cwd, stdio: ["pipe", "pipe", "ignore"] };
330
331
  }
331
- export const defaultLspSpawn = (command, args, cwd, signal) => new Promise((resolve, reject) => {
332
+ export const defaultLspSpawn = (command, args, cwd, signal) => lspSpawnWithEnv(undefined)(command, args, cwd, signal);
333
+ const lspSpawnWithEnv = (env) => (command, args, cwd, signal) => new Promise((resolve, reject) => {
332
334
  let child;
333
335
  try {
334
- child = spawn(command, args, lspSpawnOptions(cwd));
336
+ child = spawn(command, args, { ...lspSpawnOptions(cwd), env: env ?? scrubSecretEnv(process.env) });
335
337
  }
336
338
  catch (e) {
337
339
  return reject(asThrownError(e));
@@ -25,8 +25,10 @@ export declare const SHELL_LEX_DIALECT = "bash";
25
25
  /** What holds a character quoted: the four quote forms and the backslash escape, one reason each. */
26
26
  export declare const QUOTE_KINDS: readonly ["'", "\"", "$'", "$\"", "\\"];
27
27
  export type QuoteKind = (typeof QUOTE_KINDS)[number];
28
- /** The expansions this lexer names. Extended globs are absent on purpose: `bash -c` runs with `extglob` off, so
29
- * `?(…)` is a syntax error there and `(` is a group token here. */
28
+ /** The expansions this lexer names. Extended globs are not among them: `extglob` is off when `bash -c` starts, where
29
+ * `?(…)` is a syntax error and `(` is a group token here — but a text may turn it on (`shopt -s extglob` on an earlier
30
+ * line), after which bash reads `pack@(age.json)` as one pattern. A reader that plans reads treats an unquoted opener
31
+ * as a pattern it does not expand (`extglobOpenerIn`, the read station's `unreadable` floor). */
30
32
  export declare const EXPANSION_KINDS: readonly ["parameter", "command", "process", "arithmetic", "tilde", "brace", "glob"];
31
33
  export type ExpansionKind = (typeof EXPANSION_KINDS)[number];
32
34
  /** Why a word's value cannot be read off the text. Each member is a spelling whose value the text does not
@@ -242,7 +244,10 @@ export declare function argvOf(segment: LexedSegment): readonly LexedWord[];
242
244
  export declare function wordsOf(segment: LexedSegment): SegmentTokens;
243
245
  /** The substitution bodies of an UNQUOTED here-document body: `$(…)` and backticks are live there, quotes are
244
246
  * ordinary characters, a backslash protects the next character. `undefined` when a substitution never closes. */
245
- export declare function substitutionsInHeredocBody(body: string): string[] | undefined;
247
+ export declare function substitutionsInHeredocBody(body: string): {
248
+ readonly body: string;
249
+ readonly kind: "command" | "arithmetic";
250
+ }[] | undefined;
246
251
  /** Lex a command: its segments, or the one reason bash (or this lexer) cannot delimit them. */
247
252
  export declare function lexShell(source: string): ShellLex;
248
253
  /**
package/dist/shell/lex.js CHANGED
@@ -305,7 +305,7 @@ export function substitutionsInHeredocBody(body) {
305
305
  const tick = backtickBody(body, i);
306
306
  if (tick === undefined)
307
307
  return undefined;
308
- out.push(tick.body);
308
+ out.push({ body: tick.body, kind: "command" });
309
309
  i = tick.close;
310
310
  continue;
311
311
  }
@@ -313,7 +313,7 @@ export function substitutionsInHeredocBody(body) {
313
313
  const end = parenBodyEnd(body, i + 1);
314
314
  if (typeof end !== "number")
315
315
  return undefined;
316
- out.push(body.slice(i + 2, end - 1));
316
+ out.push({ body: body.slice(i + 2, end - 1), kind: body[i + 2] === "(" ? "arithmetic" : "command" });
317
317
  i = end - 1;
318
318
  }
319
319
  }
@@ -1,4 +1,4 @@
1
- import { type LexedSegment, type LexedWord, type ShellLex, type ShellSegmentConnector, type WordPiece } from "../../shell/lex.js";
1
+ import { type LexedCommand, type LexedSegment, type LexedWord, type ShellLex, type ShellSegmentConnector, type WordPiece } from "../../shell/lex.js";
2
2
  /**
3
3
  * Bare command names `bash_readonly` permits out of the box, ALSO the default reversible set for the
4
4
  * The {@link import("./fs-bash.js").bashReversibilityProbe} classifier. **Coarse first filter, NOT a security
@@ -876,6 +876,12 @@ export declare function homeExpansionOf(w: LexedWord, home: string): {
876
876
  * Elsewhere a computed word is its own unresolvable candidate, and an escape the lexer does not decode is
877
877
  * unresolved on its own (asked). */
878
878
  export declare function homeMayBeRebound(text: string): boolean;
879
+ /** {@link hasUnquotedExtglobOpener} over an already-lexed text. A here-document delimiter counts too: the lexer ends
880
+ * it at the `(` (`<<@(x)` reads as the delimiter `@` and a group), where bash with `extglob` on reads the one word
881
+ * `@(x)` — a different delimiter, so the body the lexer read as data is not the body bash reads. The token keeps no
882
+ * per-character quoting, so a delimiter quoted anywhere (`<<'E'@(X)`) counts as well: a quoted opener is bash's
883
+ * syntax error, where reading it as one costs a card and runs nothing. */
884
+ export declare function extglobOpenerIn(lexed: LexedCommand, text: string): boolean;
879
885
  /**
880
886
  * {@link classifyCompoundReadonly} with the structured verdict. The name/operator/blocking arms
881
887
  * run FIRST and unchanged, so an existing demotion keeps its exact reason and never acquires the
@@ -1510,10 +1510,14 @@ export function homeMayBeRebound(text) {
1510
1510
  }
1511
1511
  function hasUnquotedExtglobOpener(text) {
1512
1512
  const lexed = lexShell(text);
1513
- if ("parseFailure" in lexed)
1514
- return true;
1513
+ return "parseFailure" in lexed || extglobOpenerIn(lexed, text);
1514
+ }
1515
+ export function extglobOpenerIn(lexed, text) {
1515
1516
  for (const seg of lexed.segments) {
1516
- for (const tok of seg.tokens) {
1517
+ for (const [k, tok] of seg.tokens.entries()) {
1518
+ const next = seg.tokens[k + 1];
1519
+ if (tok.kind === "heredoc" && /[?*+@!]$/.test(tok.delimiter) && next?.kind === "group" && next.op === "(")
1520
+ return true;
1517
1521
  const t = tok.kind === "word" ? tok : tok.kind === "redirect" ? tok.target : undefined;
1518
1522
  if (t === undefined)
1519
1523
  continue;
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import type { ExecutionEnv } from "../../internal/harness-types.js";
37
37
  import type { ShellReadPlan, ShellReadSegment } from "./shell-read-plan.js";
38
- import type { EstablishedRead, ShellReadBoundary } from "./shell-read-judge.js";
38
+ import { type EstablishedRead, type ShellReadBoundary } from "./shell-read-judge.js";
39
39
  /**
40
40
  * How many directories one glob operand (or one traversal root) may enumerate, and how many names it may
41
41
  * match, before the leg gives up and answers `unestablishable`.
@@ -1,6 +1,8 @@
1
1
  import { normalizeAbsPathLexically, pathFamilyOf } from "./safety.js";
2
2
  import { boundaryIsTheSandbox, canonicalPathIsAuthoritative } from "../../core/remote-env.js";
3
3
  import { resolveOperandSpellings } from "./bash-readonly-classifier.js";
4
+ import { denyFloorShadowsOf } from "./shell-read-judge.js";
5
+ import { movedReads } from "./shell-read-identity.js";
4
6
  const GLOB_METACHAR_RE = /[*?[]/;
5
7
  export const MAX_GLOB_DIRS = 256;
6
8
  export const MAX_GLOB_MATCHES = 8192;
@@ -94,7 +96,7 @@ function basesFor(candidate, boundary) {
94
96
  }
95
97
  function lexicalReadsOf(candidate, boundary) {
96
98
  if (candidate.expands === "unresolvable") {
97
- return [{ candidate, established: "unestablishable", spellings: [candidate.spelled, ...(candidate.floorSpelled !== undefined ? [candidate.floorSpelled] : [])], cause: `"${candidate.raw}" is rewritten by the shell before the program sees it` }];
99
+ return [{ candidate, established: "unestablishable", spellings: [candidate.spelled], cause: `"${candidate.raw}" is rewritten by the shell before the program sees it` }];
98
100
  }
99
101
  const bases = basesFor(candidate, boundary);
100
102
  if (bases === "unmodelled") {
@@ -130,7 +132,8 @@ function lexicalReadsOf(candidate, boundary) {
130
132
  return out;
131
133
  }
132
134
  function judgedCandidatesOf(segment) {
133
- return segment.reads.filter((c) => c.provenance !== "write_redirect_target");
135
+ const reads = segment.reads.filter((c) => c.provenance !== "write_redirect_target");
136
+ return [...reads, ...denyFloorShadowsOf(reads)];
134
137
  }
135
138
  export function establishShellReadsLexically(plan, boundary) {
136
139
  const reads = new Map();
@@ -424,38 +427,7 @@ export function createShellReadEstablisher(env, readRoots) {
424
427
  if (lexicalOnly)
425
428
  return { changed: [] };
426
429
  const again = await establishPlan(previous.plan, { ...boundary, roots: previous.roots }, previous.roots, signal);
427
- const settled = (e) => {
428
- const out = [];
429
- const walk = (p) => {
430
- for (const reads of p.reads.values())
431
- for (const r of reads)
432
- if ((r.established === "canonical" || r.established === "expansion") && r.target !== undefined)
433
- out.push(r.target);
434
- for (const n of p.nested.values())
435
- walk(n);
436
- };
437
- walk(e);
438
- return out.sort();
439
- };
440
- const before = settled(previous);
441
- const after = new Set(settled(again));
442
- const beforeSet = new Set(before);
443
- const moved = before.filter((t) => !after.has(t));
444
- const appeared = [...after].filter((t) => !beforeSet.has(t));
445
- if (moved.length === 0 && appeared.length === 0)
446
- return { changed: [] };
447
- const changed = [];
448
- const collect = (p) => {
449
- for (const reads of p.reads.values())
450
- for (const r of reads)
451
- if (r.target !== undefined && (moved.includes(r.target) || appeared.includes(r.target)))
452
- changed.push(r);
453
- for (const n of p.nested.values())
454
- collect(n);
455
- };
456
- collect(previous);
457
- collect(again);
458
- return { changed };
430
+ return { changed: movedReads(previous, again) };
459
431
  },
460
432
  };
461
433
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The pre-spawn identity comparison: which established reads of one plan resolve somewhere else now.
3
+ *
4
+ * Compared PER CANDIDATE, never as one pooled set of targets: a candidate is keyed by where it stands in the plan
5
+ * (segment, nested plan) and by what it is (deny-only or a read, provenance, spelling, expansion, raw word), and its
6
+ * set of established targets is compared with the same candidate's set from the re-establishment. A pooled set let
7
+ * one candidate's targets hide another's move — `head linkx` retargeted onto a path some other word's pattern already
8
+ * matched, or onto a home shadow's target, left the pool unchanged and the call was admitted unjudged.
9
+ */
10
+ import type { EstablishedPlan } from "./shell-read-establish.js";
11
+ import type { EstablishedRead } from "./shell-read-judge.js";
12
+ /** Every read of either establishment whose candidate's settled target set differs between the two. */
13
+ export declare function movedReads(previous: EstablishedPlan, again: EstablishedPlan): EstablishedRead[];
@@ -0,0 +1,39 @@
1
+ export function movedReads(previous, again) {
2
+ const before = targetsByCandidate(previous);
3
+ const after = targetsByCandidate(again);
4
+ const changed = [];
5
+ for (const key of new Set([...before.keys(), ...after.keys()])) {
6
+ const b = before.get(key);
7
+ const a = after.get(key);
8
+ if (b !== undefined && a !== undefined && sameTargets(b.targets, a.targets))
9
+ continue;
10
+ changed.push(...(b?.reads ?? []), ...(a?.reads ?? []));
11
+ }
12
+ return changed.filter((r) => r.target !== undefined);
13
+ }
14
+ function targetsByCandidate(e) {
15
+ const out = new Map();
16
+ const walk = (p, at) => {
17
+ p.plan.segments.forEach((segment, si) => {
18
+ for (const r of p.reads.get(segment) ?? []) {
19
+ const c = r.candidate;
20
+ const key = [at, si, c.denyOnly === true ? "floor" : "read", c.provenance, c.spelled, String(c.expands), c.raw].join("\0");
21
+ const landing = out.get(key) ?? { targets: new Set(), reads: [] };
22
+ out.set(key, landing);
23
+ landing.reads.push(r);
24
+ if ((r.established === "canonical" || r.established === "expansion") && r.target !== undefined)
25
+ landing.targets.add(r.target);
26
+ }
27
+ segment.nested.forEach((n, ni) => {
28
+ const nested = p.nested.get(n);
29
+ if (nested !== undefined)
30
+ walk(nested, `${at}/${si}.${ni}`);
31
+ });
32
+ });
33
+ };
34
+ walk(e, "");
35
+ return out;
36
+ }
37
+ function sameTargets(a, b) {
38
+ return a.size === b.size && [...a].every((t) => b.has(t));
39
+ }
@@ -173,6 +173,14 @@ export interface ReadFinding {
173
173
  * neither is a containment candidate — both still meet the deny judge, on every spelling.
174
174
  */
175
175
  export declare function judgeRead(read: EstablishedRead, boundary: ShellReadBoundary): ReadFinding;
176
+ /**
177
+ * The deny floor of every home word the plan does not place: each candidate carrying a `floorSpelled` (the word
178
+ * spelled with the DECLARED home) gets a SHADOW candidate at that spelling, established exactly as a read is — the
179
+ * canonical target, a pattern's matches (a glob the word carries unquoted), a traversal root's tree — and judged for
180
+ * deny rows only (`denyOnly`). A deny row is a floor the text's rebinding may never reach past; the shadow adds no
181
+ * other finding, so a read that is not a deny row still asks through its own unplaced candidate.
182
+ */
183
+ export declare function denyFloorShadowsOf(candidates: readonly ReadCandidate[]): ReadCandidate[];
176
184
  /** One segment's judged reads and declines, with its nested plans judged the same way. */
177
185
  export interface JudgedSegment {
178
186
  readonly segment: ShellReadSegment;
@@ -1,4 +1,6 @@
1
1
  import { hasWin32AliasedComponent, normalizeAbsPathLexically, withinAnyRoot } from "./safety.js";
2
+ import { globInWord } from "./bash-readonly-classifier.js";
3
+ import { readOfText } from "./shell-read-word.js";
2
4
  export const SHELL_READ_FINDINGS = ["deny_hit", "outside_roots", "unestablishable", "declined", "identity_changed", "none"];
3
5
  export const SHELL_READ_SEATS = ["bash_off", "bash_classify", "readonly", "rejudge"];
4
6
  export const SHELL_READ_FINDING_RANK = {
@@ -32,6 +34,8 @@ export function judgeRead(read, boundary) {
32
34
  }
33
35
  }
34
36
  const containment = containmentOf(read, boundary);
37
+ if (read.candidate.denyOnly === true)
38
+ return { read, kind: "none", containment };
35
39
  const provenance = read.candidate.provenance;
36
40
  if (provenance === "env_assignment" || (provenance === "program_path" && read.candidate.expands !== "unresolvable"))
37
41
  return { read, kind: "none", containment };
@@ -39,6 +43,9 @@ export function judgeRead(read, boundary) {
39
43
  return { read, kind: "unestablishable", containment };
40
44
  return { read, kind: containment === "outside" ? "outside_roots" : "none", containment };
41
45
  }
46
+ export function denyFloorShadowsOf(candidates) {
47
+ return candidates.flatMap((c) => (c.floorSpelled === undefined ? [] : [{ spelled: c.floorSpelled, raw: c.raw, provenance: c.provenance, expands: globInWord(readOfText(c.raw).word) ? "glob" : false, bases: [], denyOnly: true }]));
48
+ }
42
49
  function containmentOf(read, boundary) {
43
50
  if (boundary.face === "open")
44
51
  return "open";
@@ -65,6 +65,8 @@ export interface ReadCandidate {
65
65
  /** A home word the plan does not place (the text may rebind `HOME`, or it sits in a value): spelled with the DECLARED
66
66
  * home. A deny row is a floor: the judge reads it for deny rows ONLY, never to establish a read. */
67
67
  readonly floorSpelled?: string;
68
+ /** A deny-floor shadow of a `floorSpelled` spelling, established as a read and judged for deny rows only. */
69
+ readonly denyOnly?: true;
68
70
  }
69
71
  /** How the shell reached one segment — what the base-candidate walk needs from the connectors. */
70
72
  export interface SegmentReach {
@@ -123,4 +125,6 @@ export interface ShellReadPlan {
123
125
  readonly strayRedirection: boolean;
124
126
  };
125
127
  }
126
- export declare function planShellReads(command: string, decl: ShellReadDeclarations, depth?: number, homeRebound?: boolean): ShellReadPlan;
128
+ /** Plan every read candidate of one command (the contract is this module's header). `arithmetic` = the text is an
129
+ * `$((…))` body, which bash evaluates and never globs (no extended-glob floor). */
130
+ export declare function planShellReads(command: string, decl: ShellReadDeclarations, depth?: number, homeRebound?: boolean, arithmetic?: boolean): ShellReadPlan;
@@ -1,5 +1,5 @@
1
- import { lexShell, PARSE_FAILURE_SENTENCE, UNRESOLVED_SENTENCE, substitutionsInHeredocBody, literalTildeSpelling, } from "../../shell/lex.js";
2
- import { compoundArrayElements, dropDefinitionHeads, expandsOf, readOfText, readsOf, rewritten, spelledWithHome } from "./shell-read-word.js";
1
+ import { lexShell, PARSE_FAILURE_SENTENCE, substitutionsInHeredocBody, literalTildeSpelling, } from "../../shell/lex.js";
2
+ import { compoundArrayElements, dropDefinitionHeads, expandsOf, readOfText, readsOf, rewritten, spelledWithHome, isArithmeticBody, unreadableSentenceOf } from "./shell-read-word.js";
3
3
  import { LAUNCHER_TABLE, SHELL_BLOCK_WORDS, programBasename, programPositionRunsShell, readProgramPosition, shellScanDeclineOf, } from "./bash-program-position.js";
4
4
  import { ASSIGNMENT_VALUE_NOT_PATHS, INTERPRETER_INLINE_PROGRAMS, NO_PATH_OPERAND_COMMANDS, POLL_LOOP_MAX_BEATS, RECURSIVE_READ_FORMS, SHELL_REENTRY_PROGRAM_RE, UNMODELLED_BASE_MOVERS, argvProgramOf, homeMayBeRebound, attachedOptionPayloads, resolveOperandLexically, inlineProgramClause, interpreterKey, isPathShapedToken, readVerbOptionSlot, sedScriptSlotFiles, segmentSelectsRecursiveRead, slotValueCandidates, suppliesFirstOperandSlot, verbFirstOperandSlot, verbOptionsArePositionIndependent, } from "./bash-readonly-classifier.js";
5
5
  import { isAbsolutePathForm } from "./safety.js";
@@ -18,17 +18,7 @@ export const READ_CANDIDATE_PROVENANCES = [
18
18
  ];
19
19
  const NESTING_MAX = 4;
20
20
  const BASE_CANDIDATE_MAX = 256;
21
- function firstUnresolvedSentence(lexed) {
22
- for (const segment of lexed.segments) {
23
- for (const token of segment.tokens) {
24
- const word = token.kind === "word" ? token : token.kind === "redirect" ? token.target : undefined;
25
- if (word?.unresolved !== undefined)
26
- return UNRESOLVED_SENTENCE[word.unresolved];
27
- }
28
- }
29
- return undefined;
30
- }
31
- export function planShellReads(command, decl, depth = 0, homeRebound = false) {
21
+ export function planShellReads(command, decl, depth = 0, homeRebound = false, arithmetic = false) {
32
22
  const shape = { grouped: false, backgrounded: false, strayRedirection: false };
33
23
  if (depth > NESTING_MAX)
34
24
  return { segments: [], unreadable: `command text nested more than ${NESTING_MAX} levels deep`, shape };
@@ -36,8 +26,8 @@ export function planShellReads(command, decl, depth = 0, homeRebound = false) {
36
26
  if ("parseFailure" in lexed)
37
27
  return { segments: [], unreadable: PARSE_FAILURE_SENTENCE[lexed.parseFailure.reason], shape };
38
28
  const home = homeRebound || homeMayBeRebound(command) ? undefined : decl.homeDir;
39
- const unreadable = firstUnresolvedSentence(lexed);
40
- const nestedPlan = (text) => planShellReads(text, decl, depth + 1, home === undefined);
29
+ const unreadable = unreadableSentenceOf(lexed, command, !arithmetic);
30
+ const nestedPlan = (text, arithmetic = false) => planShellReads(text, decl, depth + 1, home === undefined, arithmetic);
41
31
  const projected = lexed.segments.map((segment) => projectSegment(segment));
42
32
  const facts = projected.map((p, si) => segmentFacts(lexed.segments[si], p, decl, home, nestedPlan));
43
33
  shape.grouped = projected.some((p) => p.grouped);
@@ -86,7 +76,7 @@ export function planShellReads(command, decl, depth = 0, homeRebound = false) {
86
76
  if (move === undefined || baseLost)
87
77
  return;
88
78
  if (typeof move.target === "string" || move.target.expands !== false) {
89
- loseBase(reachable, typeof move.target === "string" ? undefined : move.target.floorSpelled);
79
+ loseBase(reachable, typeof move.target !== "string" ? move.target.floorSpelled : move.target === "home" ? decl.homeDir : undefined);
90
80
  return;
91
81
  }
92
82
  const target = move.target.spelled;
@@ -214,8 +204,10 @@ function segmentFacts(segment, p, decl, home, nestedPlan) {
214
204
  }
215
205
  function segmentFactsRead(segment, p, decl, home, nestedPlan) {
216
206
  const nested = [];
207
+ const arithmetic = segment.tokens.flatMap((t) => (t.kind === "word" ? t : t.kind === "redirect" ? t.target : undefined)?.pieces ?? []).flatMap((q) => (q.expansion?.kind === "arithmetic" && q.expansion.body !== undefined && isArithmeticBody(q.expansion.body) ? [q.expansion.body] : []));
208
+ const count = (list, body) => list.filter((b) => b === body).length;
217
209
  for (const body of segment.substitutions)
218
- nested.push(nestedPlan(body));
210
+ nested.push(nestedPlan(body, count(arithmetic, body) === count(segment.substitutions, body)));
219
211
  let unreadableHeredoc = false;
220
212
  for (const t of segment.tokens) {
221
213
  if (t.kind !== "heredoc" || t.quoted)
@@ -225,8 +217,8 @@ function segmentFactsRead(segment, p, decl, home, nestedPlan) {
225
217
  unreadableHeredoc = true;
226
218
  continue;
227
219
  }
228
- for (const body of inner)
229
- nested.push(nestedPlan(body));
220
+ for (const s of inner)
221
+ nested.push(nestedPlan(s.body, s.kind === "arithmetic" && isArithmeticBody(s.body)));
230
222
  }
231
223
  if (unreadableHeredoc)
232
224
  nested.push({ segments: [], unreadable: "a here-document body carries a substitution this lexer cannot delimit", shape: { grouped: false, backgrounded: false, strayRedirection: false } });
@@ -470,7 +462,7 @@ function dedupe(reads) {
470
462
  const seen = new Set();
471
463
  const out = [];
472
464
  for (const c of reads) {
473
- const key = `${c.provenance}\u0000${c.spelled}\u0000${c.expands}`;
465
+ const key = `${c.provenance}\u0000${c.spelled}\u0000${c.expands}\u0000${c.denyOnly === true}`;
474
466
  if (seen.has(key))
475
467
  continue;
476
468
  seen.add(key);
@@ -515,7 +507,7 @@ function pushAssignment(reads, w, provenance, opensValue = true) {
515
507
  reads.push({ spelled: named, raw: w.raw, provenance, expands: expandsOf(w) });
516
508
  if (a.form === "compound")
517
509
  for (const e of compoundArrayElements(a.value))
518
- reads.push({ spelled: e.folded, raw: e.raw, provenance, expands: e.expands });
510
+ reads.push({ spelled: e.folded, raw: e.raw, provenance, expands: e.expands, ...(e.denyOnly === true ? { denyOnly: true } : {}) });
519
511
  }
520
512
  function unlistedWords(reads, toks, from, launcherIndices, provenance, program) {
521
513
  const argv = program !== undefined ? { program: program.basename, from: program.index } : argvProgramOf(toks.map((r) => r.word));
@@ -4,7 +4,7 @@
4
4
  * (the home-directory word spelled with the declared home, as bash hands it to the program), and what the shell
5
5
  * does to the word before the program sees it.
6
6
  */
7
- import { type LexedToken, type LexedWord, type WordPiece } from "../../shell/lex.js";
7
+ import { type LexedCommand, type LexedToken, type LexedWord, type WordPiece } from "../../shell/lex.js";
8
8
  /** `"glob"` = an unquoted pathname-expansion character; `"unresolvable"` = the shell rewrites the word (a brace,
9
9
  * variable or command substitution) so its text is not the path. The read candidate's own field, spelled here. */
10
10
  export type WordExpands = false | "glob" | "unresolvable";
@@ -35,13 +35,27 @@ export declare function spelledWithHome(spelled: string, raw: string, home: stri
35
35
  * spelling. The lexer puts the body's compound command back at command position after the head, so a definition
36
36
  * nested in a body is dropped the same way; after a drop the token that moved up is looked at again. */
37
37
  export declare function dropDefinitionHeads(tokens: LexedToken[]): void;
38
+ /** Why a lexed text's reads cannot all be named — the plan's `unreadable` floor, or `undefined`: the first word whose
39
+ * value the text does not contain (an unresolved word), or an unquoted extended-glob opener anywhere a word, a
40
+ * redirection target or a here-document delimiter stands (`pack@(age.json)`, `<<@(x)`). After `shopt -s extglob`
41
+ * bash expands the pattern (or reads another delimiter) where the lexer reads a split word, so what the text opens is
42
+ * not what the plan names: the whole text asks, and a deny hit anywhere still refuses. `globs: false` = the text is an
43
+ * arithmetic expression (`$((2*(3+4)))`), which bash evaluates and never globs. */
44
+ export declare function unreadableSentenceOf(lexed: LexedCommand, text: string, globs?: boolean): string | undefined;
38
45
  /** The elements of a compound array value `(e1 [k]=e2 …)`, read with the one lexer as the words bash assigns (a
39
46
  * `[k]=` subscript prefix dropped): each is a value spelling the deny judge reads, as a scalar value is. The list is
40
47
  * words across lines and comments, so every segment's words count, whatever else a segment carries (a word bash
41
48
  * would reject leaves its siblings readable). None when the list does not lex — the value stays the unread list. */
42
49
  export declare function compoundArrayElements(value: {
43
50
  readonly raw: string;
44
- }): readonly Read[];
51
+ }): readonly (Read & {
52
+ readonly denyOnly?: true;
53
+ })[];
54
+ /** An `$((…))` body the shell reads as ARITHMETIC: one parenthesised group spanning the whole body (`(2*(3+4))`),
55
+ * its bounds read off the lexer's structure — a quoted, escaped or commented parenthesis is text, not a bound — across
56
+ * the segments the lexer cuts at `&&` `||` `&` `|` and line breaks (operators arithmetic shares with the shell).
57
+ * `$((cat x); :)` opens the same way but its first group closes early: a command substitution holding a subshell. */
58
+ export declare function isArithmeticBody(body: string): boolean;
45
59
  /** What the shell does to a word (or an assignment's value) before the program sees it, read off its pieces. */
46
60
  export declare function expandsOf(w: {
47
61
  readonly pieces: readonly WordPiece[];
@@ -1,5 +1,5 @@
1
- import { argvOf, lexShell, literalTildeSpelling } from "../../shell/lex.js";
2
- import { expansionInWord, globInWord, homeExpansionOf } from "./bash-readonly-classifier.js";
1
+ import { argvOf, lexShell, literalTildeSpelling, UNRESOLVED_SENTENCE } from "../../shell/lex.js";
2
+ import { expansionInWord, extglobOpenerIn, globInWord, homeExpansionOf } from "./bash-readonly-classifier.js";
3
3
  export function readsOf(words, home) {
4
4
  return words.map((word) => {
5
5
  const spelled = home === undefined ? undefined : homeExpansionOf(word, home)?.spelled;
@@ -39,17 +39,52 @@ export function dropDefinitionHeads(tokens) {
39
39
  tokens.splice(k, 3);
40
40
  }
41
41
  }
42
+ export function unreadableSentenceOf(lexed, text, globs = true) {
43
+ for (const segment of lexed.segments) {
44
+ for (const token of segment.tokens) {
45
+ const word = token.kind === "word" ? token : token.kind === "redirect" ? token.target : undefined;
46
+ if (word?.unresolved !== undefined)
47
+ return UNRESOLVED_SENTENCE[word.unresolved];
48
+ }
49
+ }
50
+ return globs && extglobOpenerIn(lexed, text) ? "an extended pattern (`?(…)` `*(…)` `+(…)` `@(…)` `!(…)`) is one this reader does not expand" : undefined;
51
+ }
42
52
  export function compoundArrayElements(value) {
43
53
  const inner = /^\(([\s\S]*)\)$/.exec(value.raw)?.[1];
44
54
  const l = inner === undefined ? undefined : lexShell(inner);
45
55
  if (l === undefined || "parseFailure" in l)
46
56
  return [];
47
- return l.segments.flatMap((sg) => argvOf(sg)).map((word) => {
48
- const folded = word.folded.replace(/^\[[^\]]*\]\+?=/, "");
57
+ return l.segments.flatMap((sg) => argvOf(sg)).flatMap((element) => {
58
+ const sub = /^\[[^\]]*\]\+?=/.exec(element.raw)?.[0];
59
+ const value = sub === undefined ? undefined : readOfText(`V=${element.raw.slice(sub.length)}`).word.assignment?.value;
60
+ const word = value === undefined ? element : { ...element, raw: value.raw, folded: value.folded, pieces: value.pieces };
49
61
  const tilde = word.pieces.some((q) => q.expansion?.kind === "tilde");
50
- return { word, raw: word.raw, folded: tilde ? folded : literalTildeSpelling(folded), expands: expandsOf(word) };
62
+ const read = { word, raw: word.raw, folded: tilde ? word.folded : literalTildeSpelling(word.folded), expands: expandsOf(word) };
63
+ const prefix = /^\[[^\]]*\]\+?=/.exec(element.folded)?.[0];
64
+ if (prefix === undefined)
65
+ return [read];
66
+ const spelled = element.pieces.some((q) => q.expansion?.kind === "tilde") ? element.folded.slice(prefix.length) : literalTildeSpelling(element.folded.slice(prefix.length));
67
+ return [read, { word: element, raw: element.raw, folded: spelled, expands: expandsOf(element), denyOnly: true }];
51
68
  });
52
69
  }
70
+ export function isArithmeticBody(body) {
71
+ const l = body.startsWith("(") && body.endsWith(")") ? lexShell(body) : undefined;
72
+ if (l === undefined || "parseFailure" in l)
73
+ return false;
74
+ const tokens = l.segments.flatMap((sg) => sg.tokens);
75
+ if (tokens[0]?.kind !== "group" || tokens[0].op !== "(")
76
+ return false;
77
+ let depth = 0;
78
+ for (const [i, t] of tokens.entries()) {
79
+ if (t.kind !== "group")
80
+ continue;
81
+ if (t.op === "(")
82
+ depth++;
83
+ else if (--depth === 0)
84
+ return i === tokens.length - 1;
85
+ }
86
+ return false;
87
+ }
53
88
  export function expandsOf(w) {
54
89
  if (expansionInWord(w) || /[`()]/.test(w.raw))
55
90
  return "unresolvable";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.30.0",
3
+ "version": "7.30.1",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -0,0 +1,154 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.30.1",
4
+ "from": "3a100146dad04ac157def06e16d624fc8c1fe6a1",
5
+ "to": "v7.30.1",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [],
9
+ "narrowed": [
10
+ {
11
+ "id": "F1",
12
+ "source": "behaviorFlips.summary",
13
+ "text": "NARROWING (#1064 ⑨, shell read boundary): an unquoted extended-glob opener (`?(` `*(` `+(` `@(` `!(`) in an argv word, a redirection target or a here-document delimiter makes the WHOLE text unestablishable — it joins the plan's `unreadable` floor, so both full-shell seats raise one MANDATED card, the read-only face and every headless face refuse, and a deny hit elsewhere in the same text still refuses outright. An `$((…))` arithmetic body keeps its reading only when the whole body is ONE complete parenthesised group, read off the lexer's structure (a quoted parenthesis is text) and followed across the segments the lexer cuts at `&&` `||` `&` `|` and line breaks; a quoted opener and a here-document body are the text they always were."
14
+ },
15
+ {
16
+ "id": "F2",
17
+ "source": "behaviorFlips.summary",
18
+ "text": "NARROWING (#1064 ⑬, shell read boundary; the CHANGELOG reads it as the repair of a 7.30.0 regression against 7.29.0 on the answered seats — 7.29.0 is not measured here): the declared-home spelling of an unplaced home word becomes a deny-only SHADOW candidate (`ReadCandidate.denyOnly`) established exactly as a read is — canonical target, a pattern's matches, a traversal root's tree — and judged for deny rows only; a bare `cd` / `cd --` puts the declared home on the deny floor; a `[k]=` compound element is read as the value bash assigns. The pre-spawn identity re-check compares each candidate's targets with its own re-establishment (keyed by plan position and candidate identity, deny-only reads their own class), and candidate de-duplication keys on `denyOnly`."
19
+ },
20
+ {
21
+ "id": "F3",
22
+ "source": "behaviorFlips.summary",
23
+ "text": "NARROWING (#1072, spawned-environment scrub): `SECRET_ENV_RE` reads `KEYS`, `TOKENS` and `PASSWORDS` as it reads their singulars, so `SERVICE_AUTH_TOKENS`, `API_KEYS`, `DB_PASSWORDS`, `GITHUB_TOKENS` no longer reach a tool's child shell; a `TOKENS` right after a count word (`TOKEN_COUNT_WORDS`: MAX OUTPUT INPUT THINKING NUM TOTAL) is a token count and is kept. The singular `TOKEN` and the exact-name exemptions keep their 7.30.0 reading."
24
+ },
25
+ {
26
+ "id": "F4",
27
+ "source": "behaviorFlips.summary",
28
+ "text": "NARROWING + additive option (#1073, language-server environment): `defaultLspSpawn` passes `scrubSecretEnv(process.env)` to `child_process.spawn` instead of no `env` at all, so a language server no longer inherits credential-shaped host variables; `NodeLspManagerOptions.env?` (new, optional) is the server's environment used VERBATIM by the manager's default spawn. A host-supplied `spawn` still owns its child's environment. The LSP scrub reports nothing (no findings collector)."
29
+ }
30
+ ],
31
+ "closedSetMembers": [],
32
+ "closedSetsUnreadable": [],
33
+ "typeKeys": [
34
+ {
35
+ "type": "NodeLspManagerOptions",
36
+ "at": [
37
+ "src/engine/lsp/node-lsp-manager.ts:76"
38
+ ],
39
+ "added": [
40
+ "env"
41
+ ],
42
+ "addedDetail": [
43
+ {
44
+ "key": "env",
45
+ "optional": true
46
+ }
47
+ ],
48
+ "addedRequired": [],
49
+ "removed": [],
50
+ "breaking": false,
51
+ "criteriaCovered": true
52
+ },
53
+ {
54
+ "type": "ReadCandidate",
55
+ "at": [
56
+ "src/tools/fs/shell-read-plan.ts:104"
57
+ ],
58
+ "added": [
59
+ "denyOnly"
60
+ ],
61
+ "addedDetail": [
62
+ {
63
+ "key": "denyOnly",
64
+ "optional": true
65
+ }
66
+ ],
67
+ "addedRequired": [],
68
+ "removed": [],
69
+ "breaking": false,
70
+ "criteriaCovered": true
71
+ }
72
+ ],
73
+ "typeKeysUnreadableArms": [],
74
+ "typeKeysFlattenedCount": 215,
75
+ "declarationMoves": [],
76
+ "mintMoves": [],
77
+ "criteriaObserves": [
78
+ {
79
+ "kind": "taskEventField",
80
+ "name": "tool_end.gate",
81
+ "assertions": [
82
+ "G1.a",
83
+ "G1.b",
84
+ "G2.a"
85
+ ]
86
+ },
87
+ {
88
+ "kind": "taskEventField",
89
+ "name": "tool_end.isError",
90
+ "assertions": [
91
+ "G1.b",
92
+ "G3.a"
93
+ ]
94
+ },
95
+ {
96
+ "kind": "taskEventField",
97
+ "name": "tool_end.output",
98
+ "assertions": [
99
+ "G1.b",
100
+ "G2.a",
101
+ "G3.a",
102
+ "G4.a",
103
+ "G4.b"
104
+ ]
105
+ },
106
+ {
107
+ "kind": "taskEventField",
108
+ "name": "tool_end.structured",
109
+ "assertions": [
110
+ "G1.b",
111
+ "G2.a",
112
+ "G3.a"
113
+ ]
114
+ },
115
+ {
116
+ "kind": "typeMember",
117
+ "name": "AskRequest.mandated",
118
+ "assertions": [
119
+ "G1.a",
120
+ "G2.b",
121
+ "G3.a"
122
+ ]
123
+ },
124
+ {
125
+ "kind": "typeMember",
126
+ "name": "NodeLspManagerOptions.env",
127
+ "assertions": [
128
+ "G5.a",
129
+ "G5.b"
130
+ ]
131
+ },
132
+ {
133
+ "kind": "typeMember",
134
+ "name": "ReadCandidate.denyOnly",
135
+ "assertions": [
136
+ "G2.b",
137
+ "G3.b"
138
+ ]
139
+ }
140
+ ],
141
+ "consumersHint": {
142
+ "cli": [
143
+ "ReadCandidate"
144
+ ],
145
+ "server": [
146
+ "ReadCandidate"
147
+ ],
148
+ "sdk": [],
149
+ "note": "scanned cli@4e593722 server@fa3e0b9b sdk@350f64f — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it. Ledger files (**/docs/core-consumption/**, **/docs/wire-consumption/**, **/*LEDGER*.json) are not evidence of use — server: 1 name(s) matched ONLY in bookkeeping files and were set aside (NodeLspManagerOptions)"
150
+ },
151
+ "breakingCount": 0,
152
+ "breakingItems": [],
153
+ "toResolvedAt": "0034a646af1de9c0cdd8ad7bd9b56234ec527c2c"
154
+ }