@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
|
-
* ·
|
|
497
|
-
* the reference implementation's path-restricted command
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
* a
|
|
501
|
-
*
|
|
502
|
-
*
|
|
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) ||
|
|
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)
|
|
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)
|