@sema-agent/core 7.17.2 → 7.17.3

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.17.3 — 2026-09-13
4
+
5
+ ### Narrowing — "not on the read-only allowlist" is not "reads no path": the read boundary judges every segment (#758; test [7138]; @server @cli @test)
6
+ - **Before.** The read boundary judged the path operands of a shell segment only when that segment's PROGRAM was on the read-only allowlist. Any other program's operands were not judged at all — not for containment, not against the sensitive-path deny table — so an out-of-root read and a deny-row read both ran with ZERO asks under `shellGate` absent AND `"classify"`, while the same read spelled `cat` was one mandated ask. Measured on the real load path (Runner prepare → NodeExecutionEnv → the gate): `strings <outside>`, `nl <outside>`, `od -c <outside>`, `rev`, `file`, and `sort ~/.ssh/id_rsa` — asks=0, command executed, both doctrines. A name table was doing double duty as a claim about behaviour, and the set it silenced is the one set a name table cannot enumerate.
7
+ - **After (one rule, three sites).** A segment whose program is not a listed reader is judged WHOLE and VERB-BLIND: every token from the program onward is offered to the boundary as a path candidate, under a head with no per-verb behaviour of its own. That is the form the containment face already used for LAUNCHER segments (`env cat <outside>`), promoted to the general rule — so the launcher special case disappears into it rather than being joined by a second one. Three sites carried the old assumption and all three now read the same way: the boundary walk of `classifyCompoundReadonlyDetailed` (which alone can answer for an in-root DENY row — the containment face deliberately drops the deny judge), `classifyOutOfRootReadGate` (which alone answers for a command the compound face refuses WHOLE), and the refused-command whitelist `refusedCommandExempt`, whose `!allow.has(head)` disjunct read an unlisted head as "a program that reads no path" and exempted it. Verb-blind is load-bearing in the safe direction: a per-verb table can silence a whole segment, skip a slot or move the base, and a program the deployment never declared must borrow none of that.
8
+ - **Behaviour, for consumers — the RANGE, not a footnote.** Under BOTH doctrines a shell command whose program is not on the reader table now asks ONCE, mandated, when any of its words resolves outside the roots or onto the deny table, or is a word the walk could not read at all (an expansion, a subshell/glob/brace character, a `~` with no declared home). Every word, not only the path-shaped ones: a deny row names bare filenames (`.npmrc`, `.env`) and an expansion names what the text does not contain. A stored allow rule is disclosed as shadowed and does not clear it. **Under the default doctrine every one of these ran with ZERO asks before**, so on a headless deployment — no approver wired — the new card is a REFUSAL, not a prompt: `node -e "console.log(1)"`, `python3 -c "print(1)"`, `npm run "$SCRIPT"`, `npm install /opt/pkg`, `git -C /outside status`, `curl -o <outside> …`, `rm -rf /etc`, `touch <outside>`. The floor is unchanged and pinned: `npm test`, `timeout 300 npm test`, `env FOO=1 npm test`, `git status`, `npm run build`, `node scripts/build.mjs`, `python3 -m pytest tests/`, `docker ps`, `make`, `sort data.txt`, `curl https://…` keep their zero-ask verdict. **Stated over-fire** (the price of not carrying per-program option tables, which this file refuses to start doing): a token that is a WRITE target rather than a read gates too; a quoted literal that spells an out-of-root path gates even where the program only prints it (`printf '/etc/hosts'`); a program run by absolute path from outside the roots gates on its own path (`sudo /bin/cat <in-root>`, unchanged); an unfollowable base move now gates in its operand-less form as well (`pushd <anywhere>`); a refused-whole command carrying a line continuation, an escaped shell operator (`\>`), or a `#` alongside a quote character is a command the refused-command whitelist's positional splitter does not read the way bash does, and gets no exemption (one predicate, `whitelistReadsTheSameWords`, and one open design item behind all three — the exemption is a second hand-written parser beside the compound lexer, #753); and a non-canonical spelling of an in-root path (a symlinked `/var` → `/private/var` prefix) reads as out-of-root for an unlisted program exactly as it already did for a listed one. @server @cli: expect one more mandated-ask source on both tiers, and on a headless lane expect refusals where commands used to run — a probe that pinned zero asks for an unlisted program naming an absolute path outside the workspace, or carrying a `-e`/`-c` program body, reds. @test: the class criterion is a synthetic name (`zzunlisted`), not a tool list — a verification that enumerates names repeats the defect.
9
+ - **Known residual, NOT closed here (a different class, pre-existing, and it is not keyed on the allowlist).** An option's ATTACHED payload is offered as a path candidate only when it is path-shaped, so a bare filename attached to a short or long option escapes the boundary walk for EVERY head, listed or not: `grep -f.npmrc`, `grep --file=.npmrc` and `awk -f.npmrc` are as silent as `base64 -i.npmrc`, while their separated (`-f .npmrc`) and path-shaped (`-i./.npmrc`) spellings mandate. Measured on this tree; filed rather than folded in, because the fix moves every head's candidate set and belongs with its own pins.
10
+ - **Also.** `CompoundReadonlyVerdict.outOfRootRead` is now carried by a NAME-refused compound too (`rm -rf /etc`); the grant-offer reading is unchanged and remains the containment SENTENCE's, never the member's — a name-refused verdict's sentence is the name refusal, so no "allow reading from …" option is offered for it. The governed-agent example canonicalizes its temp workspace at the one minting point (a demo handing an unresolved workspace to an engine that canonicalizes its roots made every absolute operand read as out-of-root).
11
+ - Pins: `test/backlog758-unlisted-reader-boundary.test.ts` (the real load path, two doctrines × out-of-root and in-root deny row × mandated-ask and zero-ask controls, on a synthetic unlisted name with real tools as corroboration; the over-fire floor at the seat face; the launcher promises unmoved; the third site's exempt/may-read sets with and without a wired deny judge; the `bash_readonly` face untouched; both mutation directions measured in the header), `test/backlog696-read-boundary-any-doctrine.test.ts` (appended unlisted-program rows on both seats), `test/backlog502-out-of-root-read-mandate.test.ts` / `test/rb412-bash-readonly-path-boundary.test.ts` / `test/rb474-glob-verdict-undecided.test.ts` (five cells re-pinned, each stating whether the old answer was a correct silence or the defect).
12
+
3
13
  ## 7.17.2 — 2026-09-13
4
14
 
5
15
  ### Fix — the directories the prompt advertises as writable feed the write gate's exemption through the request (#749; cli B-102; server S-261; @server @cli)
@@ -493,13 +493,16 @@ export declare const COMMAND_LAUNCHERS_FOR_TEST: ReadonlySet<string>;
493
493
  * reason for a demotion has one predicate to read.
494
494
  *
495
495
  * What it judges and what it does not:
496
- * · SEGMENTS whose PROGRAM is in `allow` — the deployment's own read-command table, the analogue of
497
- * the reference implementation's path-restricted command list. The program is read after the two
498
- * decorations the shell itself strips before choosing it (leading `NAME=value` assignments, a path
499
- * prefix), so `/bin/cat` is judged as `cat`: recognizing readers by their literal spelling alone let
500
- * a rule the persisted grammar admits (`Bash(/bin/cat:*)`) carry the exact read this gate exists for.
501
- * Every other segment is skipped, so this face never invents a path judgement for a verb the
502
- * deployment never declared readable.
496
+ * · EVERY segment, under one of two readings. A segment whose PROGRAM is in `allow` — the deployment's
497
+ * own read-command table, the analogue of the reference implementation's path-restricted command
498
+ * list is read with that verb's own slots (a pattern operand, a recursive form). The program is
499
+ * read after the two decorations the shell itself strips before choosing it (leading `NAME=value`
500
+ * assignments, a path prefix), so `/bin/cat` is judged as `cat`: recognizing readers by their literal
501
+ * spelling alone let a rule the persisted grammar admits (`Bash(/bin/cat:*)`) carry the exact read
502
+ * this gate exists for. Every OTHER segment is judged whole and verb-blind — its tokens are offered
503
+ * as path candidates under a head with no per-verb behaviour — because a program not on the table is
504
+ * not a program known to read nothing: skipping it made the gate silent for every reader the
505
+ * deployment had not enumerated, which is the one thing a name table cannot enumerate.
503
506
  * · `cd` is judged whether or not the deployment listed it, because the walk MODELS it (it threads the
504
507
  * base across segments) and dropping it would place every later relative operand against a base bash
505
508
  * has already left. A move the walk cannot model (`pushd`/`popd`) is not silently tolerated either —
@@ -422,6 +422,8 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
422
422
  const stripped = withoutHeredocBodies(command);
423
423
  if (stripped === undefined)
424
424
  return false;
425
+ if (!whitelistReadsTheSameWords(command, stripped.outer))
426
+ return false;
425
427
  const split = splitSubstitutions(stripped.outer);
426
428
  if (split === undefined)
427
429
  return false;
@@ -515,7 +517,7 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
515
517
  return false;
516
518
  }
517
519
  const stdinFileJudgedInside = (raw) => {
518
- if (raw === undefined || raw === "" || raw.includes(SUBSTITUTION_PLACEHOLDER) || /[$`()*?[\]{}~]/.test(raw))
520
+ if (raw === undefined || raw === "" || raw.includes(SUBSTITUTION_PLACEHOLDER) || UNREADABLE_WHITELIST_WORD.test(raw))
519
521
  return false;
520
522
  if (boundary === undefined || baseMoved)
521
523
  return false;
@@ -524,6 +526,41 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
524
526
  return false;
525
527
  return withinAnyRoot(boundary.roots, resolved);
526
528
  };
529
+ const unlistedSegmentJudgedInside = (toks, from) => {
530
+ if (boundary === undefined)
531
+ return true;
532
+ let endOfOptions = false;
533
+ for (let j = from; j < toks.length; j++) {
534
+ const t = toks[j];
535
+ if (t === "<<<" || t === "<<" || t === "<") {
536
+ j++;
537
+ continue;
538
+ }
539
+ if (/^(?:>>?|&>>?)$/.test(t)) {
540
+ j++;
541
+ continue;
542
+ }
543
+ if (/^(?:>>?|<)&\d*$/.test(t))
544
+ continue;
545
+ const word = fold(t);
546
+ if (j > from && !endOfOptions && word === "--") {
547
+ endOfOptions = true;
548
+ continue;
549
+ }
550
+ if (j === from && !isPathShapedToken(word)) {
551
+ if (UNREADABLE_WHITELIST_WORD.test(word) || word.includes(SUBSTITUTION_PLACEHOLDER))
552
+ return false;
553
+ continue;
554
+ }
555
+ const optionShaped = !endOfOptions && word.startsWith("-") && word !== "-";
556
+ const candidates = optionShaped ? [...attachedOptionPayloads(word), ...(isPathShapedToken(word) ? [word] : [])] : [word];
557
+ for (const candidate of candidates) {
558
+ if (!stdinFileJudgedInside(candidate))
559
+ return false;
560
+ }
561
+ }
562
+ return true;
563
+ };
527
564
  for (const toks of segments) {
528
565
  for (let j = 0; j < toks.length; j++)
529
566
  if (toks[j] === "<" && !stdinFileJudgedInside(toks[j + 1]))
@@ -542,8 +579,11 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
542
579
  const unwrapped = unwrapLauncher(toks.slice(i).map(fold), allow);
543
580
  if (unwrapped.kind === "refused")
544
581
  return false;
545
- if (unwrapped.kind === "unlisted")
582
+ if (unwrapped.kind === "unlisted") {
583
+ if (!unlistedSegmentJudgedInside(toks, i))
584
+ return false;
546
585
  continue;
586
+ }
547
587
  head = unwrapped.head;
548
588
  i += unwrapped.index;
549
589
  }
@@ -551,7 +591,12 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
551
591
  return false;
552
592
  if (head === "cd" || UNMODELLED_BASE_MOVERS.has(head))
553
593
  continue;
554
- if (!allow.has(head) || NO_PATH_OPERAND_COMMANDS.has(head))
594
+ if (!allow.has(head)) {
595
+ if (!unlistedSegmentJudgedInside(toks, i))
596
+ return false;
597
+ continue;
598
+ }
599
+ if (NO_PATH_OPERAND_COMMANDS.has(head))
555
600
  continue;
556
601
  const flags = STDIN_ONLY_READER_FLAGS.get(head);
557
602
  if (flags === undefined)
@@ -1243,8 +1288,13 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1243
1288
  const foldedSegments = [];
1244
1289
  let shapeRefusal = nameRefusal;
1245
1290
  for (let si = 0; si < segments.length; si++) {
1246
- if (unlistedSegments.has(si))
1291
+ if (unlistedSegments.has(si)) {
1292
+ const unlisted = tokenizeSegment(segments[si]);
1293
+ if (unlisted.folded.length === 0)
1294
+ continue;
1295
+ foldedSegments.push({ folded: [LAUNCHER_SCAN_HEAD, ...unlisted.folded], raw: [LAUNCHER_SCAN_HEAD, ...unlisted.raw] });
1247
1296
  continue;
1297
+ }
1248
1298
  const rehead = reheaded.get(si);
1249
1299
  const segmentTokens = rehead === undefined ? tokenizeSegment(segments[si]) : reheadSegment(tokenizeSegment(segments[si]), rehead.head, rehead.from);
1250
1300
  const toks = segmentTokens.folded;
@@ -1439,6 +1489,14 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
1439
1489
  }
1440
1490
  const NOT_GATED = { gated: false, paths: [] };
1441
1491
  const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
1492
+ const UNREADABLE_WHITELIST_WORD = /[$`()*?[\]{}~]/;
1493
+ function whitelistReadsTheSameWords(command, outsideHeredocBodies) {
1494
+ if (/\\\r?\n/.test(command))
1495
+ return false;
1496
+ if (/\\[<>&|;()]/.test(outsideHeredocBodies))
1497
+ return false;
1498
+ return !(outsideHeredocBodies.includes("#") && /["']/.test(outsideHeredocBodies));
1499
+ }
1442
1500
  const UNMODELLED_BASE_MOVERS = new Set(["pushd", "popd"]);
1443
1501
  const COMMAND_LAUNCHERS = new Set([
1444
1502
  "env", "command", "builtin", "exec", "noglob", "xargs",
@@ -1476,14 +1534,12 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
1476
1534
  if (written === undefined)
1477
1535
  continue;
1478
1536
  const name = programBasename(written);
1479
- if (!allow.has(name) && COMMAND_LAUNCHERS.has(name)) {
1480
- readers.push({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
1481
- continue;
1482
- }
1483
1537
  if (UNMODELLED_BASE_MOVERS.has(name))
1484
1538
  baseMoved = true;
1485
- if (!allow.has(name) && name !== "cd")
1539
+ if (!allow.has(name) && name !== "cd") {
1540
+ readers.push({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
1486
1541
  continue;
1542
+ }
1487
1543
  readers.push(i === 0 && name === written ? toks : { folded: [name, ...toks.folded.slice(i + 1)], raw: [toks.raw[i] ?? name, ...toks.raw.slice(i + 1)] });
1488
1544
  }
1489
1545
  if (readers.length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.17.2",
3
+ "version": "7.17.3",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",