@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
|
-
* ·
|
|
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)
|
|
@@ -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 (
|
|
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 (
|
|
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
|
-
|
|
962
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|