@sema-agent/core 7.23.0 → 7.23.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 +42 -0
- package/dist/core/gate-bounded-await.d.ts +1 -1
- package/dist/core/gate-bounded-await.js +1 -1
- package/dist/core/gate-crash-trace.d.ts +7 -0
- package/dist/core/gate-crash-trace.js +3 -0
- package/dist/core/gate-fold.js +2 -1
- package/dist/core/gate-lanes.js +2 -1
- package/dist/core/hooks.js +2 -1
- package/dist/core/runner/gate-exit.d.ts +0 -5
- package/dist/core/runner/gate-exit.js +0 -3
- package/dist/core/write-protect.js +1 -2
- package/dist/tools/fs/bash-lexer.d.ts +12 -5
- package/dist/tools/fs/bash-lexer.js +5 -2
- package/dist/tools/fs/bash-program-position.d.ts +18 -0
- package/dist/tools/fs/bash-program-position.js +13 -1
- package/dist/tools/fs/bash-readonly-classifier.d.ts +10 -3
- package/dist/tools/fs/bash-readonly-classifier.js +70 -41
- package/dist/tools/fs/fs-bash.d.ts +22 -0
- package/dist/tools/fs/fs-bash.js +19 -12
- package/package.json +1 -1
- package/wire-changes/core@7.23.1.json +205 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.23.1 — 2026-09-18
|
|
4
|
+
|
|
5
|
+
### Read boundary — a `$` inside SINGLE QUOTES no longer refuses the whole command (#921, ruling C-R67 ③; behaviour face, both seats; @cli @server @client-core re-pin)
|
|
6
|
+
- **Before.** The compound classifier's whole-string gate scanned for `$` character by character with no idea of quoting, so the most ordinary lines there are carried a card no rule could clear: `awk '{print $1}' f`, `awk -F: '{print $1}' f`, `awk '{print $NF}' f`, `grep 'x$' f`, `sed -n 's/$/x/p' f`. One character decided it — `awk '{print}' f`, the same invocation, was auto-allowed. Inside single quotes the character is a field reference or an end-of-line anchor; bash expands nothing there.
|
|
7
|
+
- **After.** ONE sieve, two passes in a load-bearing order. Everything whose ABSENCE makes quoting exact — escapes, subshells, line breaks, and redirection outside the tolerate mode — is still refused QUOTE-BLIND; only then, with a backslash guaranteed gone, is the lexer's existing quote mask built and `$` refused wherever bash would expand it (unquoted, or inside double quotes). Rules: −1 (the tolerate arm and the default arm were two refusal paths and are now one, with the sentence as the parameter). `quoteMask` reports WHICH quote holds each character; `quoted` is a view of that, minted in the same step.
|
|
8
|
+
- **A BACKTICK is not read through the mask, and the line between the two is measured.** Quoting stops the FIRST expansion, not a later one: `let` and `declare -i` evaluate their argument as an ARITHMETIC expression, and that evaluation performs command substitution on the text it is handed, single quotes included. Against real bash, `let 'a[0`<command>`]'` and `declare -i 'a=b[0$(<command>)]'` RUN the command; `let 'a=$x'` and `let 'a[0$x]'` hand the evaluator the literal characters `$x`, which is a syntax error and runs nothing, and a value arriving through `${x}` is expanded but never re-substituted. So the two spellings that can EXECUTE — `` ` `` and `$(` — stay refused wherever they stand (`(` being in the blind class is what keeps the second one out), which is the same fact the whitelist already states about an assignment word whose value spells a substitution literally. `$` alone cannot execute, so it is the only character this mask decides.
|
|
9
|
+
- **Exactness, since the two characters are the ones that could break the mask.** The mask and bash agree left to right until something stops a quote character from being one. Two things can: a backslash, refused before the mask is built, and the `$'…'` / `$"…"` forms, whose `$` stands OUTSIDE the region it opens — where the mask still reports it unquoted and the gate refuses the string. Either the string is refused or the mask is exact; there is no third case. An unbalanced mask is not used at all.
|
|
10
|
+
- **Reach.** Ten measured shapes stop being whole-refused, and each then answers exactly as the same command without the character. Two directions, both pinned: `awk '{print $1}' f` / `grep 'x$' f` / `cat '$F'` go to zero asks on the default/off and bypass tiers, while `awk '{print $1}' /etc/passwd` and `grep 'x$' <deny row>` STAY mandated — and now name the file they read instead of refusing the string. `awk "{print $1}" f` (double quotes, a real expansion), `cat $F`, `cat $'a\tb'`, `cat \`echo f\``, `echo '\`id\`'` (single-quoted backtick — see above) and every unbalanced-quote spelling are unchanged. `cat '$HOME/.ssh/<key>'` goes from one mandated ask to zero: bash hands `cat` the literal directory name `$HOME`, so the command cannot reach the key — and a deployment whose deny table covers that literal path still gets its refusal (`test/backlog714-home-expansion-precise.test.ts` re-pinned).
|
|
11
|
+
- **The rule lane too.** The segmenter is shared with the persisted permission-rule lane, where a whole-string refusal put the command outside the lane entirely and no stored rule of EITHER polarity could speak about it. These commands are now nameable: a deny rule reaches them, and so does an allow rule.
|
|
12
|
+
- **Not fixed, and now visible rather than assumed gone.** (i) An interpreter's inline program that opens with a slash-addressed pattern is still read as an absolute path: `awk '/error/' app.log` reports a phantom out-of-root read of `/error`, and `perl -pe '/x/' f` of `/x`. sed's positional script got a first-operand row in #892; the interpreter family has not. Pinned in `test/backlog791-interpreter-inline-program.test.ts` G-9. (ii) `trap` / `alias` / `history -s` / `git -c 'core.pager=…'` hand a LATER evaluation a command line and are not in the shell re-entry class, so the boundary walks their argument as an ordinary in-root word and vouches it. That hole is PRE-EXISTING — `trap 'cat /etc/passwd' EXIT` measured vouched on 66b7e7c5 too — and this change adds the `$` spelling (`trap 'cat $F' EXIT`) to the same already-open door; no capability is new, since anyone who can write the one can write the other. Both ticketed, pinned in `test/backlog921-single-quoted-expansion.test.ts` ④.
|
|
13
|
+
- **headless.** Both tiers, permissive direction: a headless run that refused these awk/grep/sed lines for want of an approver now executes them. Nothing is newly refused. Pins: `test/backlog921-single-quoted-expansion.test.ts`.
|
|
14
|
+
|
|
15
|
+
### Read boundary — a verb's FIRST OPERAND can be a slot its grammar fills, not a file: `sed -n '/error/p' app.log` stops asking (#892; behaviour face, both seats; @cli @server @client-core re-pin)
|
|
16
|
+
- **Before.** Only grep's PATTERN slot was modelled, by a hand-written predicate and a hand-written branch in each of the two walks that needed it. sed's SCRIPT slot was modelled for the `-e` spelling and not for the positional one, so `sed -n '/error/p' app.log` — a script opening with an address regexp, spelled with slashes — was read as the absolute path `/error/p`, found outside the roots, and carried a MANDATED card on an everyday log-reading line. The `-e` spelling of the same script was already exempt: one invocation, two readings.
|
|
17
|
+
- **After.** `VerbOptionSlots.firstOperand` is a declaration row (grep's `pattern`, sed's `script`) read with the same `OptionValueKind` vocabulary the option slots already use, and ONE reader (`suppliesFirstOperandSlot`) answers "did an option supply this slot instead" for both walks. Rules: −1 (the verb-named branch and its private predicate are gone; a verb with no row keeps an ordinary first operand). The slot's value is judged exactly as a declared option value is — a kind that cannot name a file yields no candidate, one that can is a file as before.
|
|
18
|
+
- **Reach — a NARROWING, listed per spelling.** Six shapes lose a mandated card on the default/off and bypass tiers (and take an ordinary ask on the classify tier instead): `sed -n '/error/p' <log>`, `sed -n '/x/r <out-of-root>' f` (see Stated), and the four spellings in which a path-shaped token IS sed's script — `sed <path>`, `sed <deny row>`, `sed -l <path>`, `sed -l <deny row>`. The last four are not a file under EITHER implementation: the token is the script where `-l` takes no value and `-l`'s length argument where it does, so the verb-blind reading that recorded it as a read was an over-approximation the row now retires (`test/backlog764-verb-option-slots.test.ts` G-2b re-pinned; the one spelling whose token really is an input, `sed -n -e 1p -l <file>`, still mandates and still catches the arity mutation). The narrowing is exactly one operand wide: `sed -f prog.sed <path>`, `sed p <path>`, `sed -n p /etc/passwd` and `grep pat /etc/passwd` all still mandate, and no other verb grew a row. Pins: `test/backlog892-first-operand-slot.test.ts`.
|
|
19
|
+
- **Stated (ruled reach, C-R67 ②).** `sed -n '/x/r <out-of-root>' f` was mandated only because its script's first character was `/` and the operand walk read the whole script as an absolute path — an accident, not a promise: `sed -n 'p;r <out-of-root>' f` was never caught, and `sed -n '1r <out-of-root>' f` has been the ruled, disclosed reach of the friction floor since 7.22.0. Removing the misreading removes the accident with it, so that spelling now answers like its siblings. The script slot's named files do not reach the read boundary by ruling; the floor for what they open is the execution environment's sandbox.
|
|
20
|
+
- **headless.** Both tiers, permissive direction and narrow: a headless run that refused `sed -n '/error/p' <log>` for want of an approver now executes it. Nothing is newly refused.
|
|
21
|
+
|
|
22
|
+
### Read boundary — a decline is read as evidence by the SEAT that declares it; the classify tier trades the mandate for an ordinary ask (#891, ruling C-R67 ①; behaviour face, classify tier only; @cli @server @client-core re-pin)
|
|
23
|
+
- **Before.** The shell scan declines two ways — it cannot name the program bash will run (`env -- ls`, `nice -x 5 cat f`, `xargs -0 wc -l`, `sudo -u root cat f`), or it read the program and the operands arrive on a STREAM it never saw (`find … | xargs wc -l`, `git ls-files | xargs grep -l TODO`, `ls | xargs cat`). Both refused the command outright on BOTH seats, so under `shellGate: "classify"` these everyday pipelines carried a card no allow rule and no auto classifier could retire. Neither decline names a word: they say only that the scan could not tell.
|
|
24
|
+
- **After.** Which decline a seat reads as read evidence is a TABLE the seat declares against (`SHELL_SCAN_DECLINES`, two members; `BOUNDARY_SEAT_DECLINE_EVIDENCE` keeps both, `CLASSIFY_SEAT_DECLINE_EVIDENCE` keeps neither). The question is asked ONCE for the whole command, ahead of every per-verb exit, so nothing downstream stands in front of the table — the blanket refusal of a listed reader whose operands the whitelist does not enumerate included. Rules: −4 (four per-segment refusals fold into that one pass), +1 (the pass). No third verdict state: both seats still raise an ask on the same shapes.
|
|
25
|
+
- **Reach — default and bypass tiers: none, measured byte for byte.** The read-boundary seat is what those tiers mount ALONE, where "not mandated" is VOUCHED, so it keeps both members: all 78 rows of the differential corpus answer identically, `echo /etc/passwd | xargs cat` included (the closed P0 stays closed). Under `shellGate: "classify"` 17 of those rows move from a mandated card to an ordinary one.
|
|
26
|
+
- **What the release does NOT buy, and it is one rule.** The boundary's objection over an operand the command NAMES still mandates, on BOTH halves of that judgement — the deny table and the roots. It is asked by the face that already answered the containment half (`classifyOutOfRootReadGate`), which now reports the deny finding too: ONE extraction of what the operands are, two findings over it, computed in a second pass with the deny judge present so the containment answer stays deny-free and keeps its `paths` contract. So `cat <deny row> && ls | xargs wc -l` answers exactly as its twin `cat /etc/passwd && ls | xargs wc -l`, and so do `ls | xargs wc -l; cat <deny row>`, `echo x | xargs cat <deny row>`, `ls | xargs grep -l TODO <deny row>`, the attached and separated payload spellings (`grep -f<deny row>`), a `NAME=value` operand (`dd if=<deny row>`) and a read standing before a directory change. Because the extraction is the face's own, no read is invented either: a sed SCRIPT is still not a path.
|
|
27
|
+
- **🔔 This reaches the DEFAULT/OFF and BYPASS tiers too, and it is the only place in this batch where they move.** That face reads a segment under a control word VERB-BLIND, so it was already mandating `! echo /etc/passwd` on every tier while `! echo <deny row>` was vouched — the same asymmetry, one layer up, and one its own header claims to cover. Giving it the deny half closes that as well: 252 rows of a 39,930-row control-word corpus move, every one V → M (a tightening; zero M → V), every one carrying a deny-listed operand, under `!` / `if` / `while` / `time` / `command` / `env` / `xargs` and the unreadable-launcher heads. A deployment with no deny table sees nothing (the face never reports the half its boundary cannot answer); the plain spelling is untouched, since a program declared to read no path still names no read.
|
|
28
|
+
- **Accepted reach, stated rather than mitigated.** On the classify tier, once that ordinary ask is retired by a rule or the auto classifier, a read HIDDEN inside such a command is not asked again: a stream is re-split by its consumer (`echo <deny row> | xargs cat` — the path is in `echo`'s words, and what `xargs` hands `cat` is a stream nobody read), whitespace is re-split with it (`echo " /etc/passwd" | xargs cat`), an option the walk could not read may move the working directory first (`env -q cd ..; strings ../secret`), text reaching a shell through stdin is a command line nobody parsed (`echo "cat /etc/passwd" | env -- sh`), and a stdin literal (`env -- ls < /etc/passwd`) or a substitution body (`env -- ls $(cat /etc/passwd)`) behind a decline is not walked. An operand the scan could not READ at all — an unexpanded glob — is not an objection either, which is why `find . -name '*.ts' | xargs wc -l` still drops. Each is pinned at that answer in `test/backlog891-seat-keyed-declines.test.ts`. What such a command may read is floored by the execution environment's sandbox.
|
|
29
|
+
- **headless.** Classify tier only, and in the permissive direction: a headless run (no approver) that refused these commands on a mandated card now refuses them on an ordinary one — and where a stored allow rule covers the command, it now RUNS instead of being refused. Default/off and bypass tiers are unchanged, so a deployment on those tiers sees no lane change.
|
|
30
|
+
- **@cli @server @client-core.** Re-pin any probe that expects `mandated` for these shapes under `shellGate: "classify"`; the default tier's expectations are unchanged. The PUBLIC export face is unchanged (`handoff:diff` 66b7e7c5..HEAD = +0/−0 over all three changes in this batch): the new names — `SHELL_SCAN_DECLINES`, `ShellScanDecline`, `programPositionRunsShell`, `shellScanDeclineOf`, `BOUNDARY_SEAT_DECLINE_EVIDENCE`, `CLASSIFY_SEAT_DECLINE_EVIDENCE` — are module exports inside `src/tools/fs`, not re-exported from `src/index.ts`, and `refusedCommandMayRead`'s new required fourth argument is likewise internal.
|
|
31
|
+
|
|
32
|
+
### Gates — the debts gate reds text that spills past the seventh cell (`gate:debts`; no product code)
|
|
33
|
+
- A ledger row with one bare `|` inside a cell and no closing pipe still splits into nine segments, so the column-count check passed while the tail after the last pipe sat in no cell at all — unread by the gate's positional readers and dropped by the renderer (found by the cli debt survey: five rows whose open remainders lived in that tail; one server row measured the same way today). A row must open and close with a pipe; a non-empty first or last segment is red and names the row. Negative control in `test/gate-negative-controls.test.ts`.
|
|
34
|
+
|
|
35
|
+
### Loading — every `src/tools/fs` module is a safe first entry; the gate's crash tracer leaves the record floor (#918; no behaviour change)
|
|
36
|
+
- **Before.** A host that imported `dist/tools/fs/bash-readonly-classifier.js` BEFORE `dist/tools/fs/fs-bash.js` (the cli's danger-matrix harness) got `ReferenceError: Cannot access 'NOT_AUTO_ALLOWED' before initialization` on 7.21.1, 7.22.0 and 7.23.0 alike. The runtime cycle: `tools/fs/safety` → `core/gate-bounded-await` → `runner/gate-exit` (one VALUE import, the crash tracer) → `tool-policy` → `tools/fs/index` → `fs-bash` → the classifier still mid-evaluation, whose re-export of the phrase constant fs-bash's top-level table read had not been reached. A bare import of fs-bash never showed it: ESM evaluation order is the entry's.
|
|
37
|
+
- **After.** `traceHookCrash` lives in `src/core/gate-crash-trace.ts` (two type-only edges, one call); `gate-bounded-await` has no runtime edge into `runner/`; `fs-bash` reads `NOT_AUTO_ALLOWED` from its declaring module (`bash-program-position`) and the classifier's dead re-export is retired. The built dist has no path from the classifier back to fs-bash; the cli's import order loads. Pins (`test/backlog918-module-entry-order.test.ts`): the cli order in a fresh process, every `src/tools/fs` module as a first entry (one fresh `node --import tsx` process each), and no runtime `runner/` import in the bounded-await helper.
|
|
38
|
+
- **Reach.** No wire, no export change (`traceHookCrash` was never public), no behaviour; a host importing engine modules directly in any order now loads them.
|
|
39
|
+
|
|
40
|
+
### Write protection — the NTFS data-stream strip is the reference fold's `:.*$` pattern; a stream name carrying a line terminator is a STATED miss (#912, ruling C-R66 (b))
|
|
41
|
+
- **Before.** The win32 reading cut the stream suffix by index (`indexOf(":")`), one notch stricter than the reference: `name:stream\nrest` was stripped to `name`, where the reference's regexp `.` stops at the terminator and names nothing.
|
|
42
|
+
- **After.** The cut is the reference's `/:.*$/`. `\.bashrc:evil` still names `.bashrc`; `\.bashrc:evil\nrest` (and `\r`, U+2028, U+2029) no longer does — the reference misses that spelling the same way, and alignment was the ruling. Pin `test/backlog841-invisible-and-stream-fold.test.ts` ⑥ holds the aligned form.
|
|
43
|
+
- **Reach.** One fewer question on exactly that control-character spelling, win32 readings only; no wire key, no closed set, no export change. Headless: that spelling's overwrite goes from refused to executed under the write-protection table (the opt-in deny set and the data-root passthrough are untouched).
|
|
44
|
+
|
|
3
45
|
## 7.23.0 — 2026-09-18
|
|
4
46
|
|
|
5
47
|
### Gates — every script's CLI entry guard folded into ONE helper; the roster runner no longer reads silence as green (#915; `gate:all` and its 21 guarded children; no product code)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { traceHookCrash } from "./
|
|
1
|
+
import { traceHookCrash } from "./gate-crash-trace.js";
|
|
2
2
|
import type { WriteProtectionVerdict } from "./write-protect.js";
|
|
3
3
|
/**
|
|
4
4
|
* design/77 §4: resolve `p`, but REJECT (so the caller catches and fails closed) if it hasn't settled within
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ToolGateInput } from "./hooks.js";
|
|
2
|
+
import type { SafeNotifier } from "./safe-notify.js";
|
|
3
|
+
/** Hand the crash itself to the deployment's trace lane (`onError(phase:"hook")`), never letting a
|
|
4
|
+
* throwing sink alter the fail-closed outcome — the same swallow posture as every other observer here.
|
|
5
|
+
* Shared by the gate's dispatch (the phase-1 crash arm, the seat-bound refusal), the reversibility probe's
|
|
6
|
+
* two failure arms, the approved-edit re-screen and the bounded-await deadline. */
|
|
7
|
+
export declare function traceHookCrash(input: ToolGateInput, err: unknown, notifier: SafeNotifier): void;
|
package/dist/core/gate-fold.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { inlineUntrusted } from "./untrusted-text.js";
|
|
2
2
|
import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
|
|
3
3
|
import { normalizeReadRootCandidate } from "./read-root-candidate.js";
|
|
4
|
-
import {
|
|
4
|
+
import {} from "./runner/gate-exit.js";
|
|
5
|
+
import { traceHookCrash } from "./gate-crash-trace.js";
|
|
5
6
|
import { writeProtectionMandated, writeProtectionTightenDecision, writeProtectionUnresolved } from "./write-protect.js";
|
|
6
7
|
import { createGateDeadline, awaitWriteProtectionJudgment, gateWaitFailureOf, withGateTimeout } from "./gate-bounded-await.js";
|
|
7
8
|
export async function runGateFold(pass) {
|
package/dist/core/gate-lanes.js
CHANGED
|
@@ -9,7 +9,8 @@ import { readOnlyShellVerdict } from "./read-only-shell.js";
|
|
|
9
9
|
import { COMMAND_RULE_TOOL } from "./permission-rule-model.js";
|
|
10
10
|
import { catalogRuleFaceOf } from "./tool-registry.js";
|
|
11
11
|
import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
|
|
12
|
-
import { exitGate
|
|
12
|
+
import { exitGate } from "./runner/gate-exit.js";
|
|
13
|
+
import { traceHookCrash } from "./gate-crash-trace.js";
|
|
13
14
|
import { createGateDeadline, awaitWriteProtectionJudgment } from "./gate-bounded-await.js";
|
|
14
15
|
function isPlainOwnRecord(x) {
|
|
15
16
|
try {
|
package/dist/core/hooks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { decisionText, refuseOutOfContractDecision } from "./tool-policy.js";
|
|
2
|
-
import { createSettlementLedger, engineFailClosedExit, hookDenyExit
|
|
2
|
+
import { createSettlementLedger, engineFailClosedExit, hookDenyExit } from "./runner/gate-exit.js";
|
|
3
|
+
import { traceHookCrash } from "./gate-crash-trace.js";
|
|
3
4
|
import { runGateFold } from "./gate-fold.js";
|
|
4
5
|
import { runGateLanes } from "./gate-lanes.js";
|
|
5
6
|
export { normalizeOrgGateVerdict, normalizePersistedRuleHit } from "./gate-lanes.js";
|
|
@@ -112,11 +112,6 @@ export declare function createSettlementLedger(call: {
|
|
|
112
112
|
* a non-object primitive passes through as-is (immutable anyway).
|
|
113
113
|
*/
|
|
114
114
|
export declare function cloneObserverInput(input: unknown): unknown;
|
|
115
|
-
/** Hand the crash itself to the deployment's trace lane (`onError(phase:"hook")`), never letting a
|
|
116
|
-
* throwing sink alter the fail-closed outcome — the same swallow posture as every other observer here.
|
|
117
|
-
* Shared by the gate's dispatch (the phase-1 crash arm, the seat-bound refusal), the reversibility probe's
|
|
118
|
-
* two failure arms and the approved-edit re-screen. */
|
|
119
|
-
export declare function traceHookCrash(input: ToolGateInput, err: unknown, notifier: SafeNotifier): void;
|
|
120
115
|
/**
|
|
121
116
|
* The PreToolUse screening face as the gate's stations see it — the hook machinery of `runToolGate` (the seat
|
|
122
117
|
* runner, the per-call seat bound, the fresh per-screening context, the result screen, the two fail-closed
|
|
@@ -76,9 +76,6 @@ export function cloneObserverInput(input) {
|
|
|
76
76
|
return input;
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
-
export function traceHookCrash(input, err, notifier) {
|
|
80
|
-
notifier.notify(() => input.onHookError?.(err), "toolGate.onHookError");
|
|
81
|
-
}
|
|
82
79
|
const PARK_FAILURE_CAUSE_MAX = 600;
|
|
83
80
|
function withParkFailureCause(reason, parkFailed) {
|
|
84
81
|
if (parkFailed === undefined)
|
|
@@ -66,8 +66,7 @@ function stripInvisibleFormat(s) {
|
|
|
66
66
|
}
|
|
67
67
|
function stripWin32Component(raw, normalization) {
|
|
68
68
|
const base = normalization.invisibleFirst ? stripInvisibleFormat(raw) : raw;
|
|
69
|
-
|
|
70
|
-
return (colon < 0 ? base : base.slice(0, colon)).replace(/[. ]+$/, "");
|
|
69
|
+
return (normalization.stream ? base.replace(/:.*$/, "") : base).replace(/[. ]+$/, "");
|
|
71
70
|
}
|
|
72
71
|
function foldWriteProtectName(name) {
|
|
73
72
|
return name.split("/").map(foldWriteProtectSegment).join("/");
|
|
@@ -92,20 +92,27 @@ export interface SegmentTokens {
|
|
|
92
92
|
/** What a substitution leaves in a word's FOLDED spelling: a marker no argv word can spell, so a consumer that
|
|
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
|
+
export type QuoteOpen = '"' | "'" | undefined;
|
|
95
96
|
/**
|
|
96
|
-
* Which
|
|
97
|
-
* quote characters themselves
|
|
97
|
+
* Which QUOTE holds each character of `s`, by the same pairing rule the lexer walks with, and the boolean view
|
|
98
|
+
* of it. The pairing quote characters themselves belong to the region they bound — they are not operators
|
|
99
|
+
* either way — so `openedBy` names that region's quote character at their own index too.
|
|
100
|
+
*
|
|
101
|
+
* `quoted` is a VIEW of `openedBy`, minted in the same step: a consumer asking only "is this inside quotes"
|
|
102
|
+
* reads it, one asking WHICH quote (single quotes suppress every expansion, double quotes suppress neither `$`
|
|
103
|
+
* nor the backtick) reads `openedBy`, and the two cannot drift.
|
|
98
104
|
*
|
|
99
105
|
* `balanced` is false when a region is still open at the end of the string. Callers must then NOT use the mask:
|
|
100
106
|
* an unterminated quote is a string bash itself cannot parse, and every consumer falls back to the quote-BLIND
|
|
101
107
|
* reading, which is the stricter one.
|
|
102
108
|
*
|
|
103
109
|
* EXACTNESS. This models bash only for the residual character space the strict faces operate in, where backslash
|
|
104
|
-
* escapes
|
|
105
|
-
*
|
|
106
|
-
*
|
|
110
|
+
* escapes and subshells are rejected outright — there `'` and `"` pairing is the whole of quoting, so a region
|
|
111
|
+
* computed here is exactly a region bash quotes. A caller deciding about `$` or a backtick on this mask owes
|
|
112
|
+
* the argument in `bash-readonly-classifier.ts`. It is NOT a general shell quoting parser.
|
|
107
113
|
*/
|
|
108
114
|
export declare function quoteMask(s: string): {
|
|
115
|
+
openedBy: readonly QuoteOpen[];
|
|
109
116
|
quoted: readonly boolean[];
|
|
110
117
|
balanced: boolean;
|
|
111
118
|
};
|
|
@@ -5,14 +5,17 @@ function quoteStep(open, ch) {
|
|
|
5
5
|
return open === ch ? undefined : open;
|
|
6
6
|
}
|
|
7
7
|
export function quoteMask(s) {
|
|
8
|
+
const openedBy = new Array(s.length).fill(undefined);
|
|
8
9
|
const quoted = new Array(s.length).fill(false);
|
|
9
10
|
let open;
|
|
10
11
|
for (let i = 0; i < s.length; i++) {
|
|
11
12
|
const before = open;
|
|
12
13
|
open = quoteStep(open, s[i]);
|
|
13
|
-
|
|
14
|
+
const holds = before ?? open;
|
|
15
|
+
openedBy[i] = holds;
|
|
16
|
+
quoted[i] = holds !== undefined;
|
|
14
17
|
}
|
|
15
|
-
return { quoted, balanced: open === undefined };
|
|
18
|
+
return { openedBy, quoted, balanced: open === undefined };
|
|
16
19
|
}
|
|
17
20
|
export function literalTildeSpelling(word) {
|
|
18
21
|
return word.startsWith("~") ? `./${word}` : word;
|
|
@@ -114,6 +114,9 @@ export type ProgramPositionRefusal = {
|
|
|
114
114
|
readonly kind: "control_syntax_at_program_position";
|
|
115
115
|
readonly owner: string;
|
|
116
116
|
readonly word: string;
|
|
117
|
+
/** Not a DECLINE ({@link programPositionRunsShell}): the row WAS read and a SHELL stands at the program
|
|
118
|
+
* position. Absent ⇒ genuine control syntax, which IS one. */
|
|
119
|
+
readonly runsShell?: true;
|
|
117
120
|
};
|
|
118
121
|
export interface ProgramPosition {
|
|
119
122
|
/** Indices of the bare control words stripped in front (`!`, `if`, `time`, …), in order; their declared options
|
|
@@ -159,5 +162,20 @@ export declare function readProgramPosition(toks: SegmentTokens): ProgramPositio
|
|
|
159
162
|
* stdin AND the program it runs is not one the caller accepts as a stream target. ONE reading, shared by every
|
|
160
163
|
* consumer; WHICH targets are accepted is the caller's declaration, not this reader's. */
|
|
161
164
|
export declare function argvReentryUnjudged(pos: ProgramPosition, acceptedTargets: ReadonlySet<string>): boolean;
|
|
165
|
+
/**
|
|
166
|
+
* The two ways this reader DECLINES rather than judges — a CLOSED set, because which of them a seat reads as
|
|
167
|
+
* evidence is a table each seat declares against (`fs-bash.ts`, where the argument is), not a boolean buried
|
|
168
|
+
* in a caller. `unreadable_program_position`: the walk cannot name the program bash will run (`env -- ls`).
|
|
169
|
+
* `stream_fed_operands`: the operands arrive on a STREAM it never saw (`… | xargs cat`). Neither names a WORD.
|
|
170
|
+
*/
|
|
171
|
+
export declare const SHELL_SCAN_DECLINES: readonly ["unreadable_program_position", "stream_fed_operands"];
|
|
172
|
+
export type ShellScanDecline = (typeof SHELL_SCAN_DECLINES)[number];
|
|
173
|
+
/** Does a SHELL stand where this segment's program would be — as the program itself (`sh -c`), or as a launcher
|
|
174
|
+
* ROW handing its operands to one (`watch "…"`)? ONE predicate for the re-entry class, so the two spellings
|
|
175
|
+
* cannot be answered differently; which names are shells is the caller's declaration. Not a decline. */
|
|
176
|
+
export declare function programPositionRunsShell(pos: ProgramPosition, shellProgram: RegExp): boolean;
|
|
177
|
+
/** Which decline, if any, this program position raises — ONE reading of the two facts above, so no consumer
|
|
178
|
+
* re-derives either. A refusal that says a SHELL runs is NOT one: it names what runs. */
|
|
179
|
+
export declare function shellScanDeclineOf(pos: ProgramPosition, acceptedStreamTargets: ReadonlySet<string>): ShellScanDecline | undefined;
|
|
162
180
|
/** ONE sentence per refusal kind, minted here and nowhere else. */
|
|
163
181
|
export declare function programPositionRefusalSentence(refusal: ProgramPositionRefusal): string;
|
|
@@ -107,7 +107,7 @@ export function readProgramPosition(toks) {
|
|
|
107
107
|
}
|
|
108
108
|
const launchers = [{ index: i, name: basename }];
|
|
109
109
|
const owner = head;
|
|
110
|
-
const shellRefusal = () => ({ kind: "control_syntax_at_program_position", owner, word: "sh" });
|
|
110
|
+
const shellRefusal = () => ({ kind: "control_syntax_at_program_position", owner, word: "sh", runsShell: true });
|
|
111
111
|
let owedOperands = LAUNCHER_TABLE.get(basename)?.requiredOperands ?? 0;
|
|
112
112
|
let lastRow = LAUNCHER_TABLE.get(basename);
|
|
113
113
|
const chain0 = (program, refusal) => ({ controlWords, written, assignments, launchers, argvFromStdin: false, runsDefaultProgram: false, program, refusal });
|
|
@@ -158,6 +158,18 @@ export function argvReentryUnjudged(pos, acceptedTargets) {
|
|
|
158
158
|
const runs = pos.program?.basename ?? (pos.runsDefaultProgram ? ARGV_REENTRY_DEFAULT_PROGRAM : undefined);
|
|
159
159
|
return runs === undefined || !acceptedTargets.has(runs);
|
|
160
160
|
}
|
|
161
|
+
export const SHELL_SCAN_DECLINES = ["unreadable_program_position", "stream_fed_operands"];
|
|
162
|
+
export function programPositionRunsShell(pos, shellProgram) {
|
|
163
|
+
if (pos.refusal !== undefined)
|
|
164
|
+
return pos.refusal.kind === "control_syntax_at_program_position" && pos.refusal.runsShell === true;
|
|
165
|
+
return pos.program !== undefined && shellProgram.test(pos.program.basename);
|
|
166
|
+
}
|
|
167
|
+
export function shellScanDeclineOf(pos, acceptedStreamTargets) {
|
|
168
|
+
if (pos.refusal !== undefined) {
|
|
169
|
+
return pos.refusal.kind === "control_syntax_at_program_position" && pos.refusal.runsShell === true ? undefined : "unreadable_program_position";
|
|
170
|
+
}
|
|
171
|
+
return argvReentryUnjudged(pos, acceptedStreamTargets) ? "stream_fed_operands" : undefined;
|
|
172
|
+
}
|
|
161
173
|
export function programPositionRefusalSentence(refusal) {
|
|
162
174
|
switch (refusal.kind) {
|
|
163
175
|
case "option_at_program_position":
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ShellSegmentConnector } from "./bash-lexer.js";
|
|
2
|
+
import { type ShellScanDecline } from "./bash-program-position.js";
|
|
2
3
|
/**
|
|
3
4
|
* Bare command names `bash_readonly` permits out of the box, ALSO the default reversible set for the
|
|
4
5
|
* The {@link import("./fs-bash.js").bashReversibilityProbe} classifier. **Coarse first filter, NOT a security
|
|
@@ -31,7 +32,6 @@ import { type ShellSegmentConnector } from "./bash-lexer.js";
|
|
|
31
32
|
* value-taking options defeat the stdin-floor operand count without a per-verb option-value model
|
|
32
33
|
* — those are a follow-on slice, not a silent drop.
|
|
33
34
|
*/
|
|
34
|
-
export { NOT_AUTO_ALLOWED } from "./bash-program-position.js";
|
|
35
35
|
export type { ShellSegmentConnector } from "./bash-lexer.js";
|
|
36
36
|
export declare const BASH_READONLY_DEFAULT_ALLOW: readonly string[];
|
|
37
37
|
/**
|
|
@@ -174,7 +174,7 @@ export interface ShellCompoundSegments {
|
|
|
174
174
|
* drift in a segmentation is drift in what a deny rule is even looking at.
|
|
175
175
|
*
|
|
176
176
|
* Three steps, in this order and load-bearing:
|
|
177
|
-
* 1. WHOLE-STRING hard reject
|
|
177
|
+
* 1. WHOLE-STRING hard reject ({@link hardRejectReason}) — redirection, substitution,
|
|
178
178
|
* subshells, escapes, line breaks. This is what makes step 3's quote mask EXACT: with those
|
|
179
179
|
* characters gone, `'`/`"` pairing is the entirety of quoting, so the mask's regions are exactly
|
|
180
180
|
* bash's quoted regions and the unquoted connectors are exactly bash's command boundaries. Callers
|
|
@@ -524,7 +524,10 @@ export declare function resolveOperandSpellings(base: string | undefined, operan
|
|
|
524
524
|
* here-document, a here-string or a literal in-root stdin file; a directory change (which then makes every relative
|
|
525
525
|
* stdin literal in the command unjudged). A substitution body (`$(…)`, backticks, `<(…)`) is judged on its own: green
|
|
526
526
|
* under the compound walk, or exempt under this whitelist; a here-document body is text, not commands. */
|
|
527
|
-
export declare function refusedCommandMayRead(command: string, allow: ReadonlySet<string>, boundary
|
|
527
|
+
export declare function refusedCommandMayRead(command: string, allow: ReadonlySet<string>, boundary: BashReadonlyRootBoundary | undefined,
|
|
528
|
+
/** The {@link ShellScanDecline} members THIS SEAT reads as evidence (tables and argument: `fs-bash.ts`); one
|
|
529
|
+
* the table omits ends the scan with "exempt". */
|
|
530
|
+
declinesAreEvidence: ReadonlySet<ShellScanDecline>): boolean;
|
|
528
531
|
/**
|
|
529
532
|
* The home directory's VARIABLE spellings, SUBSTITUTED with the declared value before a read face segments the
|
|
530
533
|
* command: `$HOME` / `${HOME}` at the START of a word (an empty quote pair before it included), unquoted or
|
|
@@ -602,6 +605,10 @@ export interface OutOfRootReadGateVerdict {
|
|
|
602
605
|
* operand is placed against a base bash has already left, so no path can be named honestly. Read it
|
|
603
606
|
* as display/triage evidence; emptiness is not "not gated". */
|
|
604
607
|
readonly paths: readonly string[];
|
|
608
|
+
/** Did the deployment's DENY judge speak about an operand this command names? The other half of the same
|
|
609
|
+
* question, over the very same readers, in a second pass WITH the deny judge — which is what lets the
|
|
610
|
+
* containment answer above stay deny-free and keep its `paths` contract. Absent ⇔ it said nothing. */
|
|
611
|
+
readonly denied?: true;
|
|
605
612
|
}
|
|
606
613
|
/** Test seam for the lockstep pin that the launcher table (the ONE launcher truth, in the program-position
|
|
607
614
|
* reader's module) stays a SUBSET of the rule lane's curated `BARE_INTERPRETER_NAMES`. Exported for that one
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { hasWin32AliasedComponent, isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf, withinAnyRoot } from "./safety.js";
|
|
2
2
|
import { lexShellText, literalTildeSpelling, quoteMask, substitutionsInHeredocBody, SUBSTITUTION_PLACEHOLDER, wordsOf, } from "./bash-lexer.js";
|
|
3
|
-
import { ASSIGNMENT_WORD, COMMAND_LAUNCHERS, NOT_AUTO_ALLOWED, SHELL_BLOCK_WORDS, argvReentryUnjudged, isBareControlWord, programBasename, programPositionRefusalSentence, readProgramPosition, } from "./bash-program-position.js";
|
|
4
|
-
export { NOT_AUTO_ALLOWED } from "./bash-program-position.js";
|
|
3
|
+
import { ASSIGNMENT_WORD, COMMAND_LAUNCHERS, NOT_AUTO_ALLOWED, SHELL_BLOCK_WORDS, argvReentryUnjudged, isBareControlWord, programPositionRunsShell, shellScanDeclineOf, programBasename, programPositionRefusalSentence, readProgramPosition, } from "./bash-program-position.js";
|
|
5
4
|
export const BASH_READONLY_DEFAULT_ALLOW = [
|
|
6
5
|
"ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
|
|
7
6
|
"grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
|
|
@@ -52,7 +51,8 @@ export function coarseReadonlyCheck(command, allow, options) {
|
|
|
52
51
|
return `command "${parsed.name}" is not in the read-only allowlist`;
|
|
53
52
|
return undefined;
|
|
54
53
|
}
|
|
55
|
-
const
|
|
54
|
+
const SHELL_SEGMENT_QUOTE_BLIND_REJECT = /[<>()`\n\r\\]/;
|
|
55
|
+
const SHELL_EXPANSION_CHARS = /[$]/;
|
|
56
56
|
export function carriesShellRedirection(text) {
|
|
57
57
|
return SHELL_REDIRECTION_OPERATORS.test(text);
|
|
58
58
|
}
|
|
@@ -78,10 +78,25 @@ function rejectedSansRedirection(source) {
|
|
|
78
78
|
if (redirectionPrecedesConnector(source)) {
|
|
79
79
|
return "a redirection followed by `|`, `&` or `;` is not allowed — the operator's own characters cannot be told apart from a command boundary";
|
|
80
80
|
}
|
|
81
|
+
return hardRejectReason(source, true);
|
|
82
|
+
}
|
|
83
|
+
function hardRejectReason(source, tolerateRedirection) {
|
|
84
|
+
const reason = tolerateRedirection
|
|
85
|
+
? "command/variable substitution, subshells, escapes, and line breaks are not allowed"
|
|
86
|
+
: "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed";
|
|
81
87
|
for (const ch of source) {
|
|
82
|
-
if (
|
|
83
|
-
return
|
|
84
|
-
|
|
88
|
+
if (SHELL_SEGMENT_QUOTE_BLIND_REJECT.test(ch) && !(tolerateRedirection && SHELL_REDIRECTION_OPERATORS.test(ch)))
|
|
89
|
+
return reason;
|
|
90
|
+
}
|
|
91
|
+
if (!SHELL_EXPANSION_CHARS.test(source))
|
|
92
|
+
return undefined;
|
|
93
|
+
const mask = quoteMask(source);
|
|
94
|
+
for (let i = 0; i < source.length; i++) {
|
|
95
|
+
if (!SHELL_EXPANSION_CHARS.test(source[i]))
|
|
96
|
+
continue;
|
|
97
|
+
if (mask.balanced && mask.openedBy[i] === "'")
|
|
98
|
+
continue;
|
|
99
|
+
return reason;
|
|
85
100
|
}
|
|
86
101
|
return undefined;
|
|
87
102
|
}
|
|
@@ -91,8 +106,10 @@ export function splitShellCompoundSegments(source, options) {
|
|
|
91
106
|
if (refused !== undefined)
|
|
92
107
|
return { reject: refused };
|
|
93
108
|
}
|
|
94
|
-
else
|
|
95
|
-
|
|
109
|
+
else {
|
|
110
|
+
const refused = hardRejectReason(source, false);
|
|
111
|
+
if (refused !== undefined)
|
|
112
|
+
return { reject: refused };
|
|
96
113
|
}
|
|
97
114
|
const scanned = options?.trailingTerminator !== "keep" ? source.replace(/;\s*$/, "") : source;
|
|
98
115
|
const lexed = lexShellText(scanned, { mode: "strict" });
|
|
@@ -239,8 +256,8 @@ function tokensMoveBase(toks) {
|
|
|
239
256
|
return pos.refusal !== undefined || pos.program?.basename === "cd";
|
|
240
257
|
}
|
|
241
258
|
const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi|a|mk|ya|pdk|c|tc)?sh|eval|source|\.|parallel)$/;
|
|
242
|
-
export function refusedCommandMayRead(command, allow, boundary) {
|
|
243
|
-
return !refusedCommandExempt(command, allow, boundary, 0);
|
|
259
|
+
export function refusedCommandMayRead(command, allow, boundary, declinesAreEvidence) {
|
|
260
|
+
return !refusedCommandExempt(command, allow, boundary, declinesAreEvidence, 0);
|
|
244
261
|
}
|
|
245
262
|
const STDIN_ONLY_READER_FLAGS = new Map([
|
|
246
263
|
["cat", /^-[AbeEnstTuv]+$/],
|
|
@@ -262,7 +279,7 @@ function whitelistSegment(segment) {
|
|
|
262
279
|
redirects: tokens.filter((t) => t.kind === "redirect"),
|
|
263
280
|
};
|
|
264
281
|
}
|
|
265
|
-
function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside = false) {
|
|
282
|
+
function refusedCommandExempt(command, allow, boundary, declinesAreEvidence, depth, baseMovedOutside = false) {
|
|
266
283
|
if (depth > 4)
|
|
267
284
|
return false;
|
|
268
285
|
const lexed = lexShellText(command, { mode: "lenient" });
|
|
@@ -282,12 +299,21 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
|
|
|
282
299
|
}
|
|
283
300
|
segments.push(whitelistSegment(segment));
|
|
284
301
|
}
|
|
302
|
+
let declined;
|
|
303
|
+
for (const { words } of segments) {
|
|
304
|
+
const pos = readProgramPosition(words);
|
|
305
|
+
if (programPositionRunsShell(pos, SHELL_REENTRY_PROGRAM_RE))
|
|
306
|
+
return false;
|
|
307
|
+
const decline = shellScanDeclineOf(pos, XARGS_TARGETS_ACCEPTED_UNJUDGED);
|
|
308
|
+
if (decline !== undefined && declinesAreEvidence.has(decline))
|
|
309
|
+
return false;
|
|
310
|
+
declined ??= decline;
|
|
311
|
+
}
|
|
312
|
+
if (declined !== undefined)
|
|
313
|
+
return true;
|
|
285
314
|
const baseMoved = baseMovedOutside ||
|
|
286
315
|
segments.some(({ words }) => {
|
|
287
|
-
const
|
|
288
|
-
if (pos.refusal !== undefined)
|
|
289
|
-
return true;
|
|
290
|
-
const runs = pos.program?.basename;
|
|
316
|
+
const runs = readProgramPosition(words).program?.basename;
|
|
291
317
|
return runs === "cd" || (runs !== undefined && UNMODELLED_BASE_MOVERS.has(runs));
|
|
292
318
|
});
|
|
293
319
|
for (const body of bodies) {
|
|
@@ -296,7 +322,7 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
|
|
|
296
322
|
(walked.undecidedPaths?.length ?? 0) === 0 && (walked.unresolvedOperands?.length ?? 0) === 0 && (walked.recursiveReadPaths?.length ?? 0) === 0;
|
|
297
323
|
if (fullyGreen)
|
|
298
324
|
continue;
|
|
299
|
-
if (!refusedCommandExempt(body, allow, boundary, depth + 1, baseMoved))
|
|
325
|
+
if (!refusedCommandExempt(body, allow, boundary, declinesAreEvidence, depth + 1, baseMoved))
|
|
300
326
|
return false;
|
|
301
327
|
}
|
|
302
328
|
const stdinFileJudgedInside = (word) => {
|
|
@@ -353,8 +379,6 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
|
|
|
353
379
|
if (r.op === "<" && !stdinFileJudgedInside(r.target?.folded))
|
|
354
380
|
return false;
|
|
355
381
|
const pos = readProgramPosition(words);
|
|
356
|
-
if (pos.refusal !== undefined)
|
|
357
|
-
return false;
|
|
358
382
|
for (const k of words.folded.keys()) {
|
|
359
383
|
const word = words.folded[k];
|
|
360
384
|
if (ASSIGNMENT_WORD.test(word) && /\$\(|`/.test(word) && (pos.assignments.includes(k) || pos.launchers.some((l) => l.index < k)))
|
|
@@ -365,24 +389,16 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
|
|
|
365
389
|
if (program === undefined) {
|
|
366
390
|
if (pos.launchers.length === 0)
|
|
367
391
|
continue;
|
|
368
|
-
if (argvReentryUnjudged(pos, XARGS_TARGETS_ACCEPTED_UNJUDGED))
|
|
369
|
-
return false;
|
|
370
392
|
if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index, pos.assignments))
|
|
371
393
|
return false;
|
|
372
394
|
continue;
|
|
373
395
|
}
|
|
374
396
|
if (program.name.includes(SUBSTITUTION_PLACEHOLDER) || /[$`]/.test(program.name))
|
|
375
397
|
return false;
|
|
376
|
-
if (
|
|
377
|
-
|
|
378
|
-
if (pos.launchers.length > 0) {
|
|
379
|
-
if (argvReentryUnjudged(pos, XARGS_TARGETS_ACCEPTED_UNJUDGED))
|
|
398
|
+
if (pos.launchers.length > 0 && !listed(program.basename)) {
|
|
399
|
+
if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index, pos.assignments))
|
|
380
400
|
return false;
|
|
381
|
-
|
|
382
|
-
if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index, pos.assignments))
|
|
383
|
-
return false;
|
|
384
|
-
continue;
|
|
385
|
-
}
|
|
401
|
+
continue;
|
|
386
402
|
}
|
|
387
403
|
const head = program.basename;
|
|
388
404
|
if (head === "cd" || UNMODELLED_BASE_MOVERS.has(head))
|
|
@@ -455,12 +471,12 @@ function longOptionNameOf(tok) {
|
|
|
455
471
|
function isLongOptionAbbrevOf(name, full) {
|
|
456
472
|
return full.startsWith(name);
|
|
457
473
|
}
|
|
458
|
-
function
|
|
474
|
+
function suppliesFirstOperandSlot(slot, tok) {
|
|
459
475
|
if (tok.startsWith("--")) {
|
|
460
476
|
const long = longOptionNameOf(tok);
|
|
461
|
-
return long !== undefined && (
|
|
477
|
+
return long !== undefined && slot.suppliedByLongNames.some((full) => isLongOptionAbbrevOf(long, full));
|
|
462
478
|
}
|
|
463
|
-
return
|
|
479
|
+
return new RegExp(`^-[A-Za-z0-9]*[${slot.suppliedByLetters}]`).test(tok);
|
|
464
480
|
}
|
|
465
481
|
function isGrepClusterFileStdin(tok) {
|
|
466
482
|
if (tok.startsWith("--") || !/^-[A-Za-z0-9]/.test(tok))
|
|
@@ -534,6 +550,7 @@ const VERB_OPTION_SLOTS = {
|
|
|
534
550
|
regexp: "pattern",
|
|
535
551
|
},
|
|
536
552
|
longOptionalValue: { color: "label", colour: "label", context: "count" },
|
|
553
|
+
firstOperand: { kind: "pattern", suppliedByLetters: "ef", suppliedByLongNames: ["regexp", "file"] },
|
|
537
554
|
longPlain: [
|
|
538
555
|
"basic-regexp", "byte-offset", "count", "dereference-recursive", "extended-regexp",
|
|
539
556
|
"files-with-matches", "files-without-match", "fixed-strings", "help", "ignore-case", "initial-tab",
|
|
@@ -579,6 +596,7 @@ const VERB_OPTION_SLOTS = {
|
|
|
579
596
|
shortPlain: "nrEszu",
|
|
580
597
|
longValue: { expression: "script", file: "path", "line-length": "count" },
|
|
581
598
|
longOptionalValue: { "in-place": "label" },
|
|
599
|
+
firstOperand: { kind: "script", suppliedByLetters: "ef", suppliedByLongNames: ["expression", "file"] },
|
|
582
600
|
longPlain: [
|
|
583
601
|
"debug", "follow-symlinks", "help", "null-data", "posix", "quiet", "regexp-extended", "sandbox",
|
|
584
602
|
"separate", "silent", "unbuffered", "version", "zero-terminated",
|
|
@@ -1670,7 +1688,8 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
1670
1688
|
}
|
|
1671
1689
|
else {
|
|
1672
1690
|
const eoo = args.indexOf("--");
|
|
1673
|
-
const
|
|
1691
|
+
const firstOperandSlot = VERB_OPTION_SLOTS[name]?.firstOperand;
|
|
1692
|
+
const firstOperandSuppliedByFlag = firstOperandSlot !== undefined && (eoo === -1 ? args : args.slice(0, eoo)).some((t) => suppliesFirstOperandSlot(firstOperandSlot, t));
|
|
1674
1693
|
let sawOperand = false;
|
|
1675
1694
|
let sawArgvOperand = false;
|
|
1676
1695
|
let endOfOptions = false;
|
|
@@ -1718,12 +1737,12 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
1718
1737
|
continue;
|
|
1719
1738
|
}
|
|
1720
1739
|
}
|
|
1721
|
-
const
|
|
1740
|
+
const inFirstOperandSlot = firstOperandSlot !== undefined && !firstOperandSuppliedByFlag && !sawOperand;
|
|
1722
1741
|
sawOperand = true;
|
|
1723
1742
|
if (k >= argvOperandsStartAt)
|
|
1724
1743
|
sawArgvOperand = true;
|
|
1725
1744
|
if (t === "-") {
|
|
1726
|
-
if (
|
|
1745
|
+
if (inFirstOperandSlot)
|
|
1727
1746
|
continue;
|
|
1728
1747
|
const m = RECURSIVE_READ_FORMS[slotVerb];
|
|
1729
1748
|
if (m === undefined || m.dashIsStdin === true) {
|
|
@@ -1734,8 +1753,14 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
1734
1753
|
positionalOperands.push(t);
|
|
1735
1754
|
continue;
|
|
1736
1755
|
}
|
|
1737
|
-
if (
|
|
1756
|
+
if (inFirstOperandSlot) {
|
|
1757
|
+
const named = slotValueCandidates(t, firstOperandSlot.kind);
|
|
1758
|
+
for (const c of named)
|
|
1759
|
+
judgeOperand(c, argGlobs(k));
|
|
1760
|
+
if (named.length > 0 && k >= argvOperandsStartAt)
|
|
1761
|
+
positionalOperands.push(t);
|
|
1738
1762
|
continue;
|
|
1763
|
+
}
|
|
1739
1764
|
if (k >= argvOperandsStartAt)
|
|
1740
1765
|
positionalOperands.push(t);
|
|
1741
1766
|
judgeOperand(t, argGlobs(k));
|
|
@@ -1987,7 +2012,8 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
|
|
|
1987
2012
|
if (!(pipeFed[si] ?? false)) {
|
|
1988
2013
|
const floor = STDIN_FILE_FLOOR[name];
|
|
1989
2014
|
const restArgs = toks.slice(1);
|
|
1990
|
-
const
|
|
2015
|
+
const floorFirstOperandSlot = VERB_OPTION_SLOTS[name]?.firstOperand;
|
|
2016
|
+
const floorSlotSuppliedByFlag = floorFirstOperandSlot !== undefined && restArgs.some((t) => suppliesFirstOperandSlot(floorFirstOperandSlot, t));
|
|
1991
2017
|
let sawNonFlagOperand = false;
|
|
1992
2018
|
let hasStdinDash = false;
|
|
1993
2019
|
let operandCount = 0;
|
|
@@ -2013,10 +2039,10 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
|
|
|
2013
2039
|
if (t.startsWith("-") && t !== "-")
|
|
2014
2040
|
continue;
|
|
2015
2041
|
}
|
|
2016
|
-
const
|
|
2042
|
+
const inFirstOperandSlot = floorFirstOperandSlot !== undefined && !floorSlotSuppliedByFlag && !sawNonFlagOperand;
|
|
2017
2043
|
sawNonFlagOperand = true;
|
|
2018
2044
|
operandCount++;
|
|
2019
|
-
if (
|
|
2045
|
+
if (inFirstOperandSlot)
|
|
2020
2046
|
continue;
|
|
2021
2047
|
if (t === "-") {
|
|
2022
2048
|
hasStdinDash = true;
|
|
@@ -2329,10 +2355,13 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
|
|
|
2329
2355
|
if (baseMoved)
|
|
2330
2356
|
return { gated: true, paths: [] };
|
|
2331
2357
|
const verdict = evaluateReadBoundary(readers, containmentOnly, false, readerReach);
|
|
2332
|
-
|
|
2358
|
+
const denied = boundary.denyMatch === undefined ? false : evaluateReadBoundary(readers, boundary, false, readerReach).readDenied === true;
|
|
2359
|
+
if (verdict.outOfRootRead === true)
|
|
2360
|
+
return { gated: true, paths: verdict.outOfRootPaths ?? [], ...(denied ? { denied: true } : {}) };
|
|
2361
|
+
return denied ? { gated: false, paths: [], denied: true } : NOT_GATED;
|
|
2333
2362
|
};
|
|
2334
2363
|
const direct = judge(command);
|
|
2335
|
-
if (direct.gated)
|
|
2364
|
+
if (direct.gated || direct.denied === true)
|
|
2336
2365
|
return direct;
|
|
2337
2366
|
const shape = POLL_LOOP_SHAPE.exec(command.trim());
|
|
2338
2367
|
return shape !== null ? judge(shape[3]) : NOT_GATED;
|
|
@@ -3,6 +3,7 @@ import { type TaskRegistry } from "../../core/task-registry.js";
|
|
|
3
3
|
import { type CwdRef } from "./fs-shared.js";
|
|
4
4
|
import type { ReversibilityVerdict } from "../../core/types.js";
|
|
5
5
|
import { type BashReadonlyRootBoundary } from "./bash-readonly-classifier.js";
|
|
6
|
+
import { type ShellScanDecline } from "./bash-program-position.js";
|
|
6
7
|
/**
|
|
7
8
|
* A parsed-command classifier for the `bash` tool, exposed as a
|
|
8
9
|
* `ToolSpec.reversibilityProbe`. A full shell is treated as egress+irreversible by DEFAULT; this probe is the
|
|
@@ -60,6 +61,27 @@ import { type BashReadonlyRootBoundary } from "./bash-readonly-classifier.js";
|
|
|
60
61
|
* shape classifier here changes what happens to the residual shell risk, never that reading.
|
|
61
62
|
*/
|
|
62
63
|
export declare function bashReversibilityProbe(allow?: readonly string[], boundary?: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined)): (args: unknown) => ReversibilityVerdict;
|
|
64
|
+
/**
|
|
65
|
+
* ONE rule, TWO TABLES: which {@link ShellScanDecline} a SEAT reads as read evidence. A decline names no word,
|
|
66
|
+
* so it is never the boundary DECLARING anything about a path.
|
|
67
|
+
*
|
|
68
|
+
* The READ-BOUNDARY seat is what the default/off doctrine and a bypass mode mount ALONE: there "not mandated"
|
|
69
|
+
* is VOUCHED, so a decline must count as evidence or a read nobody judged runs silently (`<out-of-root> |
|
|
70
|
+
* xargs cat` at zero asks — a closed P0). It keeps both. The CLASSIFY seat composes the same boundary with the
|
|
71
|
+
* SHAPE classifier, which refuses all these shapes anyway: the only question left is whether a stored answer
|
|
72
|
+
* may retire the ask. It keeps NEITHER — empty by the argument, not by omission.
|
|
73
|
+
* THE COST, stated rather than mitigated: once that ask is retired, an out-of-root or deny-listed read HIDDEN
|
|
74
|
+
* inside such a command is not asked again. A command's words are not the argv its programs receive (a stream
|
|
75
|
+
* is re-split by its consumer, an unread option may move the working directory first, stdin text is a command
|
|
76
|
+
* line nobody parsed), so a scan of them cannot be the guarantee it was claimed to be — three independently
|
|
77
|
+
* exploitable shapes measured that. "A read the boundary objects to is never retired by a stored rule" does
|
|
78
|
+
* NOT hold on the classify seat for this class, and holds unchanged elsewhere; below it is the execution
|
|
79
|
+
* environment's sandbox. One word-level check survives on both seats and is not part of the trade: a token the
|
|
80
|
+
* scan DID read naming a path outside the roots still gates the call ({@link classifyOutOfRootReadGate}).
|
|
81
|
+
*/
|
|
82
|
+
export declare const BOUNDARY_SEAT_DECLINE_EVIDENCE: ReadonlySet<ShellScanDecline>;
|
|
83
|
+
/** The classify doctrine's table — see above; empty by the argument. */
|
|
84
|
+
export declare const CLASSIFY_SEAT_DECLINE_EVIDENCE: ReadonlySet<ShellScanDecline>;
|
|
63
85
|
/**
|
|
64
86
|
* The READ-BOUNDARY seat of the full shell: a `ToolSpec.reversibilityProbe` that answers exactly ONE
|
|
65
87
|
* question — does the deployment's read boundary object to where this command reads? — and nothing about
|
package/dist/tools/fs/fs-bash.js
CHANGED
|
@@ -13,7 +13,8 @@ import { boundaryIsTheSandbox, canonicalPathIsAuthoritative, hasDestroy, isIsola
|
|
|
13
13
|
import { ghRateLimitHint } from "./gh-rate-limit.js";
|
|
14
14
|
import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashTimeoutArgRefusal, bashTimeoutParamDescription, envErrorDetail, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, createShellOverflowSpoolFence, shellRecoveryHint, CWD_SENTINEL, stripCwdSentinel, BASH_READONLY_CONFINEMENT_NOTE, } from "./fs-shared.js";
|
|
15
15
|
import { PROBE_CAUSE_PATH_MAX, inlineUntrusted } from "../../core/untrusted-text.js";
|
|
16
|
-
import { bashClassifyDefaultAllow, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoopDetailed, classifyCompoundReadonlyDetailed, classifyOutOfRootReadGate, refusedCommandMayRead, classifySimpleCommandReadBoundary,
|
|
16
|
+
import { bashClassifyDefaultAllow, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoopDetailed, classifyCompoundReadonlyDetailed, classifyOutOfRootReadGate, refusedCommandMayRead, classifySimpleCommandReadBoundary, } from "./bash-readonly-classifier.js";
|
|
17
|
+
import { NOT_AUTO_ALLOWED, SHELL_SCAN_DECLINES } from "./bash-program-position.js";
|
|
17
18
|
import { toolFace } from "../../core/tool-catalog-entries.js";
|
|
18
19
|
const RECURSIVE_CAUSE_MAX_PATHS = 3;
|
|
19
20
|
const RECURSIVE_READ_CAUSE_CODE = "shell.recursive_read_unbounded";
|
|
@@ -27,8 +28,8 @@ function operandFamily(paths) {
|
|
|
27
28
|
export function bashReversibilityProbe(allow, boundary) {
|
|
28
29
|
const allowSet = new Set(allow ?? bashClassifyDefaultAllow());
|
|
29
30
|
const judge = (args, command, resolved, offers) => {
|
|
30
|
-
const boundaryGate = (verdict) => readBoundaryMandate(command, allowSet, resolved, verdict);
|
|
31
|
-
const outOfRootGate = () => readBoundaryMandate(command, allowSet, resolved, {});
|
|
31
|
+
const boundaryGate = (verdict) => readBoundaryMandate(command, allowSet, resolved, verdict, CLASSIFY_SEAT_DECLINE_EVIDENCE);
|
|
32
|
+
const outOfRootGate = () => readBoundaryMandate(command, allowSet, resolved, {}, CLASSIFY_SEAT_DECLINE_EVIDENCE);
|
|
32
33
|
const backgrounded = isBackgroundShellCall(args);
|
|
33
34
|
const detailed = classifyCompoundReadonlyDetailed(command, allowSet, resolved);
|
|
34
35
|
if (backgrounded)
|
|
@@ -46,7 +47,7 @@ export function bashReversibilityProbe(allow, boundary) {
|
|
|
46
47
|
if (loop.reason === undefined)
|
|
47
48
|
return { reversible: true };
|
|
48
49
|
const seatOffer = (verdict) => (offers ? readRootCandidateOf(verdict) : {});
|
|
49
|
-
if (boundaryDeclared(loop) || unjudgedRead(command, allowSet, resolved, loop))
|
|
50
|
+
if (boundaryDeclared(loop) || unjudgedRead(command, allowSet, resolved, loop, CLASSIFY_SEAT_DECLINE_EVIDENCE))
|
|
50
51
|
return { reversible: false, mandated: true, ...seatOffer(loop) };
|
|
51
52
|
const refusedCause = recursiveReadCause(detailed);
|
|
52
53
|
return { reversible: false, ...boundaryGate(detailed), ...seatOffer(detailed), ...(refusedCause !== undefined ? { cause: refusedCause } : {}) };
|
|
@@ -86,24 +87,30 @@ function shellCommandOf(args) {
|
|
|
86
87
|
function isBackgroundShellCall(args) {
|
|
87
88
|
return args?.run_in_background === true;
|
|
88
89
|
}
|
|
89
|
-
|
|
90
|
+
export const BOUNDARY_SEAT_DECLINE_EVIDENCE = new Set(SHELL_SCAN_DECLINES);
|
|
91
|
+
export const CLASSIFY_SEAT_DECLINE_EVIDENCE = new Set();
|
|
92
|
+
function readBoundaryMandate(command, allowSet, resolved, verdict, declineEvidence) {
|
|
90
93
|
if (resolved === undefined)
|
|
91
94
|
return {};
|
|
92
|
-
if (denyJudgeSpoke(verdict) || unjudgedRead(command, allowSet, resolved, verdict))
|
|
95
|
+
if (denyJudgeSpoke(verdict) || unjudgedRead(command, allowSet, resolved, verdict, declineEvidence))
|
|
93
96
|
return { mandated: true };
|
|
94
|
-
|
|
97
|
+
const named = classifyOutOfRootReadGate(command, allowSet, resolved);
|
|
98
|
+
return named.gated || named.denied === true ? { mandated: true } : {};
|
|
95
99
|
}
|
|
96
100
|
function boundaryUnjudged(verdict) {
|
|
97
101
|
return verdict.refusedWhole === true || (verdict.unresolvedOperands !== undefined && verdict.unresolvedOperands.length > 0);
|
|
98
102
|
}
|
|
99
|
-
function unjudgedRead(command, allowSet, resolved, verdict) {
|
|
103
|
+
function unjudgedRead(command, allowSet, resolved, verdict, declineEvidence) {
|
|
100
104
|
if (verdict.unresolvedOperands !== undefined && verdict.unresolvedOperands.length > 0)
|
|
101
105
|
return true;
|
|
102
106
|
if (verdict.refusedWhole !== true)
|
|
103
107
|
return false;
|
|
104
|
-
if (refusedCommandMayRead(command, allowSet, resolved))
|
|
108
|
+
if (refusedCommandMayRead(command, allowSet, resolved, declineEvidence))
|
|
105
109
|
return true;
|
|
106
|
-
|
|
110
|
+
if (resolved === undefined)
|
|
111
|
+
return false;
|
|
112
|
+
const named = classifyOutOfRootReadGate(command, allowSet, resolved);
|
|
113
|
+
return named.gated || named.denied === true;
|
|
107
114
|
}
|
|
108
115
|
function recursiveReadCause(detailed) {
|
|
109
116
|
const recursive = detailed.recursiveReadPaths;
|
|
@@ -121,7 +128,7 @@ export function bashReadBoundaryProbe(boundary) {
|
|
|
121
128
|
const allowSet = new Set(bashClassifyDefaultAllow());
|
|
122
129
|
const judge = (command, resolved, offers) => {
|
|
123
130
|
const seatVerdict = (verdict) => {
|
|
124
|
-
if (boundaryDeclared(verdict) || unjudgedRead(command, allowSet, resolved, verdict)) {
|
|
131
|
+
if (boundaryDeclared(verdict) || unjudgedRead(command, allowSet, resolved, verdict, BOUNDARY_SEAT_DECLINE_EVIDENCE)) {
|
|
125
132
|
const cause = recursiveReadCause(verdict);
|
|
126
133
|
return { reversible: false, mandated: true, ...(cause !== undefined ? { cause } : {}) };
|
|
127
134
|
}
|
|
@@ -145,7 +152,7 @@ export function bashReadBoundaryProbe(boundary) {
|
|
|
145
152
|
const offerFrom = (verdict) => (offers ? readRootCandidateOf(verdict) : {});
|
|
146
153
|
if (mandated !== undefined)
|
|
147
154
|
return { ...mandated, ...offerFrom(detailed), ...offerFrom(loop) };
|
|
148
|
-
if (readBoundaryMandate(command, allowSet, resolved, detailed).mandated === true)
|
|
155
|
+
if (readBoundaryMandate(command, allowSet, resolved, detailed, BOUNDARY_SEAT_DECLINE_EVIDENCE).mandated === true)
|
|
149
156
|
return { reversible: false, mandated: true };
|
|
150
157
|
return compoundVerdict ?? loopVerdict ?? { reversible: true };
|
|
151
158
|
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
{
|
|
2
|
+
"repo": "core",
|
|
3
|
+
"version": "7.23.1",
|
|
4
|
+
"from": "0f45a3d257bcdb5f38faaa7c75a6fa1298685402",
|
|
5
|
+
"to": "v7.23.1",
|
|
6
|
+
"added": [],
|
|
7
|
+
"removed": [],
|
|
8
|
+
"changed": [],
|
|
9
|
+
"narrowed": [
|
|
10
|
+
{
|
|
11
|
+
"id": "F2",
|
|
12
|
+
"source": "behaviorFlips.summary",
|
|
13
|
+
"text": "FEWER CARDS, both seats (#892): a verb's FIRST OPERAND can be a slot its grammar fills rather than a file. `VerbOptionSlots.firstOperand` (`src/tools/fs/bash-readonly-classifier.ts:1562`) is a declaration ROW — grep's `pattern` (`:1621`), sed's `script` (`:1675`) — read with the same `OptionValueKind` vocabulary the option slots already use, and ONE reader (`suppliesFirstOperandSlot` `:1401`) answers \"did an option supply this slot instead\" for both walks that needed it (the boundary walk `:3441`/`:3459-3466`, the stdin-floor walk `:4007`/`:4012`). Rules: -1 (the verb-named branch and its private predicate are gone; a verb with no row keeps an ordinary first operand). WHO NEEDS IT: `sed -n '/error/p' app.log` — an everyday log-reading line whose script, spelled with slashes because it opens with an address regexp, was read as the absolute path `/error/p`, found outside the roots and mandated; the `-e` spelling of the same script was already exempt, so one invocation had two readings. WHO IS HURT: a probe pinned at `mandated` for a path-shaped sed script, including its deny-listed spelling — `sed <deny row>` and `sed -l <deny row>` now drop their card on the default/off and bypass tiers. COMPENSATION: the token is not a file under EITHER implementation (it is the script where `-l` takes no value and `-l`'s length argument where it does), so the verb-blind reading that recorded it as a read was an over-approximation, not a protection; the narrowing is exactly one operand wide and no other verb grew a row."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "G2",
|
|
17
|
+
"source": "criteria.title",
|
|
18
|
+
"text": "#892 — a verb's first operand can be a slot its grammar fills, and the narrowing is exactly one operand wide"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"closedSetMembers": [
|
|
22
|
+
{
|
|
23
|
+
"set": "QuoteOpen",
|
|
24
|
+
"at": [
|
|
25
|
+
"src/tools/fs/bash-lexer.ts:109"
|
|
26
|
+
],
|
|
27
|
+
"newSet": true,
|
|
28
|
+
"added": [
|
|
29
|
+
"\"",
|
|
30
|
+
"'"
|
|
31
|
+
],
|
|
32
|
+
"removed": [],
|
|
33
|
+
"breaking": false,
|
|
34
|
+
"criteriaCovered": false
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"set": "SHELL_SCAN_DECLINES",
|
|
38
|
+
"at": [
|
|
39
|
+
"src/tools/fs/bash-program-position.ts:346"
|
|
40
|
+
],
|
|
41
|
+
"newSet": true,
|
|
42
|
+
"added": [
|
|
43
|
+
"stream_fed_operands",
|
|
44
|
+
"unreadable_program_position"
|
|
45
|
+
],
|
|
46
|
+
"removed": [],
|
|
47
|
+
"breaking": false,
|
|
48
|
+
"criteriaCovered": false
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"closedSetsUnreadable": [],
|
|
52
|
+
"typeKeys": [],
|
|
53
|
+
"declarationMoves": [
|
|
54
|
+
{
|
|
55
|
+
"name": "NOT_AUTO_ALLOWED",
|
|
56
|
+
"kind": null,
|
|
57
|
+
"onSurface": false,
|
|
58
|
+
"from": [
|
|
59
|
+
"src/tools/fs/bash-program-position.ts",
|
|
60
|
+
"src/tools/fs/bash-readonly-classifier.ts"
|
|
61
|
+
],
|
|
62
|
+
"to": [
|
|
63
|
+
"src/tools/fs/bash-program-position.ts"
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"name": "traceHookCrash",
|
|
68
|
+
"kind": null,
|
|
69
|
+
"onSurface": false,
|
|
70
|
+
"from": [
|
|
71
|
+
"src/core/runner/gate-exit.ts"
|
|
72
|
+
],
|
|
73
|
+
"to": [
|
|
74
|
+
"src/core/gate-crash-trace.ts"
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"mintMoves": [],
|
|
79
|
+
"criteriaObserves": [
|
|
80
|
+
{
|
|
81
|
+
"kind": "typeMember",
|
|
82
|
+
"name": "AskRequest.message",
|
|
83
|
+
"assertions": [
|
|
84
|
+
"G5.a",
|
|
85
|
+
"G5.b"
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"kind": "typeMember",
|
|
90
|
+
"name": "AskRequest.origin",
|
|
91
|
+
"assertions": [
|
|
92
|
+
"G5.a",
|
|
93
|
+
"G5.b",
|
|
94
|
+
"G5.c"
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"kind": "typeMember",
|
|
99
|
+
"name": "AskRequest.ruleOffersAbsence",
|
|
100
|
+
"assertions": [
|
|
101
|
+
"G1.d",
|
|
102
|
+
"G3.b",
|
|
103
|
+
"G3.d",
|
|
104
|
+
"G4.c"
|
|
105
|
+
]
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"kind": "typeMember",
|
|
109
|
+
"name": "AskRequest.toolName",
|
|
110
|
+
"assertions": [
|
|
111
|
+
"G5.a"
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"kind": "typeMember",
|
|
116
|
+
"name": "BashReadonlyRootBoundary.denyMatch",
|
|
117
|
+
"assertions": [
|
|
118
|
+
"G1.c"
|
|
119
|
+
]
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"kind": "typeMember",
|
|
123
|
+
"name": "CompoundReadonlyVerdict.outOfRootPaths",
|
|
124
|
+
"assertions": [
|
|
125
|
+
"G1.b",
|
|
126
|
+
"G2.a",
|
|
127
|
+
"G2.b",
|
|
128
|
+
"G2.c",
|
|
129
|
+
"G2.d"
|
|
130
|
+
]
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"kind": "typeMember",
|
|
134
|
+
"name": "CompoundReadonlyVerdict.readDenied",
|
|
135
|
+
"assertions": [
|
|
136
|
+
"G1.b",
|
|
137
|
+
"G2.b"
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"kind": "typeMember",
|
|
142
|
+
"name": "ReadRootGrantCandidate.dir",
|
|
143
|
+
"assertions": [
|
|
144
|
+
"G4.e"
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"kind": "typeMember",
|
|
149
|
+
"name": "ReversibilityVerdict.mandated",
|
|
150
|
+
"assertions": [
|
|
151
|
+
"G1.a",
|
|
152
|
+
"G1.b",
|
|
153
|
+
"G1.c",
|
|
154
|
+
"G2.a",
|
|
155
|
+
"G2.b",
|
|
156
|
+
"G2.c",
|
|
157
|
+
"G2.d",
|
|
158
|
+
"G3.a",
|
|
159
|
+
"G3.c",
|
|
160
|
+
"G3.d",
|
|
161
|
+
"G3.e",
|
|
162
|
+
"G4.a",
|
|
163
|
+
"G4.b",
|
|
164
|
+
"G4.c",
|
|
165
|
+
"G4.d",
|
|
166
|
+
"G6.a",
|
|
167
|
+
"G6.c",
|
|
168
|
+
"G7.a",
|
|
169
|
+
"G7.b",
|
|
170
|
+
"G7.c"
|
|
171
|
+
]
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"kind": "typeMember",
|
|
175
|
+
"name": "ReversibilityVerdict.readRootCandidate",
|
|
176
|
+
"assertions": [
|
|
177
|
+
"G4.e"
|
|
178
|
+
]
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"kind": "typeMember",
|
|
182
|
+
"name": "ReversibilityVerdict.reversible",
|
|
183
|
+
"assertions": [
|
|
184
|
+
"G1.a",
|
|
185
|
+
"G3.a",
|
|
186
|
+
"G3.e",
|
|
187
|
+
"G4.b",
|
|
188
|
+
"G4.d",
|
|
189
|
+
"G6.a",
|
|
190
|
+
"G6.b",
|
|
191
|
+
"G7.a",
|
|
192
|
+
"G7.c"
|
|
193
|
+
]
|
|
194
|
+
}
|
|
195
|
+
],
|
|
196
|
+
"consumersHint": {
|
|
197
|
+
"cli": [],
|
|
198
|
+
"server": [],
|
|
199
|
+
"sdk": [],
|
|
200
|
+
"note": "scanned cli@879f19e9 server@1b232ad6 sdk@6209e5e — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
|
|
201
|
+
},
|
|
202
|
+
"breakingCount": 0,
|
|
203
|
+
"breakingItems": [],
|
|
204
|
+
"toResolvedAt": "29f9c021a132099c91933bd71a58bd4da716bfac"
|
|
205
|
+
}
|