@sema-agent/core 7.17.2 → 7.17.4

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.17.4 — 2026-09-14
4
+
5
+ ### Narrowing — an option's ATTACHED payload is a word like any other: the boundary walk judges it (#763; the residual filed at 7.17.3; @server @cli @test)
6
+ - **Before.** The boundary walk offered an attached option payload (`-fVALUE`, `-xVALUE` in a cluster, `--flag=VALUE`) as a path candidate only when the payload was path-SHAPED, and a sensitive-path deny row names BARE filenames (`.npmrc`, `.env`, `id_rsa`) — no separator, no shape. The payload was dropped before anything judged it, so the deny row was read with no question at all, for EVERY head, listed or not: measured on the real load path (Runner prepare → NodeExecutionEnv → the gate) under the default doctrine, `grep -f.npmrc`, `grep --file=.npmrc`, `awk -f.npmrc` and `base64 -i.npmrc` were asks=0 and EXECUTED (`awk -f.npmrc` put the file's bytes into the model record, since awk quotes the source it cannot parse), while the separated (`grep -f .npmrc`) and path-shaped (`-i./.npmrc`) spellings of the IDENTICAL read were one mandated ask. Under `"classify"` the same defect showed as `grep --file=.npmrc` asks=0 and the other three raising only the doctrine's own retirable ask, with no mandate. This is the same wrong assumption #758 removed one level down ("a word that does not look like a path is not a file"), surviving at the walk; it is NOT the #758 class — the read-only allowlist has nothing to do with it.
7
+ - **After (a rule removed, none added).** The payload arm's shape filter is gone. Every judged word — an attached payload and a positional operand alike — takes ONE lane split (`judgeOperand`): a word that can denote a location outside the working directory (path-shaped, or an absolute form) is a containment candidate; a separator-less word can only land under the working directory, so it is deny-judged and reported as a checked path but cannot demote. That is the lane the positional operands always used; the payload now shares it instead of being filtered out of the judgement entirely. The per-verb carve-outs are unchanged (grep's `-e`/`--regexp=` PATTERN payloads, cut's delimiter values), and an option payload is still NOT a traversal root.
8
+ - **Same class, second spelling: the cluster is read to its END.** `attachedOptionPayloads` walked a short-option cluster's option characters as LETTERS only, so a DIGIT stopped the scan: `grep -2f.npmrc` offered `2f.npmrc` (a bare word under the working directory) and never `.npmrc`, while the installed grep opens `.npmrc` — verified against the real program, not inferred from an option table. Digits are ordinary getopt option characters (`grep -1`…`-9`, `head -1`, `tar -0`), so the repair is the CHARSET: one constant (`CLUSTER_OPTION_CHAR`) read by BOTH readers of a cluster — the payload scan and grep's value-owner scan, which must agree about where the options end, or a digit-prefixed PATTERN payload (`grep -2e/x`) would become an out-of-root path candidate and a refusal on the readonly face. No per-program option list is introduced, and the charset closes the same digit hole in the DEVICE arm, which extracts payloads through the same function: `grep -2f/dev/zero a b` was measured auto-allowed (grep consuming the endless device as its pattern file) and is now the device refusal, beside the `grep -f/dev/zero` spelling that was already caught. `grep -2f.npmrc a b` is now a deny-row mandate and `grep -2f/etc/passwd a b` an out-of-root mandate, on both seats; every everyday numeric spelling (`head -n10`, `head -c100`, `tail -n+100`, `make -j8`, `git log -n5`, `grep -A2`/`-B3`/`-C2`/`-m1`, `ls -1`, `head -5`, `cut -c1-5`, `base64 -w0`, `ssh -p2222`, `docker run -p8080:80`, `mysql -h127.0.0.1`, `chmod -R755`, `ip -4`) keeps its zero-ask verdict, measured. **The over-fire it buys, swept for and named row by row:** payload extraction is program-blind and over-approximates by design (`-if../x` offers `f../x` AND `../x`), and the charset is what stops a split from being taken INSIDE a relative path (`-fsrc/a.txt` breaks at the `/` and never invents `/a.txt`). Admitting one more option character therefore moves some split points one place right, and where the next character is a `/`, an absolute reading appears that the letters-only scan did not produce. Three rows move, each an attached value whose DIGITS are followed by a separator: `date -d1/2/2026` (offers `/2/2026`), `diff -I1/foo` (a regex value) and `sed -e'1s/a/b/'` (a line-addressed script) — the last two a mandated ask on the shell lane and a hard refusal on the `bash_readonly` face. Their non-moving neighbours are pinned beside them (`date -d '1/2/2026'`, `sed -e'1,5s/a/b/'`, `sed -e'1d'`, `diff -Ifoo`, `head -n1`), as are the letters-only analogues that already mandated before this car (`date -da/b/c`, `sed -e's/a/b/'`). The repair for the whole row-set is #764's declared non-path-slot table (`sed -e` is a script, `diff -I` a regex, `date -d` a time string), not a per-name special case here and not narrowing the charset back — that reopens the attached-file reads this closes. **Residual, disclosed and pinned:** a PUNCTUATION option character still ends the scan, so `curl -#K.npmrc` hides the `-K` payload behind curl's `#` flag (verified against the installed curl) and the deny row is read unasked under the default doctrine; enumerating punctuation IS the per-program option table this file refuses to carry, and the sound answers are #764's value-ownership table or mandating on a cluster the scan cannot fully interpret.
9
+ - **The zero-option reading narrowed to where it names a location.** The suffix scan also offered `tok.slice(1)` — the whole post-dash text as a value no option consumed — unconditionally. getopt has no such form, and on an everyday cluster it is a word no program opens (`-f` offered the phantom `f`, `-xzf` offered `xzf`; `cat -/etc/passwd` is an illegal option on the installed cat/head/grep rather than a read), which stayed invisible only while payloads were shape-filtered. Dropping it outright was measured against the pre-car tree and lost three mandates — `grep -/etc/passwd f`, `cat -//` and `chpst -// cat etc/passwd` went SILENT where the baseline mandated — because that reading is what covers an option character this scan cannot enumerate: `-//` is `-/ /` to a program whose `/` takes a value, and the chroot that follows reads from the filesystem root. So shape decides here exactly as it does in the lane split — a location-shaped whole is offered, a bare word is not. An exact-`checkedPaths` pin for `tail -f app.log` therefore reads `[<root>/app.log]` as before, and every out-of-root boundary the pre-car scan drew is still drawn.
10
+ - **Behaviour, for consumers.** A shell command whose option payload names a deny row now asks ONCE, mandated, under both doctrines — a stored allow rule is disclosed as shadowed and does not clear it — and the ask is the same one the separated spelling already raised. The lower bound is unchanged and pinned: `grep -epattern f`, `head -n10 f`, `tar -xzf x.tgz`, `curl -Hheader`, `wc -l f`, `cut -d, -f1 f`, `sort -k1 f`, `ls -la`, `npm run build -- --silent`, `git log -n5`, `node --max-old-space-size=4096 x.mjs`, `make -j8`, `docker run -it img`, `jq -r .name pkg.json`, `sed -n '1,5p' f`, `base64 -w0 f` keep their zero-ask verdict on both seats. The widened judgement adds `checkedPaths` entries for payload words that resolve inside the roots (the extractor is getopt-blind and offers every suffix that follows at least one option character, so `curl -Hheader` now reports `header`, `eader`, … beside `curl`): those are candidates a filesystem-holding caller re-checks with symlinks resolved, and they never affect a verdict. @test: the class criterion is the SPELLING (attached short / cluster / long `=`) held against the separated and path-shaped spellings of one read, on a listed program and a synthetic unlisted name alike — a verification that enumerates tool names repeats the defect.
11
+ - **A pre-existing hole CLOSED beside the defect (a narrowing, not an over-fire).** Folding the two arms' shape tests into one predicate also gave the POSITIONAL arm the absolute-form half the payload arm always had. Measured over a win-form corpus on all three faces, exactly one answer moves: on a WIN32-rooted boundary `classifySimpleCommandReadBoundary` returned an EMPTY verdict for `cat \\host\share\secret` and `cat C:\other\secret` — no reason, no `refusedWhole`, no out-of-root finding, i.e. a read outside every root AUTO-ALLOWED on the one face whose doctrine is hard refusal, while the compound face refused the identical command whole. Both faces now name the out-of-root read. The in-file note that called the shape unreachable is corrected: the backslash hard-reject that makes it true covers the COMPOUND faces only, and never covered the single-command face. @server @cli: on a win32 tree a `bash_readonly` command naming a UNC or drive-letter path outside the roots is refused where it previously ran.
12
+ - **Stated over-fire, measured and pinned.** A payload that SPELLS a deny row gates even where the program would not open it: `grep --exclude=.npmrc`, `--include=`, `--label=` are filename patterns and labels, not files grep opens, and they now raise a mandated ask that a standing rule cannot retire (the ordinary values — `--exclude=build`, `--include=src` — keep their zero-ask verdict). Telling "a payload that names a file" from "a payload that names a pattern" needs the per-program option semantics this file refuses to carry; the file's stated error direction is that over-reading a token as a path costs a prompt while under-reading one is a boundary circumvention. The two existing carve-outs stay exactly where they are — payloads that provably are not filesystem targets AND whose over-read would be a hard REFUSAL rather than a prompt. Related, NOT fixed here: a listed verb's non-path slots have no declared table (`sed -e` is a script, `grep -e` a pattern, `head -n` a number), so `sed -e's/a/b/'` mandates on the `/a/b/` its cluster also spells — pre-existing, identical before and after this car, and filed as #764 rather than answered with a `sed` special case.
13
+ - Pins: `test/backlog763-attached-payload-deny.test.ts` (the real load path, two doctrines: the four measured-silent spellings, the mandated-ask lane under a covering rule, the listed/unlisted × attached/separated/path-shaped grid with its non-deny negative twin, the everyday-payload lower bound end to end and at both seats, the digit-cluster deny/out-of-root mandates with the pattern carve-out control and the numeric lower bound, the out-of-root payload and positional deny-row regressions, the accepted over-fire, the payload-is-not-an-operand-slot cases, and the closed win-form hole on both root families; four mutation directions measured in the header).
14
+
15
+ ## 7.17.3 — 2026-09-13
16
+
17
+ ### Narrowing — "not on the read-only allowlist" is not "reads no path": the read boundary judges every segment (#758; test [7138]; @server @cli @test)
18
+ - **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.
19
+ - **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.
20
+ - **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.
21
+ - **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.
22
+ - **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).
23
+ - 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).
24
+
3
25
  ## 7.17.2 — 2026-09-13
4
26
 
5
27
  ### 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)
@@ -938,13 +983,14 @@ function isGrepFileStdinLongOption(tok) {
938
983
  const name = longOptionNameOf(tok);
939
984
  return name !== undefined && isLongOptionAbbrevOf(name, "file");
940
985
  }
986
+ const CLUSTER_OPTION_CHAR = /[A-Za-z0-9]/;
941
987
  function grepClusterValueOwner(tok) {
942
- if (!/^-[A-Za-z]/.test(tok) || tok.startsWith("--"))
988
+ if (!tok.startsWith("-") || tok.startsWith("--") || !CLUSTER_OPTION_CHAR.test(tok[1] ?? ""))
943
989
  return undefined;
944
990
  for (const ch of tok.slice(1)) {
945
991
  if (ch === "e" || ch === "f")
946
992
  return ch;
947
- if (!/[A-Za-z]/.test(ch))
993
+ if (!CLUSTER_OPTION_CHAR.test(ch))
948
994
  return undefined;
949
995
  }
950
996
  return undefined;
@@ -958,8 +1004,11 @@ function attachedOptionPayloads(tok) {
958
1004
  out.push(literal(tok.slice(eq + 1)));
959
1005
  return out;
960
1006
  }
961
- for (let k = 1; k <= tok.length; k++) {
962
- if (!/^[A-Za-z]*$/.test(tok.slice(1, k)))
1007
+ const whole = literal(tok.slice(1));
1008
+ if (whole.length > 0 && (isPathShapedToken(whole) || isAbsolutePathForm(whole)))
1009
+ out.push(whole);
1010
+ for (let k = 2; k <= tok.length; k++) {
1011
+ if (![...tok.slice(1, k)].every((ch) => CLUSTER_OPTION_CHAR.test(ch)))
963
1012
  break;
964
1013
  const payload = tok.slice(k);
965
1014
  if (payload.length > 0)
@@ -975,6 +1024,9 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
975
1024
  const findings = [];
976
1025
  const candidates = [];
977
1026
  const bareWordOperands = [];
1027
+ const judgeOperand = (text, globbed) => {
1028
+ (isPathShapedToken(text) || isAbsolutePathForm(text) ? candidates : bareWordOperands).push({ text, globbed });
1029
+ };
978
1030
  const positionalOperands = [];
979
1031
  let stdinOperand = false;
980
1032
  const argGlobs = (k) => {
@@ -1031,10 +1083,8 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1031
1083
  if (name === "grep" && (grepClusterValueOwner(t) === "e" || isGrepPatternPayloadLongOption(t)))
1032
1084
  continue;
1033
1085
  if (t.startsWith("-") && t !== "-") {
1034
- for (const payload of attachedOptionPayloads(t)) {
1035
- if (isAbsolutePathForm(payload) || isPathShapedToken(payload))
1036
- candidates.push({ text: payload, globbed: argGlobs(k) });
1037
- }
1086
+ for (const payload of attachedOptionPayloads(t))
1087
+ judgeOperand(payload, argGlobs(k));
1038
1088
  continue;
1039
1089
  }
1040
1090
  }
@@ -1054,10 +1104,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1054
1104
  if (isGrepPatternSlot)
1055
1105
  continue;
1056
1106
  positionalOperands.push(t);
1057
- if (isPathShapedToken(t))
1058
- candidates.push({ text: t, globbed: argGlobs(k) });
1059
- else
1060
- bareWordOperands.push({ text: t, globbed: argGlobs(k) });
1107
+ judgeOperand(t, argGlobs(k));
1061
1108
  }
1062
1109
  }
1063
1110
  for (let k = 0; k < args.length; k++) {
@@ -1243,8 +1290,13 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1243
1290
  const foldedSegments = [];
1244
1291
  let shapeRefusal = nameRefusal;
1245
1292
  for (let si = 0; si < segments.length; si++) {
1246
- if (unlistedSegments.has(si))
1293
+ if (unlistedSegments.has(si)) {
1294
+ const unlisted = tokenizeSegment(segments[si]);
1295
+ if (unlisted.folded.length === 0)
1296
+ continue;
1297
+ foldedSegments.push({ folded: [LAUNCHER_SCAN_HEAD, ...unlisted.folded], raw: [LAUNCHER_SCAN_HEAD, ...unlisted.raw] });
1247
1298
  continue;
1299
+ }
1248
1300
  const rehead = reheaded.get(si);
1249
1301
  const segmentTokens = rehead === undefined ? tokenizeSegment(segments[si]) : reheadSegment(tokenizeSegment(segments[si]), rehead.head, rehead.from);
1250
1302
  const toks = segmentTokens.folded;
@@ -1439,6 +1491,14 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
1439
1491
  }
1440
1492
  const NOT_GATED = { gated: false, paths: [] };
1441
1493
  const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
1494
+ const UNREADABLE_WHITELIST_WORD = /[$`()*?[\]{}~]/;
1495
+ function whitelistReadsTheSameWords(command, outsideHeredocBodies) {
1496
+ if (/\\\r?\n/.test(command))
1497
+ return false;
1498
+ if (/\\[<>&|;()]/.test(outsideHeredocBodies))
1499
+ return false;
1500
+ return !(outsideHeredocBodies.includes("#") && /["']/.test(outsideHeredocBodies));
1501
+ }
1442
1502
  const UNMODELLED_BASE_MOVERS = new Set(["pushd", "popd"]);
1443
1503
  const COMMAND_LAUNCHERS = new Set([
1444
1504
  "env", "command", "builtin", "exec", "noglob", "xargs",
@@ -1476,14 +1536,12 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
1476
1536
  if (written === undefined)
1477
1537
  continue;
1478
1538
  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
1539
  if (UNMODELLED_BASE_MOVERS.has(name))
1484
1540
  baseMoved = true;
1485
- if (!allow.has(name) && name !== "cd")
1541
+ if (!allow.has(name) && name !== "cd") {
1542
+ readers.push({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
1486
1543
  continue;
1544
+ }
1487
1545
  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
1546
  }
1489
1547
  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.4",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",