@sema-agent/core 7.25.0 → 7.25.1
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 +22 -0
- package/dist/core/arg-summary.d.ts +0 -7
- package/dist/core/arg-summary.js +2 -1
- package/dist/core/tool-policy.js +30 -23
- package/dist/tools/fs/bash-lexer.d.ts +13 -0
- package/dist/tools/fs/bash-lexer.js +61 -0
- package/dist/tools/fs/bash-program-position.d.ts +32 -3
- package/dist/tools/fs/bash-program-position.js +33 -2
- package/dist/tools/fs/bash-readonly-classifier.d.ts +6 -4
- package/dist/tools/fs/bash-readonly-classifier.js +55 -18
- package/dist/tools/fs/shell-read-plan.js +9 -11
- package/package.json +1 -1
- package/wire-changes/core@7.25.1.json +64 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.25.1 — 2026-09-20
|
|
4
|
+
|
|
5
|
+
### A bare `NAME+=value` word is the assignment bash reads, not a program name (#993; P2; read boundary; narrowing on deny rows, widening on the rest; no wire key, no export)
|
|
6
|
+
- **Before.** `A=.env` and `A+=.env` are one act to bash, and the program-position reader read only the plain spelling. The append word therefore arrived AS the program — an unrecognised one — so the segment took the verb-blind reading and its VALUE met no judge at all. Measured on the base of this entry, four faces, deny row `**/.env`: `A=.env; cat "$A"` was refused at zero cards with `read_path_denied` on all four, while `A+=.env; cat "$A"` produced ONE approval card naming no deny row on both Bash seats and a YES returned the file's bytes.
|
|
7
|
+
- **After, as one rule.** A leading assignment is the plain spelling, or the append spelling whose **prefix** — the NAME and the OPERATOR — is written bare in the raw token (`isLeadingAssignmentWord`, `src/tools/fs/bash-program-position.ts`). That is the question bash itself asks, before it removes any quoting and about those characters only: quoting any of `NAME+=` makes the whole word a program name on PATH (`'A+=x'`, `"A+=x"`, `A\+=x` — their argument stays judged), while quoting the VALUE does not (`A+=".env"` and `A+='.env'` assign `.env` exactly as `A+=.env` does — verified against the shell). A whole-word bareness test conflates the two and leaves the quoted-value spelling one quote character away from the escape this entry closes; the adversarial round found it and it is pinned as a mutation. The value channel is the same one rule: an assignment word carries TWO independent facts — what the text could not settle, and what path the text DOES name — and the append form has both, so both are handed over (`assignmentWordPayload`). A tilde-bearing or empty value still names nothing. The seat that answers "which program, and where its argv begins" (`argvProgramOf`) no longer strips the leading assignments a second time; the one reader answers.
|
|
8
|
+
- **NARROWING — who is hurt.** A deny row spelled in a bare append word is now REFUSED at zero cards on every face (`deniedBy: "read_boundary"`, `structured.read_path_denied`) where it was an approvable card: `A+=.env; cat "$A"`, `A+=".env"; cat "$A"`, `A+=.env cat x`, `A+=.env && …`, `A+=.env | …`, `B+=.env` behind another assignment, and the same word behind a launcher that owns assignment operands (`env A+=.env dd if=f`). Anyone whose command spells a deny-listed path in an append word loses the card. Compensation: none is owed — the plain spelling has had exactly this verdict since 7.25.0, and the card it replaces was one no approver could read the deny row from.
|
|
9
|
+
- **WIDENING — who is hurt.** The same alignment runs the other way for values that are merely outside the roots: `A+=/etc/passwd <unlisted>` and `A+=~/secret <unlisted>` were a MANDATED card (the verb-blind reading of a word mistaken for a program) and now vouch, because a leading environment word's value is the deny judge's alone and no containment candidate (KL-945a, the standing rule for `A=…`). A deployment that relied on that card as an out-of-root alarm on the append spelling loses it; the deny list is the channel that still fires, on either spelling.
|
|
10
|
+
- **Unchanged, pinned:** a quoted or escaped append word is a program name and its path argument is judged; `env A+=x dd if=<root>/f` reads word for word as before (the launcher owns the word, `dd` is the program); behind a launcher with no assignment grammar (`nice A+=x true`) the word is still the program it execs; `A+="$(cat <deny row>)"` is refused through the nested plan as before.
|
|
11
|
+
- **ONE question for both operators (E5; P2, narrowing; closes KL-945h / the #987 program-position格).** The predicate asks what bash asks — is the matched `NAME=` / `NAME+=` prefix written bare in the raw token — for BOTH operators. Reading the plain spelling on the FOLDED word alone conflated the two the other way: `'A=x' /outside/report.txt` is a command NAMED `A=x` to bash, and the reader took it for an assignment, stepped over the real program and read the file as a program PATH (executed, not disclosed, so no containment question at all). It is that program's operand now, and it is judged — a mandated card with a `readRootCandidate`, where there was none. Who is hurt: a command whose first word is a quoted or escaped assignment shape (`'A=x' file`, `"A+=x" file`) loses its free pass. Compensation: the card carries the way out; the bare spelling is unchanged, and so is a quoted VALUE.
|
|
12
|
+
- **A subscripted name is an assignment too (E6; P2, narrowing; closes KL-993a).** `A[0]=.env` writes one element of `A` and `${A[0]}` reads it back — bash's own reading, verified against the shell — and the grammar knew only unsubscripted names, so the whole word became a program name and its VALUE met no judge. `ASSIGNMENT_PREFIX` reads a subscript carrying no whitespace; the subscript itself is not parsed and the value is judged like any other. A quoted `'A[0]=x'` stays a program name. One element does not settle the whole variable, so the delete walk treats it as it treats an append: verifiable only where the prior content was. Who is hurt: a command spelling a deny-listed path in a subscripted assignment loses its card.
|
|
13
|
+
- **A declaration's operand is read after quote removal (G2).** `export` / `declare` and their family are BUILTINS: the word reaches them with the quotes gone, so `export 'D=/tmp/safe'` assigns what the bare spelling assigns. Requiring the word to be written bare there — the question the LEADING position asks, where the shell itself decides whether a word is an assignment at all — refused to read a word the builtin reads and left the name unregistered, which is a finding on a command that settles its target. The quoted spelling now answers exactly as its bare twin does in both directions: `export "D+=$UNKNOWN"` and `export 'D+=x'` (no prior value) revoke, `D=/tmp/safe; export 'D+=x'` does not.
|
|
14
|
+
- **The declared spelling is the same state transition (E7; P2, narrowing).** `export` / `declare` / `typeset` / `local` / `readonly` carry the same operand, and the second write site of the in-window-assignment rule kept its own copy of the grammar — `NAME=` only, `export` only. So `export D+=$UNKNOWN` left `D` verifiable while the bare `D+=$UNKNOWN` revoked it, and `rm -rf "$D"` behind the declared spelling drew no finding. Both sites read the one grammar now, and both answer "does this settle the whole name?" the same way. Who is hurt: a command that used a declaration head to keep a name verifiable through an append or a subscript now draws the finding the bare spelling always drew.
|
|
15
|
+
- **The activity summary skips every leading assignment (E8; privacy).** `primaryActivityArg` (`src/core/arg-summary.ts`) steps over a command's leading assignments to reach the command NAME, and its own copy of the grammar knew `NAME=` alone — so `PAYLOAD+=<secret> cat f` was read as a command called `PAYLOAD+=<secret>` and the VALUE was written out as the activity's name. That summary is persisted and SSE-forwarded. It reads the one grammar now; a lone assignment and a quoted one still withhold.
|
|
16
|
+
- **Same grammar, second walk (leg B; P2, narrowing).** The unverifiable-recursive-delete walk (`findUnverifiableRecursiveDelete`, `src/core/tool-policy.ts`) skips a statement's leading assignments to reach the command, and recognised them with a private copy of the grammar that knew only the plain operator — so `A+=x rm -rf $UNSET` stopped that walk on its first token and the delete behind it was never examined, while `A=x rm -rf $UNSET` reported its unverifiable target. Both walks now read ONE spelling (`ASSIGNMENT_PREFIX`, exported beside the reader). With it comes the append form's own asymmetry: `NAME+=value` hands the program what NAME already held plus this value, so a readable value settles only the tail — the name is verifiable only where its prior content was (`A+=/tmp/j rm -rf $A` reports; `A=/tmp/j; A+=/k rm -rf $A` does not), and the root mark an append carries is only ever added, never cleared. Who is hurt: a command that used one `+` in front of a delete and drew no finding now draws one — the fail-closed side, and the plain spelling's answer. A quoted prefix (`'A+=x' rm …`, a program name to bash) is unchanged on both spellings. `test/backlog993-tool-policy-leading-assignment.test.ts`.
|
|
17
|
+
- **Which `$` opens a quote form is ONE question (H; regression, fail-open).** The word scan decided it with a one-character peek behind the `$`, and the expansion scan did not look behind at all — so `cat x\\$'…'` (a literal backslash followed by a REAL ANSI-C quote: bash reads `x\\$'y'` as `x\y`) was read as a literal `$` by one and as a quote by the other. The word folded to a path INSIDE the roots while nothing reported an expansion, and the read ran at zero cards on the seat with no shape tier. Both walkers now ask one function, and it counts the backslash RUN — odd is escaped (`\$'y'` is a literal `$`), even is bare — which a peek at one character cannot do.
|
|
18
|
+
- **The same class, third walk (leg C; P2, narrowing).** `$'…'` (ANSI-C quoting) and `$"…"` are QUOTE FORMS, and the lexer folded their `$` into the word — so a deny row written `$'.env'` reached every judge as `$.env`, a spelling no filesystem has, and the read escaped into an approvable card a yes then executed (measured: one card naming no deny row on both Bash seats, sentinel returned, for `cat $'.env'`, `A=$'.env'; cat "$A"` and `A+=$'.env'; cat "$A"`). The forms are read as quotes now: a body with no backslash is its own text exactly as a single-quoted one is, `$"…"` is an ordinary double-quoted string, an unterminated `$'` is a command bash refuses to parse and is unreadable here too, and the quote walks know that `\'` does not close the region. `raw` keeps the written spelling, so every reader that asks "was this written bare" is unaffected. A `$` that OPENS a quote no longer counts as an unquoted expansion either — calling it one made the word unresolvable, which names no path for a judge to meet. **The ESCAPE SEQUENCES are deliberately not folded**: a body carrying any backslash is unreadable (the card this release started from, fail-closed). Three adversarial rounds each found a different transcription error in a decoder for them — a code point read where bash builds a byte, a UTF-16 unit encoded where bash writes a character, a control-letter formula off by one table — and each named a DIFFERENT FILE than the shell would open, so the table belongs with the single-lexer work (#994) where a differential test against `/bin/bash printf` bytes can carry it. `test/backlog993-ansi-c-quoting.test.ts`; KL-993g records the limit, KL-993d the read-only shell's own `$` grammar.
|
|
19
|
+
- **The tilde question is asked of the PREFIX, not the word (E3).** A slot expands only where its tilde-PREFIX — up to the terminating `/` or `:` — carries no quoting, which is bash's own rule. Asking it of the whole word answered it for `A=~/'.env'` and `A+=x:~/'.env'`, whose prefixes are bare and whose values are not: bash expands those (`A=~/".env"` gives `<home>/.env`, verified against the shell) and calling the value literal named a file no shell would open. Both now read unresolved and ask on both faces; `A='~'/x` — a QUOTED prefix — is the ordinary relative path it is.
|
|
20
|
+
- **The same class, fourth walk (leg D; P2, narrowing).** A tilde in an assignment value is read by POSITION, as bash reads it: expanded at the start of the value and right after each `:`, LITERAL everywhere else, and nowhere at all where the word is not written bare. Reading "contains a tilde" as "cannot be settled" threw away every value whose tilde was literal, and a value nobody names is a value no judge meets — so `A=.env~` / `A+=.env~` (the editor-backup spelling) reached a deny row spelled `**/.env*` as nothing at all, on both spellings, and asked where `cat .env~` refused. The value is now named (`.env~`), the row bites, and a quoted leading tilde is an ordinary path component spelled `./~…`. A tilde WHERE BASH EXPANDS ONE is still reported unresolved and still asks on both faces, unchanged. Who is hurt: these values lose their card where they name a deny row. KL-993b now records only what is left — such a tilde is not placed against the declared home.
|
|
21
|
+
- **Three corners of the same three legs (F1–F3).** (F1, regression) An ANSI-C body's LITERAL run was encoded by UTF-16 unit, so a character outside the basic plane became two replacement characters — bytes that decode cleanly and name a different file: `cat $'🔒/credentials.txt'` under a deny row for that directory read it at zero cards on the seat with no shape tier. Literal runs are encoded by code point now, and the decoder keeps a BOM instead of stripping bytes bash keeps. (F2) The bareness question covers the NAME and the OPERATOR and nothing else: a quoted SUBSCRIPT still names the element (`A['0']=v` assigns it), and comparing the whole prefix took that for a program name — a card where a refusal was owed. (F3) A declaration carries its own OPTIONS and as many operands as it likes; reading one word after the head word left every later name unregistered and every option-carrying declaration unread, so `export -n D=/tmp/safe; rm -rf "$D"` drew a finding on a command that settles its target. Every operand takes the same state transition now, an unreadable one revokes rather than vouches, and the transition is written once for both sites.
|
|
22
|
+
- **KNOWN-LIMITS:** KL-945h and KL-993a are CLOSED by E5 / E6 above (both operators and the subscripted name now take the one bare-prefix question). Left open: KL-993b (a tilde in an expansion slot reports "unresolved" rather than resolving against the declared home), KL-993c (a value assembled across two appends — `A+=.en; A+=v` — is not one text, so the read it feeds is the ordinary unresolvable-expansion card), KL-993d (the read-only shell's own grammar still refuses every `$` spelling, `$'…'` included), KL-993e (a value equal to the substitution placeholder text is dropped as a substitution), KL-993f (a subscript carrying whitespace is not read as one word).
|
|
23
|
+
- Reach: @server / @cli / @client-core — zero: no wire key, no notice code, no export added or removed, no card SHAPE changed. Headless: a deny row in an append word refuses (`read_boundary`) where it auto-denied an unanswerable card; an out-of-root value in one vouches where it refused. `test/backlog993-append-assignment-word.test.ts` pins P1–P5 with a positive control; `backlog860` G2 and `backlog862b` ③/⑧ carry the retired dialect's tripwire.
|
|
24
|
+
|
|
3
25
|
## 7.25.0 — 2026-09-20
|
|
4
26
|
|
|
5
27
|
### One station judges every shell read, before approval (#945 S1; BREAKING ×3; one narrowing, three widenings; #976 closed structurally)
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* design/99 — a SHORT, SECRET-SCRUBBED one-line summary of a tool call's arguments, for an OPERATOR-FACING
|
|
3
|
-
* display (the MF-W workflow monitor's `Read(path)` / `Bash(grep …)` activity line; the MF-24 permission-denial
|
|
4
|
-
* ledger's denied-tool input). Shared so both the activity sink and the denial record scrub identically — a
|
|
5
|
-
* single audited boundary. NOT `redactHostLeaks` (that is for untrusted-LLM egress and over-redacts the very
|
|
6
|
-
* path the trusted owner-scoped monitor wants to show). Reviewed: 3 blocker + 5 major findings closed.
|
|
7
|
-
*/
|
|
8
1
|
/** Known credential SHAPES + `secretKey=value` — redacted from the FINAL abbreviated string, the single scrub
|
|
9
2
|
* point before any store/SSE fan-out. Conservative (real credential formats). */
|
|
10
3
|
/** every prefix is LEFT-ANCHORED with \`(?<![A-Za-z0-9])\` — without it, ordinary words whose
|
package/dist/core/arg-summary.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { writtenAssignmentPrefix } from "../tools/fs/bash-program-position.js";
|
|
1
2
|
const ACTIVITY_ARG_MAX = 80;
|
|
2
3
|
const SENSITIVE_KEY = /token|secret|key|password|passwd|credential|auth/i;
|
|
3
4
|
export function summarizeRedactions(findings) {
|
|
@@ -234,7 +235,7 @@ export function primaryActivityArg(args) {
|
|
|
234
235
|
if (command !== undefined) {
|
|
235
236
|
const tokens = command.trim().split(/\s+/);
|
|
236
237
|
let i = 0;
|
|
237
|
-
while (i < tokens.length &&
|
|
238
|
+
while (i < tokens.length && writtenAssignmentPrefix(tokens[i]) !== undefined) {
|
|
238
239
|
if (/["'\\]/.test(tokens[i]))
|
|
239
240
|
return undefined;
|
|
240
241
|
i++;
|
package/dist/core/tool-policy.js
CHANGED
|
@@ -13,6 +13,7 @@ import { thrownText } from "./thrown-value.js";
|
|
|
13
13
|
import { isNamespacedCoveringRuleName, namespacedRuleNameCovers, parsePermissionRule } from "./permission-rules.js";
|
|
14
14
|
import { protocolOf } from "./protocol-table.js";
|
|
15
15
|
import { isAbsoluteForFamily, isAbsolutePathForm, joinForFamily, normalizeAbsPathLexically, pathFamilyOf, writeTargetPath } from "../tools/fs/safety.js";
|
|
16
|
+
import { ASSIGNMENT_PREFIX, writtenAssignmentPrefix } from "../tools/fs/bash-program-position.js";
|
|
16
17
|
export function advertisedWritableRootsSeat(seat) {
|
|
17
18
|
return seat === undefined ? {} : { advertisedWritableRoots: Object.freeze([...seat.roots]) };
|
|
18
19
|
}
|
|
@@ -618,22 +619,28 @@ export function findUnverifiableRecursiveDelete(command, safeVars = new Set(UNVE
|
|
|
618
619
|
}
|
|
619
620
|
return false;
|
|
620
621
|
};
|
|
622
|
+
const applyAssignmentWord = (words, at) => {
|
|
623
|
+
const written = writtenAssignmentPrefix(words[at]);
|
|
624
|
+
if (written === undefined) {
|
|
625
|
+
assigned.delete(words[at].replace(/\[.*$/, ""));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const value = restOfAssignment(words, at, written.prefix.length - 1);
|
|
629
|
+
if ((!written.settlesWholeName && !assigned.has(written.name)) || valueIsOpaque(value, assigned, safeVars))
|
|
630
|
+
assigned.delete(written.name);
|
|
631
|
+
else
|
|
632
|
+
assigned.add(written.name);
|
|
633
|
+
if (valueEscapesRoot(value, safeVars))
|
|
634
|
+
assignedEscapesRoot.add(written.name);
|
|
635
|
+
else if (written.settlesWholeName)
|
|
636
|
+
assignedEscapesRoot.delete(written.name);
|
|
637
|
+
};
|
|
621
638
|
const statements = command.replace(/\\\r?\n/g, " ").split(STATEMENT_SPLIT);
|
|
622
639
|
for (const stmt of statements) {
|
|
623
640
|
const tokens = stmt.trim().split(/\s+/).filter(Boolean);
|
|
624
641
|
let i = 0;
|
|
625
|
-
while (i < tokens.length &&
|
|
626
|
-
|
|
627
|
-
const name = tokens[i].slice(0, eq);
|
|
628
|
-
const value = restOfAssignment(tokens, i, eq);
|
|
629
|
-
if (valueIsOpaque(value, assigned, safeVars))
|
|
630
|
-
assigned.delete(name);
|
|
631
|
-
else
|
|
632
|
-
assigned.add(name);
|
|
633
|
-
if (valueEscapesRoot(value, safeVars))
|
|
634
|
-
assignedEscapesRoot.add(name);
|
|
635
|
-
else
|
|
636
|
-
assignedEscapesRoot.delete(name);
|
|
642
|
+
while (i < tokens.length && ASSIGNMENT_PREFIX.test(tokens[i])) {
|
|
643
|
+
applyAssignmentWord(tokens, i);
|
|
637
644
|
i += assignmentTokenSpan(tokens, i);
|
|
638
645
|
}
|
|
639
646
|
let sawWrapper = false;
|
|
@@ -646,7 +653,7 @@ export function findUnverifiableRecursiveDelete(command, safeVars = new Set(UNVE
|
|
|
646
653
|
if (DELETE_WRAPPERS.has(t)) {
|
|
647
654
|
sawWrapper = true;
|
|
648
655
|
i++;
|
|
649
|
-
while (i < tokens.length && (/^-/.test(tokens[i]) ||
|
|
656
|
+
while (i < tokens.length && (/^-/.test(tokens[i]) || ASSIGNMENT_PREFIX.test(tokens[i])))
|
|
650
657
|
i++;
|
|
651
658
|
continue;
|
|
652
659
|
}
|
|
@@ -672,16 +679,16 @@ export function findUnverifiableRecursiveDelete(command, safeVars = new Set(UNVE
|
|
|
672
679
|
i = headIdx;
|
|
673
680
|
const head = stripTokenEdges(tokens[i]).replace(/^\\/, "");
|
|
674
681
|
const headName = head.split("/").pop() ?? head;
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
682
|
+
const declStmt = stmt.trim();
|
|
683
|
+
const declaredWords = /^(?:export|declare|typeset|local|readonly)\b/.test(declStmt) ? declStmt.split(/\s+/).filter(Boolean).slice(1) : ASSIGNMENT_PREFIX.test(declStmt) ? [declStmt] : [];
|
|
684
|
+
for (let d = 0; d < declaredWords.length;) {
|
|
685
|
+
if (/^[-+]/.test(declaredWords[d])) {
|
|
686
|
+
d += 1;
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
declaredWords[d] = stripTokenEdges(declaredWords[d]);
|
|
690
|
+
applyAssignmentWord(declaredWords, d);
|
|
691
|
+
d += assignmentTokenSpan(declaredWords, d);
|
|
685
692
|
}
|
|
686
693
|
if (headName === "read") {
|
|
687
694
|
void 0;
|
|
@@ -93,6 +93,19 @@ export interface SegmentTokens {
|
|
|
93
93
|
* judges words sees "this word carries text the shell computes" rather than the computing text itself. */
|
|
94
94
|
export declare const SUBSTITUTION_PLACEHOLDER = " subst";
|
|
95
95
|
export type QuoteOpen = '"' | "'" | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* Does the `$` at `at` OPEN a quote form (`$'…'` / `$"…"`)? ONE question, asked by every walker that has to
|
|
98
|
+
* answer it — the word scan here and the expansion scan in the classifier — because a `$` one of them reads
|
|
99
|
+
* as escaped and the other as bare yields two different words for the same text, and the judge then compares
|
|
100
|
+
* the wrong one. Escaped means an ODD run of backslashes in front of it: `\$'y'` is a literal `$`, while
|
|
101
|
+
* `x\\$'y'` is a literal backslash followed by a real ANSI-C quote (both read off /bin/bash). Counting the
|
|
102
|
+
* run is what a one-character peek behind cannot do.
|
|
103
|
+
*/
|
|
104
|
+
export declare function dollarOpensQuote(text: string, at: number): boolean;
|
|
105
|
+
/** The `'` that CLOSES the ANSI-C quote opened at `open`, or `undefined` when none does. `\'` does not close
|
|
106
|
+
* one — the single place a quote region honours a backslash, and a pairing walk blind to it reads the real
|
|
107
|
+
* closing quote as an OPENING one and everything after as quoted, which hides an expansion the shell performs. */
|
|
108
|
+
export declare function ansiCQuoteClose(source: string, open: number): number | undefined;
|
|
96
109
|
/**
|
|
97
110
|
* Which QUOTE holds each character of `s`, by the same pairing rule the lexer walks with, and the boolean view
|
|
98
111
|
* of it. The pairing quote characters themselves belong to the region they bound — they are not operators
|
|
@@ -4,11 +4,44 @@ function quoteStep(open, ch) {
|
|
|
4
4
|
return ch === '"' || ch === "'" ? ch : undefined;
|
|
5
5
|
return open === ch ? undefined : open;
|
|
6
6
|
}
|
|
7
|
+
export function dollarOpensQuote(text, at) {
|
|
8
|
+
const next = text[at + 1];
|
|
9
|
+
if (next !== "'" && next !== '"')
|
|
10
|
+
return false;
|
|
11
|
+
let run = 0;
|
|
12
|
+
for (let k = at - 1; k >= 0 && text[k] === "\\"; k--)
|
|
13
|
+
run += 1;
|
|
14
|
+
return run % 2 === 0;
|
|
15
|
+
}
|
|
16
|
+
export function ansiCQuoteClose(source, open) {
|
|
17
|
+
for (let i = open + 1; i < source.length; i++) {
|
|
18
|
+
if (source[i] === "\\") {
|
|
19
|
+
i += 1;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (source[i] === "'")
|
|
23
|
+
return i;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
7
27
|
export function quoteMask(s) {
|
|
8
28
|
const openedBy = new Array(s.length).fill(undefined);
|
|
9
29
|
const quoted = new Array(s.length).fill(false);
|
|
10
30
|
let open;
|
|
11
31
|
for (let i = 0; i < s.length; i++) {
|
|
32
|
+
if (open === undefined && s[i] === "$" && s[i + 1] === "'") {
|
|
33
|
+
const close = ansiCQuoteClose(s, i + 1);
|
|
34
|
+
for (let k = i; k <= (close ?? s.length - 1); k++) {
|
|
35
|
+
openedBy[k] = "'";
|
|
36
|
+
quoted[k] = true;
|
|
37
|
+
}
|
|
38
|
+
if (close === undefined) {
|
|
39
|
+
open = "'";
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
i = close;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
12
45
|
const before = open;
|
|
13
46
|
open = quoteStep(open, s[i]);
|
|
14
47
|
const holds = before ?? open;
|
|
@@ -32,6 +65,13 @@ function tildeIsLiteral(raw) {
|
|
|
32
65
|
}
|
|
33
66
|
return false;
|
|
34
67
|
}
|
|
68
|
+
function ansiCBody(source, open) {
|
|
69
|
+
const close = ansiCQuoteClose(source, open);
|
|
70
|
+
if (close === undefined)
|
|
71
|
+
return undefined;
|
|
72
|
+
const body = source.slice(open + 1, close);
|
|
73
|
+
return body.includes("\\") ? { unreadable: true, close } : { text: body, close };
|
|
74
|
+
}
|
|
35
75
|
export function wordsOf(segment) {
|
|
36
76
|
const folded = [];
|
|
37
77
|
const raw = [];
|
|
@@ -310,6 +350,27 @@ function scan(source, opts, quotesLiteral) {
|
|
|
310
350
|
i = j;
|
|
311
351
|
}
|
|
312
352
|
const ch = source[i];
|
|
353
|
+
if (!quotesLiteral && ch === "$" && !inSingle && !inDouble && dollarOpensQuote(source, i)) {
|
|
354
|
+
const q = next(i);
|
|
355
|
+
if (source[q] === '"') {
|
|
356
|
+
raw += ch;
|
|
357
|
+
started = true;
|
|
358
|
+
i = q - 1;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (source[q] === "'") {
|
|
362
|
+
const ansi = ansiCBody(source, q);
|
|
363
|
+
if (ansi === undefined)
|
|
364
|
+
return { reject: "an ANSI-C quoted word never closes" };
|
|
365
|
+
if ("unreadable" in ansi)
|
|
366
|
+
return { reject: "an ANSI-C quoted word carries an escape this lexer does not fold" };
|
|
367
|
+
raw += source.slice(i, ansi.close + 1);
|
|
368
|
+
folded += ansi.text;
|
|
369
|
+
started = true;
|
|
370
|
+
i = ansi.close;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
313
374
|
if (!quotesLiteral) {
|
|
314
375
|
if (ch === "'" && !inDouble) {
|
|
315
376
|
inSingle = !inSingle;
|
|
@@ -14,9 +14,27 @@ import type { SegmentTokens } from "./bash-lexer.js";
|
|
|
14
14
|
export declare const NOT_AUTO_ALLOWED = "\u2014 not auto-allowed";
|
|
15
15
|
/** argv[0] as the program bash will resolve it: `/usr/bin/cat` and `cat` are one name. */
|
|
16
16
|
export declare function programBasename(word: string): string;
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
19
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The `NAME=` / `NAME+=` prefix bash reads as an ASSIGNMENT — the ONE spelling of that grammar, matched where
|
|
19
|
+
* it is WRITTEN. Both operators, because bash reads both; the match IS the prefix, so a caller holding raw
|
|
20
|
+
* text can ask whether its token carries one bare. Every walk that strips leading assignments reads this:
|
|
21
|
+
* a second copy of the grammar is how one walk came to strip `A=x` and skip `A+=x`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const ASSIGNMENT_PREFIX: RegExp;
|
|
24
|
+
/**
|
|
25
|
+
* The prefix this raw token BEGINS with, or `undefined`.
|
|
26
|
+
*
|
|
27
|
+
* `name` is the VARIABLE the word assigns to, which is the part before any subscript: `A[0]=x` writes one
|
|
28
|
+
* element of `A`, so a reader asking "is `$A` settled" must not be told that `A[0]` is. `settlesWholeName` is
|
|
29
|
+
* false for exactly the two spellings that do not settle it — an append (the earlier content is not in the
|
|
30
|
+
* text) and a subscript (the other elements are not) — so a consumer of that question asks it once.
|
|
31
|
+
*/
|
|
32
|
+
export declare function writtenAssignmentPrefix(token: string): {
|
|
33
|
+
readonly prefix: string;
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly appends: boolean;
|
|
36
|
+
readonly settlesWholeName: boolean;
|
|
37
|
+
} | undefined;
|
|
20
38
|
/** Shell control words that stand BEFORE a command: transparent for the boundary walk (the command behind them is
|
|
21
39
|
* judged). */
|
|
22
40
|
export declare const SHELL_CONTROL_PREFIXES: ReadonlySet<string>;
|
|
@@ -25,6 +43,17 @@ export declare const SHELL_BLOCK_WORDS: ReadonlySet<string>;
|
|
|
25
43
|
/** A control word is a keyword only when bash sees it BARE: `'if'` / `"do"` / `\\!` are command names (a program
|
|
26
44
|
* looked up on PATH, whose failure changes nothing), so a quoted or escaped spelling never counts as one. */
|
|
27
45
|
export declare function isBareControlWord(toks: SegmentTokens, i: number, words: ReadonlySet<string>): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* A word bash strips as an ASSIGNMENT before it chooses the program.
|
|
48
|
+
*
|
|
49
|
+
* WHAT HAS TO BE BARE IS THE PREFIX, NOT THE WORD, and it is ONE question for BOTH operators. Bash decides this
|
|
50
|
+
* before it removes any quoting, and only the NAME and the OPERATOR take part in it: quoting any of `NAME=` /
|
|
51
|
+
* `NAME+=` makes the whole word a program name looked up on PATH (`'A=x'`, `'A+=x'`, `A\+=x`), while quoting the
|
|
52
|
+
* VALUE does not (`A+=".env"` assigns `.env` exactly as `A+=.env` does — verified against the shell). A
|
|
53
|
+
* whole-word test conflates the two and reads the quoted VALUE as a program name; a FOLDED-word test conflates
|
|
54
|
+
* them the other way and reads the quoted PROGRAM NAME as an assignment, stepping over the real program.
|
|
55
|
+
*/
|
|
56
|
+
export declare function isLeadingAssignmentWord(toks: SegmentTokens, i: number): boolean;
|
|
28
57
|
/**
|
|
29
58
|
* Programs that turn their STANDARD INPUT into the OPERANDS of another program, and EXEC that program directly.
|
|
30
59
|
*
|
|
@@ -2,13 +2,44 @@ export const NOT_AUTO_ALLOWED = "— not auto-allowed";
|
|
|
2
2
|
export function programBasename(word) {
|
|
3
3
|
return word.includes("/") ? (word.split("/").pop() ?? word) : word;
|
|
4
4
|
}
|
|
5
|
-
export const
|
|
5
|
+
export const ASSIGNMENT_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]\s]*\])?\+?=/;
|
|
6
|
+
export function writtenAssignmentPrefix(token) {
|
|
7
|
+
const prefix = ASSIGNMENT_PREFIX.exec(token)?.[0];
|
|
8
|
+
if (prefix === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
const appends = prefix.endsWith("+=");
|
|
11
|
+
const written = prefix.slice(0, prefix.length - (appends ? 2 : 1));
|
|
12
|
+
const bracket = written.indexOf("[");
|
|
13
|
+
return { prefix, name: bracket < 0 ? written : written.slice(0, bracket), appends, settlesWholeName: !appends && bracket < 0 };
|
|
14
|
+
}
|
|
6
15
|
export const SHELL_CONTROL_PREFIXES = new Set(["!", "if", "then", "elif", "else", "while", "until", "do", "time"]);
|
|
7
16
|
export const SHELL_BLOCK_WORDS = new Set(["for", "select", "case", "esac", "fi", "done", "function", "coproc", "[[", "]]", "in"]);
|
|
8
17
|
export function isBareControlWord(toks, i, words) {
|
|
9
18
|
const word = toks.folded[i];
|
|
10
19
|
return word !== undefined && toks.raw[i] === word && words.has(word);
|
|
11
20
|
}
|
|
21
|
+
export function isLeadingAssignmentWord(toks, i) {
|
|
22
|
+
const word = toks.folded[i];
|
|
23
|
+
const raw = toks.raw[i];
|
|
24
|
+
if (word === undefined || raw === undefined)
|
|
25
|
+
return false;
|
|
26
|
+
const written = writtenAssignmentPrefix(word);
|
|
27
|
+
return written !== undefined && prefixWrittenBare(raw, written);
|
|
28
|
+
}
|
|
29
|
+
function prefixWrittenBare(raw, written) {
|
|
30
|
+
if (!raw.startsWith(written.name))
|
|
31
|
+
return false;
|
|
32
|
+
let at = written.name.length;
|
|
33
|
+
if (written.prefix[at] === "[") {
|
|
34
|
+
if (raw[at] !== "[")
|
|
35
|
+
return false;
|
|
36
|
+
const close = raw.indexOf("]", at);
|
|
37
|
+
if (close < 0)
|
|
38
|
+
return false;
|
|
39
|
+
at = close + 1;
|
|
40
|
+
}
|
|
41
|
+
return raw.startsWith(written.appends ? "+=" : "=", at);
|
|
42
|
+
}
|
|
12
43
|
export const ARGV_REENTRY_PROGRAM_RE = /^xargs$/;
|
|
13
44
|
export const ARGV_REENTRY_DEFAULT_PROGRAM = "echo";
|
|
14
45
|
const NO_DECLARED_OPTIONS = { optionArity: new Map() };
|
|
@@ -90,7 +121,7 @@ export function readProgramPosition(toks) {
|
|
|
90
121
|
return none(undefined, []);
|
|
91
122
|
const written = i;
|
|
92
123
|
const assignments = [];
|
|
93
|
-
while (i < folded.length &&
|
|
124
|
+
while (i < folded.length && isLeadingAssignmentWord(toks, i)) {
|
|
94
125
|
assignments.push(i);
|
|
95
126
|
i++;
|
|
96
127
|
}
|
|
@@ -811,12 +811,14 @@ export declare function inlineProgramClause(model: InlineProgramModel, args: rea
|
|
|
811
811
|
* command name. Names-only traversals (`find`, `ls -R`, `du`, `tree`, an archive write) are in the table
|
|
812
812
|
* and answer `false` here — see {@link RecursiveReadFormModel.discloses}. */
|
|
813
813
|
export declare function segmentSelectsRecursiveRead(name: string, args: readonly string[]): boolean;
|
|
814
|
-
|
|
814
|
+
/** `raw` is the word as WRITTEN; it defaults to the folded spelling, which is the reading that expands most. */
|
|
815
|
+
export declare function assignmentWordPayload(word: string, raw?: string): {
|
|
815
816
|
kind: "value";
|
|
816
817
|
value: string;
|
|
817
818
|
} | {
|
|
818
819
|
kind: "unresolved";
|
|
819
820
|
reason: string;
|
|
821
|
+
value?: string;
|
|
820
822
|
} | undefined;
|
|
821
823
|
/** WHICH programs are DECLARED not to open the value of a `NAME=value` ARGUMENT — an allow-list, because
|
|
822
824
|
* the other polarity was measured false. `make PREFIX=/usr/local`, `cmake -D…`, `docker run -e HOME=/root`
|
|
@@ -834,9 +836,9 @@ export declare function assignmentWordPayload(word: string): {
|
|
|
834
836
|
export declare const ASSIGNMENT_VALUE_NOT_PATHS: ReadonlySet<string>;
|
|
835
837
|
/** The program the shell will RUN here and where its OWN argv begins — the two seats
|
|
836
838
|
* {@link HeadedSegment.argvProgram} / {@link HeadedSegment.argvFrom} carry, from one reader so they cannot
|
|
837
|
-
* disagree.
|
|
838
|
-
*
|
|
839
|
-
*
|
|
839
|
+
* disagree. The LEADING assignments (both spellings) are the program-position reader's own step, so this
|
|
840
|
+
* seat no longer strips them a second time: a second strip read the quoted `'A+=x'` as an assignment the
|
|
841
|
+
* one reader calls a program name, which is two readers with two answers. */
|
|
840
842
|
export declare function argvProgramOf(tokens: SegmentTokens): {
|
|
841
843
|
program: string;
|
|
842
844
|
from: number;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf } from "./safety.js";
|
|
2
|
-
import { lexShellText, literalTildeSpelling, quoteMask, wordsOf, } from "./bash-lexer.js";
|
|
3
|
-
import {
|
|
2
|
+
import { lexShellText, literalTildeSpelling, ansiCQuoteClose, dollarOpensQuote, quoteMask, wordsOf, } from "./bash-lexer.js";
|
|
3
|
+
import { COMMAND_LAUNCHERS, NOT_AUTO_ALLOWED, SHELL_BLOCK_WORDS, argvReentryUnjudged, isBareControlWord, programPositionRefusalSentence, ASSIGNMENT_PREFIX, readProgramPosition, writtenAssignmentPrefix, } from "./bash-program-position.js";
|
|
4
4
|
export const BASH_READONLY_DEFAULT_ALLOW = [
|
|
5
5
|
"ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
|
|
6
6
|
"grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
|
|
@@ -208,7 +208,21 @@ function tokenizeSegment(segment) {
|
|
|
208
208
|
}
|
|
209
209
|
export function hasUnquotedExpansionMetachar(rawToken) {
|
|
210
210
|
let open;
|
|
211
|
-
for (
|
|
211
|
+
for (let i = 0; i < rawToken.length; i++) {
|
|
212
|
+
const ch = rawToken[i];
|
|
213
|
+
const opener = rawToken[i + 1];
|
|
214
|
+
if (open === undefined && ch === "$" && dollarOpensQuote(rawToken, i)) {
|
|
215
|
+
if (opener === '"') {
|
|
216
|
+
open = opener;
|
|
217
|
+
i += 1;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const close = ansiCQuoteClose(rawToken, i + 1);
|
|
221
|
+
if (close === undefined)
|
|
222
|
+
return true;
|
|
223
|
+
i = close;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
212
226
|
if (open === undefined && (ch === '"' || ch === "'")) {
|
|
213
227
|
open = ch;
|
|
214
228
|
continue;
|
|
@@ -1444,28 +1458,51 @@ function isGrepFileStdinLongOption(tok) {
|
|
|
1444
1458
|
return name !== undefined && isLongOptionAbbrevOf(name, "file");
|
|
1445
1459
|
}
|
|
1446
1460
|
const CLUSTER_OPTION_CHAR = /[A-Za-z0-9]/;
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1461
|
+
function tildeExpandsInValue(rawValue) {
|
|
1462
|
+
if (rawValue === undefined)
|
|
1463
|
+
return false;
|
|
1464
|
+
for (let at = 0; at <= rawValue.length;) {
|
|
1465
|
+
if (rawValue[at] === "~") {
|
|
1466
|
+
let quoted = false;
|
|
1467
|
+
for (let k = at + 1; k < rawValue.length && rawValue[k] !== "/" && rawValue[k] !== ":"; k++) {
|
|
1468
|
+
if (rawValue[k] === "'" || rawValue[k] === '"' || rawValue[k] === "\\") {
|
|
1469
|
+
quoted = true;
|
|
1470
|
+
break;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
if (!quoted)
|
|
1474
|
+
return true;
|
|
1475
|
+
}
|
|
1476
|
+
const next = rawValue.indexOf(":", at);
|
|
1477
|
+
if (next < 0)
|
|
1478
|
+
return false;
|
|
1479
|
+
at = next + 1;
|
|
1480
|
+
}
|
|
1481
|
+
return false;
|
|
1482
|
+
}
|
|
1483
|
+
export function assignmentWordPayload(word, raw = word) {
|
|
1484
|
+
const written = writtenAssignmentPrefix(word);
|
|
1485
|
+
if (written === undefined)
|
|
1450
1486
|
return undefined;
|
|
1451
|
-
const eq =
|
|
1452
|
-
const
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
if (
|
|
1487
|
+
const eq = written.prefix.length - 1;
|
|
1488
|
+
const spelled = word.slice(eq + 1);
|
|
1489
|
+
const expands = tildeExpandsInValue(raw.startsWith(written.prefix) ? raw.slice(written.prefix.length) : undefined);
|
|
1490
|
+
const value = expands ? spelled : literalTildeSpelling(spelled);
|
|
1491
|
+
if (written.appends) {
|
|
1492
|
+
const reason = `"${word}" appends to a value the text does not contain`;
|
|
1493
|
+
return spelled === "" || expands ? { kind: "unresolved", reason } : { kind: "unresolved", reason, value };
|
|
1494
|
+
}
|
|
1495
|
+
if (expands)
|
|
1456
1496
|
return { kind: "unresolved", reason: `"${word}" carries a tilde whose expansion the text does not settle` };
|
|
1457
|
-
return
|
|
1497
|
+
return spelled === "" ? undefined : { kind: "value", value };
|
|
1458
1498
|
}
|
|
1459
1499
|
export const ASSIGNMENT_VALUE_NOT_PATHS = new Set(["make", "gmake", "bmake", "cmake", "ninja", "meson", "docker", "podman"]);
|
|
1460
1500
|
export function argvProgramOf(tokens) {
|
|
1461
|
-
|
|
1462
|
-
while (lead < tokens.folded.length && ASSIGNMENT_OR_APPEND_WORD.test(tokens.folded[lead]))
|
|
1463
|
-
lead++;
|
|
1464
|
-
const pos = readProgramPosition({ folded: tokens.folded.slice(lead), raw: tokens.raw.slice(lead) });
|
|
1501
|
+
const pos = readProgramPosition(tokens);
|
|
1465
1502
|
if (pos.program === undefined)
|
|
1466
1503
|
return undefined;
|
|
1467
|
-
const at =
|
|
1468
|
-
return
|
|
1504
|
+
const at = pos.program.index;
|
|
1505
|
+
return ASSIGNMENT_PREFIX.test(tokens.folded[at] ?? "") ? undefined : { program: pos.program.basename, from: at };
|
|
1469
1506
|
}
|
|
1470
1507
|
export function attachedOptionPayloads(tok) {
|
|
1471
1508
|
const literal = literalTildeSpelling;
|
|
@@ -285,7 +285,7 @@ function segmentFacts(segment, p, decl, nestedPlan) {
|
|
|
285
285
|
reads.push({ spelled: word, raw, provenance: "unlisted_word", expands: "unresolvable" });
|
|
286
286
|
continue;
|
|
287
287
|
}
|
|
288
|
-
if (assignmentWordPayload(word) !== undefined) {
|
|
288
|
+
if (assignmentWordPayload(word, toks.raw[j]) !== undefined) {
|
|
289
289
|
pushAssignment(reads, toks, j, "env_assignment");
|
|
290
290
|
continue;
|
|
291
291
|
}
|
|
@@ -465,15 +465,13 @@ function redirectFact(r) {
|
|
|
465
465
|
}
|
|
466
466
|
function pushAssignment(reads, toks, k, provenance) {
|
|
467
467
|
const word = toks.folded[k];
|
|
468
|
-
const payload = assignmentWordPayload(word);
|
|
468
|
+
const payload = assignmentWordPayload(word, toks.raw[k]);
|
|
469
469
|
if (payload === undefined)
|
|
470
470
|
return;
|
|
471
|
-
if (payload.kind === "
|
|
472
|
-
return;
|
|
473
|
-
if (payload.kind === "value")
|
|
474
|
-
reads.push(bare(toks.raw[k], payload.value, provenance));
|
|
475
|
-
else
|
|
471
|
+
if (payload.kind === "unresolved")
|
|
476
472
|
reads.push({ spelled: word, raw: toks.raw[k], provenance, expands: "unresolvable" });
|
|
473
|
+
if (payload.value !== undefined && payload.value !== SUBSTITUTION_PLACEHOLDER)
|
|
474
|
+
reads.push(bare(toks.raw[k], payload.value, provenance));
|
|
477
475
|
}
|
|
478
476
|
function unlistedWords(reads, toks, from, launcherIndices, provenance, program) {
|
|
479
477
|
const argv = program !== undefined ? { program: program.basename, from: program.index } : argvProgramOf(toks);
|
|
@@ -742,11 +740,11 @@ function listedVerbCandidates(name, args, rawArgs, reads) {
|
|
|
742
740
|
positional.push({ word: t, raw: rawArgs[k] });
|
|
743
741
|
push(k, t, "operand");
|
|
744
742
|
if (!ASSIGNMENT_VALUE_NOT_PATHS.has(name)) {
|
|
745
|
-
const payload = assignmentWordPayload(t);
|
|
746
|
-
if (payload?.kind === "
|
|
747
|
-
push(k, payload.value, "operand");
|
|
748
|
-
else if (payload !== undefined)
|
|
743
|
+
const payload = assignmentWordPayload(t, rawArgs[k]);
|
|
744
|
+
if (payload?.kind === "unresolved")
|
|
749
745
|
reads.push({ spelled: t, raw: rawArgs[k], provenance: "operand", expands: "unresolvable" });
|
|
746
|
+
if (payload?.value !== undefined)
|
|
747
|
+
push(k, payload.value, "operand");
|
|
750
748
|
}
|
|
751
749
|
}
|
|
752
750
|
for (let k = 0; k < args.length; k++) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"repo": "core",
|
|
3
|
+
"version": "7.25.1",
|
|
4
|
+
"from": "b205840bf3354e748513a90e5d698b8dd8a639fd",
|
|
5
|
+
"to": "v7.25.1",
|
|
6
|
+
"added": [],
|
|
7
|
+
"removed": [],
|
|
8
|
+
"changed": [],
|
|
9
|
+
"narrowed": [
|
|
10
|
+
{
|
|
11
|
+
"id": "F1",
|
|
12
|
+
"source": "behaviorFlips.summary",
|
|
13
|
+
"text": "NARROWING (#993, P2, read boundary): a deny-listed path spelled through an assignment word or a quote form is REFUSED at zero cards on every shell face — bare `NAME+=value`, `NAME[sub]=value` (subscript may be quoted), a `$'…'` / `$\"…\"` body with no backslash, a tilde outside an expansion slot (`A=.env~`), and a declaration operand (`export`/`declare`/`local` …) of those spellings."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "F3",
|
|
17
|
+
"source": "behaviorFlips.summary",
|
|
18
|
+
"text": "NARROWING (#993 E5): a QUOTED assignment-shaped first word (`'A=x' cmd`, `\"A+=x\" cmd`) is the program name bash runs, so it is judged as an unknown program and its path operands are judged — one bare-prefix question for `=` and `+=` alike; KL-945h closed."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "F4",
|
|
22
|
+
"source": "behaviorFlips.summary",
|
|
23
|
+
"text": "NARROWING (#993 leg B / E7 / F3 / G2, irreversibility): the unverifiable-recursive-delete walk skips leading assignments with the same bare-prefix grammar and applies one state transition to every declaration operand, so `A+=x rm -rf $UNSET` and `export -n D+=$UNKNOWN; rm -rf $D` report a finding where they reported none; `export -n D=/tmp/safe; rm -rf \"$D\"` no longer over-asks."
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"closedSetMembers": [],
|
|
27
|
+
"closedSetsUnreadable": [],
|
|
28
|
+
"typeKeys": [],
|
|
29
|
+
"declarationMoves": [],
|
|
30
|
+
"mintMoves": [],
|
|
31
|
+
"criteriaObserves": [
|
|
32
|
+
{
|
|
33
|
+
"kind": "taskEventField",
|
|
34
|
+
"name": "tool_end.gate",
|
|
35
|
+
"assertions": [
|
|
36
|
+
"G1.a",
|
|
37
|
+
"G1.b"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"kind": "taskEventField",
|
|
42
|
+
"name": "tool_end.structured",
|
|
43
|
+
"assertions": [
|
|
44
|
+
"G1.a"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"kind": "typeMember",
|
|
49
|
+
"name": "AskRequest.requiresRealApproval",
|
|
50
|
+
"assertions": [
|
|
51
|
+
"G2.a"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"consumersHint": {
|
|
56
|
+
"cli": [],
|
|
57
|
+
"server": [],
|
|
58
|
+
"sdk": [],
|
|
59
|
+
"note": "NOT SCANNED: sema-cli, sema-server, sema-sdk — an empty list for those repos means \"not checked out\", not \"no consumer\""
|
|
60
|
+
},
|
|
61
|
+
"breakingCount": 0,
|
|
62
|
+
"breakingItems": [],
|
|
63
|
+
"toResolvedAt": "ce38d4e944d7b23bd648af718325db86e81b57c6"
|
|
64
|
+
}
|