@sema-agent/core 7.17.3 → 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 +12 -0
- package/dist/tools/fs/bash-readonly-classifier.js +14 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## 7.17.3 — 2026-09-13
|
|
4
16
|
|
|
5
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)
|
|
@@ -983,13 +983,14 @@ function isGrepFileStdinLongOption(tok) {
|
|
|
983
983
|
const name = longOptionNameOf(tok);
|
|
984
984
|
return name !== undefined && isLongOptionAbbrevOf(name, "file");
|
|
985
985
|
}
|
|
986
|
+
const CLUSTER_OPTION_CHAR = /[A-Za-z0-9]/;
|
|
986
987
|
function grepClusterValueOwner(tok) {
|
|
987
|
-
if (
|
|
988
|
+
if (!tok.startsWith("-") || tok.startsWith("--") || !CLUSTER_OPTION_CHAR.test(tok[1] ?? ""))
|
|
988
989
|
return undefined;
|
|
989
990
|
for (const ch of tok.slice(1)) {
|
|
990
991
|
if (ch === "e" || ch === "f")
|
|
991
992
|
return ch;
|
|
992
|
-
if (
|
|
993
|
+
if (!CLUSTER_OPTION_CHAR.test(ch))
|
|
993
994
|
return undefined;
|
|
994
995
|
}
|
|
995
996
|
return undefined;
|
|
@@ -1003,8 +1004,11 @@ function attachedOptionPayloads(tok) {
|
|
|
1003
1004
|
out.push(literal(tok.slice(eq + 1)));
|
|
1004
1005
|
return out;
|
|
1005
1006
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
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)))
|
|
1008
1012
|
break;
|
|
1009
1013
|
const payload = tok.slice(k);
|
|
1010
1014
|
if (payload.length > 0)
|
|
@@ -1020,6 +1024,9 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
1020
1024
|
const findings = [];
|
|
1021
1025
|
const candidates = [];
|
|
1022
1026
|
const bareWordOperands = [];
|
|
1027
|
+
const judgeOperand = (text, globbed) => {
|
|
1028
|
+
(isPathShapedToken(text) || isAbsolutePathForm(text) ? candidates : bareWordOperands).push({ text, globbed });
|
|
1029
|
+
};
|
|
1023
1030
|
const positionalOperands = [];
|
|
1024
1031
|
let stdinOperand = false;
|
|
1025
1032
|
const argGlobs = (k) => {
|
|
@@ -1076,10 +1083,8 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
1076
1083
|
if (name === "grep" && (grepClusterValueOwner(t) === "e" || isGrepPatternPayloadLongOption(t)))
|
|
1077
1084
|
continue;
|
|
1078
1085
|
if (t.startsWith("-") && t !== "-") {
|
|
1079
|
-
for (const payload of attachedOptionPayloads(t))
|
|
1080
|
-
|
|
1081
|
-
candidates.push({ text: payload, globbed: argGlobs(k) });
|
|
1082
|
-
}
|
|
1086
|
+
for (const payload of attachedOptionPayloads(t))
|
|
1087
|
+
judgeOperand(payload, argGlobs(k));
|
|
1083
1088
|
continue;
|
|
1084
1089
|
}
|
|
1085
1090
|
}
|
|
@@ -1099,10 +1104,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
1099
1104
|
if (isGrepPatternSlot)
|
|
1100
1105
|
continue;
|
|
1101
1106
|
positionalOperands.push(t);
|
|
1102
|
-
|
|
1103
|
-
candidates.push({ text: t, globbed: argGlobs(k) });
|
|
1104
|
-
else
|
|
1105
|
-
bareWordOperands.push({ text: t, globbed: argGlobs(k) });
|
|
1107
|
+
judgeOperand(t, argGlobs(k));
|
|
1106
1108
|
}
|
|
1107
1109
|
}
|
|
1108
1110
|
for (let k = 0; k < args.length; k++) {
|