@dzhechkov/harness-core 0.8.11 → 0.8.21
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/.dz-manifest.json +399 -139
- package/LICENSE +13 -0
- package/README.md +420 -6
- package/dist/agents-policy.d.ts +15 -1
- package/dist/agents-policy.d.ts.map +1 -1
- package/dist/agents-policy.js +27 -1
- package/dist/agents-policy.js.map +1 -1
- package/dist/amendment-trace.d.ts +72 -0
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +385 -17
- package/dist/amendment-trace.js.map +1 -1
- package/dist/backlog-public.d.ts +153 -0
- package/dist/backlog-public.d.ts.map +1 -0
- package/dist/backlog-public.js +415 -0
- package/dist/backlog-public.js.map +1 -0
- package/dist/backlog-transitions.d.ts +48 -0
- package/dist/backlog-transitions.d.ts.map +1 -0
- package/dist/backlog-transitions.js +64 -0
- package/dist/backlog-transitions.js.map +1 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +13 -0
- package/dist/backlog.js.map +1 -1
- package/dist/claim-check.d.ts.map +1 -1
- package/dist/claim-check.js +24 -2
- package/dist/claim-check.js.map +1 -1
- package/dist/claude-hooks-assets.d.ts +93 -0
- package/dist/claude-hooks-assets.d.ts.map +1 -0
- package/dist/claude-hooks-assets.js +208 -0
- package/dist/claude-hooks-assets.js.map +1 -0
- package/dist/clean-room-smoke.d.ts +83 -0
- package/dist/clean-room-smoke.d.ts.map +1 -0
- package/dist/clean-room-smoke.js +138 -0
- package/dist/clean-room-smoke.js.map +1 -0
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +36 -6
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +25 -7
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +138 -30
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +21 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +21 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/course-staleness.d.ts +19 -0
- package/dist/course-staleness.d.ts.map +1 -0
- package/dist/course-staleness.js +95 -0
- package/dist/course-staleness.js.map +1 -0
- package/dist/destructive-guard-hook.d.ts +40 -0
- package/dist/destructive-guard-hook.d.ts.map +1 -0
- package/dist/destructive-guard-hook.js +109 -0
- package/dist/destructive-guard-hook.js.map +1 -0
- package/dist/destructive-guard.d.ts +27 -0
- package/dist/destructive-guard.d.ts.map +1 -0
- package/dist/destructive-guard.js +2808 -0
- package/dist/destructive-guard.js.map +1 -0
- package/dist/discrimination-gate.d.ts +28 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +76 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +95 -1
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +193 -27
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +90 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +271 -0
- package/dist/guard.js.map +1 -1
- package/dist/harness-core-location.d.ts +18 -0
- package/dist/harness-core-location.d.ts.map +1 -0
- package/dist/harness-core-location.js +42 -0
- package/dist/harness-core-location.js.map +1 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -3
- package/dist/index.js.map +1 -1
- package/dist/lead-shift-gate.d.ts +95 -0
- package/dist/lead-shift-gate.d.ts.map +1 -0
- package/dist/lead-shift-gate.js +100 -0
- package/dist/lead-shift-gate.js.map +1 -0
- package/dist/ledger-backfill.d.ts +11 -1
- package/dist/ledger-backfill.d.ts.map +1 -1
- package/dist/ledger-backfill.js +19 -0
- package/dist/ledger-backfill.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +56 -7
- package/dist/loop-lint.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +1 -3
- package/dist/loop-plan-graph.d.ts.map +1 -1
- package/dist/loop-plan-graph.js +70 -1
- package/dist/loop-plan-graph.js.map +1 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +16 -2
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +5 -6
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +2 -2
- package/dist/managed-hooks.js.map +1 -1
- package/dist/model-recommender.d.ts.map +1 -1
- package/dist/model-recommender.js +14 -3
- package/dist/model-recommender.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +82 -0
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +23 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +10 -0
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +11 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +16 -2
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +3 -2
- package/dist/registry.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +15 -4
- package/dist/score.js.map +1 -1
- package/dist/session-retro.d.ts +121 -2
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +454 -11
- package/dist/session-retro.js.map +1 -1
- package/dist/setup.d.ts +28 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +223 -6
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +18 -1
- package/dist/sign.js.map +1 -1
- package/dist/skills-verify.d.ts +34 -1
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +82 -0
- package/dist/skills-verify.js.map +1 -1
- package/dist/stage-line.d.ts +68 -0
- package/dist/stage-line.d.ts.map +1 -0
- package/dist/stage-line.js +129 -0
- package/dist/stage-line.js.map +1 -0
- package/dist/statusline.d.ts +99 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +310 -44
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +26 -0
- package/dist/store-counts.d.ts.map +1 -0
- package/dist/store-counts.js +125 -0
- package/dist/store-counts.js.map +1 -0
- package/dist/store-guard.d.ts +106 -0
- package/dist/store-guard.d.ts.map +1 -0
- package/dist/store-guard.js +294 -0
- package/dist/store-guard.js.map +1 -0
- package/dist/swarm-brief.d.ts +95 -0
- package/dist/swarm-brief.d.ts.map +1 -0
- package/dist/swarm-brief.js +660 -0
- package/dist/swarm-brief.js.map +1 -0
- package/dist/trace-bundle.d.ts +8 -0
- package/dist/trace-bundle.d.ts.map +1 -1
- package/dist/trace-bundle.js +11 -0
- package/dist/trace-bundle.js.map +1 -1
- package/package.json +12 -11
- package/sbom.json +804 -154
- package/src/agents-policy.ts +46 -2
- package/src/amendment-trace.ts +441 -19
- package/src/backlog-public.ts +503 -0
- package/src/backlog-transitions.ts +77 -0
- package/src/backlog.ts +12 -0
- package/src/claim-check.ts +25 -2
- package/src/claude-hooks-assets.ts +227 -0
- package/src/clean-room-smoke.ts +195 -0
- package/src/cmd-usage.ts +29 -5
- package/src/codex-hooks-assets.ts +140 -30
- package/src/codex-hooks.ts +21 -1
- package/src/course-staleness.ts +125 -0
- package/src/destructive-guard-hook.ts +151 -0
- package/src/destructive-guard.ts +3027 -0
- package/src/discrimination-gate.ts +98 -19
- package/src/feature-adr-routing.ts +220 -22
- package/src/guard.ts +318 -0
- package/src/harness-core-location.ts +44 -0
- package/src/index.ts +111 -3
- package/src/lead-shift-gate.ts +145 -0
- package/src/ledger-backfill.ts +20 -1
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-lint.ts +52 -7
- package/src/loop-plan-graph.ts +66 -1
- package/src/loop-trace.ts +13 -1
- package/src/managed-hooks.ts +5 -6
- package/src/model-recommender.ts +14 -3
- package/src/operations.ts +75 -0
- package/src/patterns.ts +33 -0
- package/src/publish.ts +27 -2
- package/src/registry.ts +3 -2
- package/src/score.ts +16 -4
- package/src/session-retro.ts +466 -11
- package/src/setup.ts +246 -9
- package/src/sign.ts +18 -1
- package/src/skills-verify.ts +99 -1
- package/src/stage-line.ts +151 -0
- package/src/statusline.ts +396 -47
- package/src/store-counts.ts +154 -0
- package/src/store-guard.ts +388 -0
- package/src/swarm-brief.ts +661 -0
- package/src/trace-bundle.ts +10 -0
|
@@ -0,0 +1,3027 @@
|
|
|
1
|
+
// destructive-guard — feature `destructive-command-guard`, ADR-001
|
|
2
|
+
// ("the guard's scope is set by what it can DECIDE from the form, not by how bad the consequence is").
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS SO NARROW. A measurement over 20 938 real shell commands from this project's
|
|
5
|
+
// transcripts showed that for the broad class "recursive deletion" the guard cannot justify 83 % of
|
|
6
|
+
// its own hits (44 % target in a shell variable, 39 % relative target after a directory change).
|
|
7
|
+
// A check that invents violations is worse than no check — people learn to switch it off, and the
|
|
8
|
+
// 17 % that worked goes with it. So the rules below fire ONLY on a LITERAL path into a protected
|
|
9
|
+
// store, where the form IS the fact.
|
|
10
|
+
//
|
|
11
|
+
// PURE BY CONTRACT: a string in, a structure out. No filesystem access, no subprocess, and above
|
|
12
|
+
// all no variable expansion — asking the shell where `$T` points means EXECUTING a fragment of an
|
|
13
|
+
// untrusted command inside the defence against that command (ADR-001, option C, rejected by
|
|
14
|
+
// construction).
|
|
15
|
+
//
|
|
16
|
+
// THREE OUTCOMES, NOT TWO. `undecidable` is never dressed up as `refuse`: a refusal derived from an
|
|
17
|
+
// inability to parse is a false guarantee, and the consumer must PASS on it (AC-10).
|
|
18
|
+
|
|
19
|
+
/** A verdict on one shell command. `limits` is always populated — see LIMITS below. */
|
|
20
|
+
export interface DestructiveVerdict {
|
|
21
|
+
/** `refuse` = block it. `allow` = out of scope. `undecidable` = could not parse; consumer PASSES. */
|
|
22
|
+
outcome: 'refuse' | 'allow' | 'undecidable';
|
|
23
|
+
/** The literal path that triggered the refusal, as the SHELL would pass it to the program. */
|
|
24
|
+
path: string | null;
|
|
25
|
+
/** The id of the rule that fired — one of DESTRUCTIVE_RULES. */
|
|
26
|
+
rule: string | null;
|
|
27
|
+
/** One line naming what was decided and why. A refusal always names the path and the rule. */
|
|
28
|
+
reason: string;
|
|
29
|
+
/** What this guard does NOT decide. Printed on EVERY verdict, refusals included. */
|
|
30
|
+
limits: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The limits the guard is obliged to PRINT rather than keep quiet about.
|
|
35
|
+
*
|
|
36
|
+
* Verbatim from ADR-001, section "Пределы, которые страж обязан НАЗЫВАТЬ в собственном тексте".
|
|
37
|
+
* MEASURED reason: a guard that stays silent about its limits reads as a guarantee. During the
|
|
38
|
+
* 2026-09-04 shift the false-refusal class "text ABOUT a command is not a command" fired six times,
|
|
39
|
+
* twice on the very lesson recording that class.
|
|
40
|
+
*/
|
|
41
|
+
const LIMITS: readonly string[] = Object.freeze([
|
|
42
|
+
'цель в переменной оболочки — не разрешается, страж пропускает',
|
|
43
|
+
'относительный путь после смены каталога — не разрешается, страж пропускает',
|
|
44
|
+
'`git rm` — не удаление файлов, исключено по построению',
|
|
45
|
+
'содержимое кавычек читается ТОЛЬКО у глагола удаления и у носителя команды, исполняемой оболочкой («sh -c», «npm/npx -c», «pnpm --shell-mode exec»); под любой другой головой это текст о команде, а не команда; тела вставных блоков не читаются никогда',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** The complete rule table. A refusal always carries one of these ids. */
|
|
49
|
+
export const DESTRUCTIVE_RULES: readonly { id: string; what: string }[] = Object.freeze([
|
|
50
|
+
{ id: 'protected-store-dz', what: 'буквальный путь в защищаемое хранилище .dz' },
|
|
51
|
+
{ id: 'protected-store-agentic-qe', what: 'буквальный путь в защищаемое хранилище .agentic-qe' },
|
|
52
|
+
{ id: 'database-file', what: 'буквальный путь к файлу базы данных (.db/.sqlite/.sqlite3/.rvf)' },
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/** Directory names that ARE a protected store, matched as a whole path segment. */
|
|
56
|
+
const PROTECTED_SEGMENTS: ReadonlyMap<string, string> = new Map([
|
|
57
|
+
['.dz', 'protected-store-dz'],
|
|
58
|
+
['.agentic-qe', 'protected-store-agentic-qe'],
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** A live store file, recognised by extension. `-wal`/`-shm`/`-journal` are SQLite satellites. */
|
|
62
|
+
const DATABASE_FILE = /\.(db|sqlite|sqlite3|rvf)(-wal|-shm|-journal)?$/i;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Options that make a deletion verb print something and EXIT — no operand is ever removed.
|
|
66
|
+
*
|
|
67
|
+
* MEASURED with GNU coreutils 8.32, each case in its own temp directory: `<verb> --help .dzprobe`,
|
|
68
|
+
* `<verb> --version .dzprobe`, `rmdir --help …`, `unlink --help …` and `shred --version …` each
|
|
69
|
+
* printed usage or a version banner, exited 0 and left the target ALIVE — and position does not
|
|
70
|
+
* matter, `<verb> -rf .dzprobe --help` behaves the same. The classifier skipped these as ordinary
|
|
71
|
+
* flags and then named the operand as a protected deletion (cross-family review, gpt-5.6-sol,
|
|
72
|
+
* round 5): a refusal naming a store the command CANNOT touch, the R-flags defect.
|
|
73
|
+
*
|
|
74
|
+
* They are only modes BEFORE the option terminator. MEASURED the other way too:
|
|
75
|
+
* `<verb> -rf -- --help .dzprobe` left the target GONE, and `<verb> -- --help .dzprobe` reported
|
|
76
|
+
* `cannot remove '--help': No such file or directory` — past `--` the word is a FILE NAME.
|
|
77
|
+
*/
|
|
78
|
+
const TERMINAL_MODE_OPTIONS: ReadonlySet<string> = new Set(['--help', '--version']);
|
|
79
|
+
|
|
80
|
+
/** Verbs that delete from the filesystem. Deliberately short. */
|
|
81
|
+
const DELETE_VERBS: ReadonlySet<string> = new Set(['rm', 'rmdir', 'unlink', 'shred']);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The option arity of the deletion verbs themselves — declared BELOW, next to OptionArity.
|
|
85
|
+
*
|
|
86
|
+
* `rm`, `rmdir` and `unlink` genuinely have no option that takes a separate word, and that is the
|
|
87
|
+
* load-bearing half: an invented entry here would swallow the real target and the guard would go
|
|
88
|
+
* silent on a live deletion. Only `shred` has any, and only those it truly has.
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Which options of a command take the NEXT word as their value.
|
|
93
|
+
*
|
|
94
|
+
* WHY THIS EXISTS. "Skip everything that starts with a dash" is not a parse: in `env -u FOO rm …`
|
|
95
|
+
* the word after `-u` is the variable to unset, not the program, and the guard read `FOO` as the
|
|
96
|
+
* command being run — so the deletion behind it was never examined (cross-family review,
|
|
97
|
+
* gpt-5.6-sol). The lists below are deliberately conservative: an option is named here only when
|
|
98
|
+
* it unambiguously eats a word, because a WRONG entry swallows the verb and yields a silent miss.
|
|
99
|
+
*/
|
|
100
|
+
interface OptionArity {
|
|
101
|
+
/** Short option LETTERS whose value is the next word, written on their own as `-u root`. */
|
|
102
|
+
short: string;
|
|
103
|
+
/** Long option names, without `--`, whose value is the next word (`--unset FOO`). */
|
|
104
|
+
long: ReadonlySet<string>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const NO_VALUE_OPTIONS: OptionArity = { short: '', long: new Set() };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The first value-taking letter in a SHORT option cluster, and the value it carries.
|
|
111
|
+
*
|
|
112
|
+
* `getopt` reads a cluster left to right and stops at the first letter that wants a value: what
|
|
113
|
+
* follows that letter inside the SAME word is the value, and when nothing follows it, the value is
|
|
114
|
+
* the NEXT word. So `-uFOO` and `-u FOO` are one option written two ways, and in `-iu FOO` the
|
|
115
|
+
* `-i` is a boolean while the trailing `-u` still eats `FOO`.
|
|
116
|
+
*
|
|
117
|
+
* ROUND-4 CORRECTION (cross-family review, gpt-5.6-sol). The rule used to be "only a bare
|
|
118
|
+
* two-character flag takes the next word", justified by an asymmetry: consuming a word that was not
|
|
119
|
+
* a value swallows the deletion verb, while NOT consuming one only leaves the classifier where it
|
|
120
|
+
* already was. The second half of that is FALSE for a cluster — the unconsumed value becomes the
|
|
121
|
+
* head word, an unknown head STOPS the search (see WRAPPERS), and the deletion behind it is
|
|
122
|
+
* allowed. MEASURED with the real tools: `env -iu FOO printf RAN` prints RAN and
|
|
123
|
+
* `sudo -nu root printf RAN` prints RAN, so `FOO`/`root` are values and the command is what follows
|
|
124
|
+
* them; `env -uFOO printf RAN` also prints RAN, so an ATTACHED value does NOT eat the next word.
|
|
125
|
+
* Both directions are pinned (F20, F20-guard).
|
|
126
|
+
*/
|
|
127
|
+
interface ShortClusterValue {
|
|
128
|
+
/** The letter that takes the value. */
|
|
129
|
+
letter: string;
|
|
130
|
+
/** The value written in the SAME word, or null when the value is the next word. */
|
|
131
|
+
attached: string | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function shortClusterValue(letters: string, flag: string): ShortClusterValue | null {
|
|
135
|
+
if (letters === '' || flag.startsWith('--') || !flag.startsWith('-')) return null;
|
|
136
|
+
for (let p = 1; p < flag.length; p++) {
|
|
137
|
+
const letter = flag.charAt(p);
|
|
138
|
+
if (!letters.includes(letter)) continue;
|
|
139
|
+
const rest = flag.slice(p + 1);
|
|
140
|
+
return { letter, attached: rest === '' ? null : rest };
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** True when `flag`, written exactly like this, consumes the word that follows it. */
|
|
146
|
+
function consumesNextWord(arity: OptionArity, flag: string): boolean {
|
|
147
|
+
if (flag.startsWith('--')) {
|
|
148
|
+
const name = flag.slice(2);
|
|
149
|
+
return !name.includes('=') && arity.long.has(name); // `--unset=FOO` carries its own value
|
|
150
|
+
}
|
|
151
|
+
const hit = shortClusterValue(arity.short, flag);
|
|
152
|
+
return hit !== null && hit.attached === null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Commands that EXEC another command, so the real verb is the next word — with the arity of the
|
|
157
|
+
* options each of them accepts.
|
|
158
|
+
*
|
|
159
|
+
* This is an ALLOWLIST on purpose: an unknown head word stops the search, i.e. the default is
|
|
160
|
+
* "not a deletion, allow". A denylist would default to "keep looking", which refuses on
|
|
161
|
+
* `echo rm .dz/x` — the very false-refusal class this feature exists to avoid.
|
|
162
|
+
*/
|
|
163
|
+
// The arities are named data consumed by the single carrier table below; none of them identifies a
|
|
164
|
+
// wrapper on its own. R17 deliberately removed the old parallel `ARGV_WRAPPER_OPTIONS` registry:
|
|
165
|
+
// splitting "where is the command?" across that map and the strategy table hid npm's shell carrier.
|
|
166
|
+
// `sudo`: `-h`/`--host` was the gap. sudo's own `--help` documents `-h, --host=host`, and the
|
|
167
|
+
// value was not consumed, so the HOST became the command head and the deletion behind it was
|
|
168
|
+
// allowed (cross-family review, gpt-5.6-sol, round 7). MEASURED honestly in both directions on
|
|
169
|
+
// this build: `sudo -u root printf RAN` and `sudo -g root printf RAN` print RAN (the value IS
|
|
170
|
+
// eaten), while `sudo -H/-E/-n/-s/-i/-b/-k printf RAN` print RAN written bare (they eat nothing).
|
|
171
|
+
// `-h` itself could NOT be exercised here — `sudo -h localhost <verb> …` answers `a remote host
|
|
172
|
+
// may only be specified when listing privileges` and deletes nothing on this build — so its
|
|
173
|
+
// evidence is sudo's own synopsis, not a run. It is entered as value-taking anyway, because the
|
|
174
|
+
// expensive direction of the error is a SILENT MISS on a build whose plugin does support remote
|
|
175
|
+
// execution, while the false refusal it can cause costs a measured zero (`sudo -h`/`--host`
|
|
176
|
+
// appears 0 times in the 20 938-command corpus).
|
|
177
|
+
const SUDO_WRAPPER_OPTIONS: OptionArity = { short: 'ugpCUrtDRTh', long: new Set(['user', 'group', 'prompt', 'close-from', 'other-user', 'role', 'type', 'chdir', 'chroot', 'command-timeout', 'host']) };
|
|
178
|
+
const DOAS_WRAPPER_OPTIONS: OptionArity = { short: 'uCa', long: new Set() };
|
|
179
|
+
// `env`: `-S`/`--split-string` is NOT an ordinary value-taking option — its value IS the command
|
|
180
|
+
// (the option-value carrier in the table below), and the letter stays in `short` only so a cluster
|
|
181
|
+
// ending in it is walked correctly. The three signal options are absent on purpose: their argument is
|
|
182
|
+
// OPTIONAL (`--ignore-signal[=SIG]`), so they never eat a separate word. MEASURED —
|
|
183
|
+
// `env --ignore-signal printf RAN` prints RAN and exits 0, while `env --unset printf RAN`
|
|
184
|
+
// consumes `RAN` and exits 127. Listing them as value-taking swallowed the deletion verb itself
|
|
185
|
+
// and the guard went silent on a live deletion (cross-family review, gpt-5.6-sol, round 4; F19).
|
|
186
|
+
const ENV_WRAPPER_OPTIONS: OptionArity = { short: 'uCS', long: new Set(['unset', 'chdir']) };
|
|
187
|
+
const NICE_WRAPPER_OPTIONS: OptionArity = { short: 'n', long: new Set(['adjustment']) };
|
|
188
|
+
const STDBUF_WRAPPER_OPTIONS: OptionArity = { short: 'ioe', long: new Set(['input', 'output', 'error']) };
|
|
189
|
+
const TIME_WRAPPER_OPTIONS: OptionArity = { short: 'of', long: new Set(['output', 'format']) };
|
|
190
|
+
// `xargs`: GNU spells three of its long options with an OPTIONAL argument — `--eof[=eof-str]`,
|
|
191
|
+
// `--replace[=R]`, `--max-lines[=n]` — so written BARE they consume nothing and the next word is
|
|
192
|
+
// the command. MEASURED with GNU findutils 4.8.0: `printf x | xargs --eof <verb> -rf .dzprobe`
|
|
193
|
+
// exited 0 and left the target GONE, and so did `--replace` and `--max-lines`; the five that
|
|
194
|
+
// remain each refused to run with the verb in the value slot (`invalid number "rm"`,
|
|
195
|
+
// `Invalid input delimiter specification rm`, `invalid option -- 'f'`), which is the proof that
|
|
196
|
+
// they DO consume a word. The SHORT spellings `-I`, `-E`, `-L` take a required argument and stay.
|
|
197
|
+
// `xargs`: `--process-slot-var` was the gap — MEASURED end to end,
|
|
198
|
+
// `printf x | xargs --process-slot-var SLOT <verb> -rf .dzprobe` exited 0 and left the target
|
|
199
|
+
// GONE. The sweep confirmed the rest in both directions: `-I -E -n -P -s -a -d -L` and
|
|
200
|
+
// `--max-args --max-procs --max-chars --arg-file --delimiter` each consume a word, while
|
|
201
|
+
// `-0 -r -t -x`, the optional-argument `-l`, and `--eof/--replace/--max-lines` (round 6) do not.
|
|
202
|
+
const XARGS_WRAPPER_OPTIONS: OptionArity = { short: 'IEnPsadL', long: new Set(['max-args', 'max-procs', 'max-chars', 'arg-file', 'delimiter', 'process-slot-var']) };
|
|
203
|
+
const EXEC_WRAPPER_OPTIONS: OptionArity = { short: 'a', long: new Set() };
|
|
204
|
+
// `ionice <verb> -rf .dz` runs the deletion, but ionice was missing from the allowlist, so
|
|
205
|
+
// resolution stopped at an unknown head and allowed it (cross-family review, gpt-5.6-sol,
|
|
206
|
+
// round 8). The arity is the round-8 sweep's, MEASURED: `-c`/`--class` and `-n`/`--classdata`
|
|
207
|
+
// consume a word (`ionice -c 2 printf RAN` prints RAN, bare `-c` does not), `-t`/`--ignore`
|
|
208
|
+
// consume nothing, and `--help`/`--version` exec nothing at all. The pid/uid selectors take a
|
|
209
|
+
// value per ionice's own synopsis (`-p, --pid <pid>...`).
|
|
210
|
+
//
|
|
211
|
+
// Only ionice was added. The other candidates the sweep turned up — timeout, chroot, taskset,
|
|
212
|
+
// chrt, flock, unbuffer, script, torify — are NOT here: widening the allowlist is an ADR question
|
|
213
|
+
// for the owner (backlog 422f7596), not a fix-round decision, and "an unknown head stops the
|
|
214
|
+
// search" is a DELIBERATE limit of ADR-001 rather than a defect.
|
|
215
|
+
const IONICE_WRAPPER_OPTIONS: OptionArity = { short: 'cnpPu', long: new Set(['class', 'classdata', 'pid', 'pgid', 'uid']) };
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Options of a wrapper whose VALUE IS THE COMMAND, not a parameter of the wrapper.
|
|
219
|
+
*
|
|
220
|
+
* `env -S '<verb> -rf .dz'` does not run a program called `<verb> -rf .dz`; it SPLITS the string
|
|
221
|
+
* into words and runs the result, with any remaining operands of the `env` line appended. Read as
|
|
222
|
+
* an ordinary value-taking option, the whole command text was skipped and a live deletion of the
|
|
223
|
+
* store was allowed (cross-family review, gpt-5.6-sol, round 4). MEASURED with GNU coreutils 8.32,
|
|
224
|
+
* each case in its own temp directory: `env -S '<verb> -rf .dzprobe'`,
|
|
225
|
+
* `env --split-string '<verb> -rf .dzprobe'` and `env --split-string=<verb> -rf .dzprobe` all exited
|
|
226
|
+
* 0 and left the target GONE — note that the third carries only the VERB in the string, which is
|
|
227
|
+
* why the rest of the line has to be appended rather than dropped.
|
|
228
|
+
*
|
|
229
|
+
* This does NOT widen the scope, for the same reason `sh -c` does not: a string is data about a
|
|
230
|
+
* command only while the head does not EXECUTE it (ADR-001, limit 4).
|
|
231
|
+
*/
|
|
232
|
+
const ENV_COMMAND_STRING_OPTIONS: OptionArity = { short: 'S', long: new Set(['split-string']) };
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Options that turn a wrapper into an INSPECTION: it reports where a command lives and runs nothing.
|
|
236
|
+
*
|
|
237
|
+
* MEASURED: `command -v <verb> .dzprobe` printed `/usr/bin/rm`, `command -V <verb> .dzprobe` printed
|
|
238
|
+
* `rm is /usr/bin/rm`, and `command -pv <verb> .dzprobe` printed `/bin/rm` — all exited 0 and left
|
|
239
|
+
* the target ALIVE. Resolving the next word as an executable unconditionally refused a pure lookup
|
|
240
|
+
* (cross-family review, gpt-5.6-sol, round 5); the idiom appears 16 times in the 20 938-command
|
|
241
|
+
* corpus, so the false refusal is not hypothetical. The letters are what disarm it and nothing else:
|
|
242
|
+
* `command <verb> -rf .dzprobe` and `command -p <verb> -rf .dzprobe` both left the target GONE.
|
|
243
|
+
*
|
|
244
|
+
* `builtin` is deliberately NOT here, and the reason is named rather than left to a reader's guess.
|
|
245
|
+
* The review is right that `builtin <verb>` deletes nothing under bash — MEASURED for all four
|
|
246
|
+
* verbs, each `not a shell builtin`, exit 1, target ALIVE — but `builtin` dispatches to whatever the
|
|
247
|
+
* RUNNING shell has, and zsh's `zsh/files` module provides a real builtin `rm`. zsh is not installed
|
|
248
|
+
* on this machine, so that could not be probed, and turning a refusal into `allow` on an unprobed
|
|
249
|
+
* shell is the expensive direction of the error. The false refusal it leaves costs a measured zero:
|
|
250
|
+
* `builtin` at the head of a command appears 0 times in the same corpus.
|
|
251
|
+
*/
|
|
252
|
+
const INSPECTION_OPTIONS: ReadonlyMap<string, OptionArity> = new Map([
|
|
253
|
+
['command', { short: 'vV', long: new Set() }],
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
/** True when `flag` puts the wrapper into an inspection mode — any letter of a cluster counts. */
|
|
257
|
+
function isInspectionFlag(inspect: OptionArity, flag: string): boolean {
|
|
258
|
+
if (flag.startsWith('--')) {
|
|
259
|
+
const eq = flag.indexOf('=');
|
|
260
|
+
return inspect.long.has(eq === -1 ? flag.slice(2) : flag.slice(2, eq));
|
|
261
|
+
}
|
|
262
|
+
for (let p = 1; p < flag.length; p++) if (inspect.short.includes(flag.charAt(p))) return true;
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Wrappers for which a LONE `-` is an option rather than the command.
|
|
268
|
+
*
|
|
269
|
+
* MEASURED with GNU coreutils 8.32: `env - <verb> -rf .dzprobe` exited 0 and left the target GONE,
|
|
270
|
+
* exactly like `env -i …`, and `env - printf RAN` prints RAN. A one-character word ended the
|
|
271
|
+
* wrapper scan, so `-` resolved as the command head, an unknown head STOPS the search, and a live
|
|
272
|
+
* deletion was allowed (cross-family review, gpt-5.6-sol, round 7).
|
|
273
|
+
*
|
|
274
|
+
* It also ENDS the options, like `--`: `env - -i <marker>` answers `env: '-i': No such file or
|
|
275
|
+
* directory`, i.e. the word after the dash is the COMMAND. And it is env's spelling alone —
|
|
276
|
+
* `sudo - <marker>` answers `sudo: -: command not found`.
|
|
277
|
+
*/
|
|
278
|
+
const LONE_DASH_WRAPPERS: ReadonlySet<string> = new Set(['env']);
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Options that make a WRAPPER print something and exit without exec'ing anything.
|
|
282
|
+
*
|
|
283
|
+
* MEASURED across all eight wrappers available here — env, nice, stdbuf, xargs, setsid, nohup,
|
|
284
|
+
* sudo, ionice — sixteen runs, and the marker program executed in NONE of them. The classifier
|
|
285
|
+
* walked past the option to the verb and refused a command that runs nothing (cross-family review,
|
|
286
|
+
* gpt-5.6-sol, round 7).
|
|
287
|
+
*
|
|
288
|
+
* RECORDED BECAUSE THE FIRST MEASUREMENT WAS WRONG, and the error is the interesting half: the
|
|
289
|
+
* probe counted output lines matching `RAN`, and the GNU version banner contains `WARRANTY`. Five
|
|
290
|
+
* wrappers therefore looked as though `--version` still ran the command. The instrument decided the
|
|
291
|
+
* verdict until it was re-run against the marker's full text.
|
|
292
|
+
*
|
|
293
|
+
* Only these two LONG spellings are claimed. Short spellings differ per tool (`sudo -V` is a
|
|
294
|
+
* version request, `-V` is not that anywhere else here), so they are deliberately not generalised.
|
|
295
|
+
*/
|
|
296
|
+
const WRAPPER_TERMINAL_OPTIONS: ReadonlySet<string> = new Set(['--help', '--version']);
|
|
297
|
+
|
|
298
|
+
/** How many times one segment may be rewritten by a command-string option before we give up. */
|
|
299
|
+
const MAX_COMMAND_STRING_REWRITES = 4;
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* `-S`'s value split into words the way GNU `env` splits it — MEASURED, not inferred.
|
|
303
|
+
*
|
|
304
|
+
* The distinction from a shell parse is load-bearing in BOTH directions. `env -S` hands the split
|
|
305
|
+
* result to `execvp`, so `;`, `|` and `&` are ordinary characters of an argument: in
|
|
306
|
+
* `env -S '<verb> -rf .dz; echo hi'` the operand handed to the verb is `.dz;`, a name that does not
|
|
307
|
+
* exist, and the store is untouched — classifying that string as a shell command line would refuse
|
|
308
|
+
* on a store the command does not delete (the R-flags defect).
|
|
309
|
+
*
|
|
310
|
+
* ROUND-6 CORRECTION. This function used to treat `\_` as an escaped underscore. It is env's WORD
|
|
311
|
+
* SEPARATOR: `env -S '<verb>\_-rf\_.dzprobe'` exited 0 and left the target GONE, and printing argv
|
|
312
|
+
* from inside `-S` shows `a\_b` arriving as TWO arguments, `<a><b>`. Read as one word, the head was
|
|
313
|
+
* not a deletion verb and the guard allowed a live deletion (cross-family review, gpt-5.6-sol,
|
|
314
|
+
* round 6).
|
|
315
|
+
*
|
|
316
|
+
* THE COMPLETE MEASURED TABLE (GNU coreutils 8.32, argv printed from inside `-S`):
|
|
317
|
+
* - OUTSIDE quotes: `\_` SEPARATES words (repeats and edges collapse: `\_a\_b\_` is `<a><b>`);
|
|
318
|
+
* `\t \n \f \r \v` are those control characters INSIDE the word, not separators; `\\ \# \$` are
|
|
319
|
+
* the literal character; `\c` ENDS the whole string (`a\cb\_c` is `<a>`); ordinary spaces
|
|
320
|
+
* separate as well.
|
|
321
|
+
* - Inside DOUBLE quotes: escapes still apply but `\_` is an ordinary SPACE (`a"b\_c"d` is
|
|
322
|
+
* `<ab cd>`), and `\c` is an ERROR (`'\c' must not appear in double-quoted -S string`).
|
|
323
|
+
* - Inside SINGLE quotes: NOTHING is processed (`a'b\_c'd` is `<ab\_cd>`, `'${HOME}'` stays
|
|
324
|
+
* literal).
|
|
325
|
+
* - `${VAR}` is expanded by env; a BARE `$VAR` is an error (`only ${VARNAME} expansion is
|
|
326
|
+
* supported`), and so is any unrecognised escape (`invalid sequence '\q' in -S`).
|
|
327
|
+
*
|
|
328
|
+
* WHAT AN ERROR MEANS HERE. When env refuses the string it runs NOTHING, so the safe reading is
|
|
329
|
+
* "not decidable by form": the word is marked dynamic, which can never produce a refusal and cannot
|
|
330
|
+
* hide a deletion that never happens. Expansion is marked the same way — it is the guard's first
|
|
331
|
+
* printed limit.
|
|
332
|
+
*/
|
|
333
|
+
|
|
334
|
+
/** `env -S` escapes that stand for one character INSIDE a word, outside single quotes. */
|
|
335
|
+
const ENV_S_ESCAPES: ReadonlyMap<string, string> = new Map([
|
|
336
|
+
['t', '\t'], ['n', '\n'], ['f', '\f'], ['r', '\r'], ['v', '\v'],
|
|
337
|
+
['\\', '\\'], ['#', '#'], ['$', '$'],
|
|
338
|
+
]);
|
|
339
|
+
|
|
340
|
+
/** The split of an `env -S` value, or null when env would REFUSE the whole string and run nothing. */
|
|
341
|
+
function splitStringWords(value: string): Word[] | null {
|
|
342
|
+
const words: Word[] = [];
|
|
343
|
+
let buf = '';
|
|
344
|
+
let has = false;
|
|
345
|
+
let dynamic = false;
|
|
346
|
+
/**
|
|
347
|
+
* True when env would reject the whole `-S` value and exec NOTHING.
|
|
348
|
+
*
|
|
349
|
+
* MEASURED, four spellings, each in a throwaway directory: an unterminated double quote, an
|
|
350
|
+
* unterminated single quote, an unknown escape `\q` and a bare `$BARE` each make env exit 125
|
|
351
|
+
* (`no terminating quote in -S string`, `invalid sequence '\q' in -S`, `only ${VARNAME}
|
|
352
|
+
* expansion is supported`) and leave the target ALIVE. Marking only the offending WORD dynamic
|
|
353
|
+
* left the words BEFORE it standing and produced a refusal for a command that runs nothing
|
|
354
|
+
* (cross-family review, gpt-5.6-sol, round 10) — the invalidity belongs to the whole string.
|
|
355
|
+
*/
|
|
356
|
+
let invalid = false;
|
|
357
|
+
const flush = (): void => {
|
|
358
|
+
if (!has) return;
|
|
359
|
+
// `env -S` splits its string and calls execvp DIRECTLY — there is no shell left to expand
|
|
360
|
+
// anything. MEASURED: with a directory `.dz` and a file literally named `{.dz,foo}` side by
|
|
361
|
+
// side, `env -S '<verb> -rf {.dz,foo}'` removed the FILE and left `.dz` ALIVE, and argv printed
|
|
362
|
+
// from inside `-S` shows `<{.dz,foo}>` as one literal argument. So every brace here is part of
|
|
363
|
+
// the name; expanding it refused a path the command never touches (cross-family review,
|
|
364
|
+
// gpt-5.6-sol, round 9). Operands the OUTER shell appends keep their own mask.
|
|
365
|
+
words.push({ kind: 'word', text: buf, dynamic, literalMask: '1'.repeat(buf.length) });
|
|
366
|
+
buf = '';
|
|
367
|
+
has = false;
|
|
368
|
+
dynamic = false;
|
|
369
|
+
};
|
|
370
|
+
/** One escape, shared by the unquoted and double-quoted paths. Returns the next index. */
|
|
371
|
+
const escape = (i: number, inDoubleQuotes: boolean): number => {
|
|
372
|
+
const e = value.charAt(i + 1);
|
|
373
|
+
if (e === '_') {
|
|
374
|
+
if (inDoubleQuotes) { buf += ' '; has = true; } // an ordinary space inside quotes
|
|
375
|
+
else flush(); // …but a WORD SEPARATOR outside them
|
|
376
|
+
return i + 2;
|
|
377
|
+
}
|
|
378
|
+
if (e === 'c' && !inDoubleQuotes) { flush(); return value.length; } // ends the whole string
|
|
379
|
+
const lit = ENV_S_ESCAPES.get(e);
|
|
380
|
+
if (lit !== undefined) { buf += lit; has = true; return i + 2; }
|
|
381
|
+
invalid = true; // env REFUSES this string and runs nothing
|
|
382
|
+
return i + 2;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
for (let i = 0; i < value.length; ) {
|
|
386
|
+
const c = value.charAt(i);
|
|
387
|
+
if (c === '\\') {
|
|
388
|
+
if (i + 1 >= value.length) { invalid = true; break; }
|
|
389
|
+
i = escape(i, false);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (c === "'") { // single quotes: nothing is processed
|
|
393
|
+
const end = value.indexOf("'", i + 1);
|
|
394
|
+
if (end === -1) { invalid = true; break; }
|
|
395
|
+
buf += value.slice(i + 1, end);
|
|
396
|
+
has = true;
|
|
397
|
+
i = end + 1;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (c === '"') {
|
|
401
|
+
let j = i + 1;
|
|
402
|
+
let closed = false;
|
|
403
|
+
has = true;
|
|
404
|
+
while (j < value.length) {
|
|
405
|
+
const d = value.charAt(j);
|
|
406
|
+
if (d === '"') { closed = true; break; }
|
|
407
|
+
if (d === '\\') { j = escape(j, true); continue; }
|
|
408
|
+
if (d === '$') { dynamic = true; buf += d; j++; continue; }
|
|
409
|
+
buf += d;
|
|
410
|
+
j++;
|
|
411
|
+
}
|
|
412
|
+
if (!closed) { invalid = true; break; }
|
|
413
|
+
i = j + 1;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
// `${VARNAME}` is expanded; a BARE `$VAR` is an error env refuses to run.
|
|
417
|
+
if (c === '$') {
|
|
418
|
+
if (value.charAt(i + 1) !== '{') { invalid = true; break; }
|
|
419
|
+
dynamic = true;
|
|
420
|
+
buf += c;
|
|
421
|
+
has = true;
|
|
422
|
+
i++;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (/\s/.test(c)) { flush(); i++; continue; }
|
|
426
|
+
buf += c;
|
|
427
|
+
has = true;
|
|
428
|
+
i++;
|
|
429
|
+
}
|
|
430
|
+
if (invalid) return null;
|
|
431
|
+
flush();
|
|
432
|
+
return words;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** See the note next to DELETE_VERBS. `rm`/`rmdir`/`unlink` take no separate option values. */
|
|
436
|
+
const DELETE_OPTIONS: ReadonlyMap<string, OptionArity> = new Map([
|
|
437
|
+
['rm', NO_VALUE_OPTIONS],
|
|
438
|
+
['rmdir', NO_VALUE_OPTIONS],
|
|
439
|
+
['unlink', NO_VALUE_OPTIONS],
|
|
440
|
+
// RETRACTED CLAIM. Round 1 wrote here that only a FILE-valued option can be mistaken for a
|
|
441
|
+
// target, and that `-n`/`--iterations` and `-s`/`--size` — which take NUMBERS — give the same
|
|
442
|
+
// verdict whether their value is skipped or scanned, so no test could tell the two tables apart.
|
|
443
|
+
// That is FALSE for MALFORMED input, and the falsifier is a single line: `shred -s .dz/foo
|
|
444
|
+
// /tmp/ordinary` makes shred read `.dz/foo` as the SIZE, reject it and destroy nothing (MEASURED
|
|
445
|
+
// with GNU coreutils 8.32 on a throwaway file: `shred: invalid file size: '.dz/foo'`, exit 1,
|
|
446
|
+
// the file byte-identical afterwards), while the classifier named that path as a protected
|
|
447
|
+
// deletion and refused — a refusal naming a store the command does not touch, the R-flags
|
|
448
|
+
// defect. The entries are back WITH the test that discriminates them (F16), and the claim is
|
|
449
|
+
// retracted rather than left standing next to its own counterexample.
|
|
450
|
+
['shred', { short: 'ns', long: new Set(['random-source', 'iterations', 'size']) }],
|
|
451
|
+
]);
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The ONE registry that decides whether a command head carries another command and, if it does,
|
|
455
|
+
* which grammar locates it. Grammar data such as option arity remains next to its parser, but no
|
|
456
|
+
* head is exempted or unpacked outside this table.
|
|
457
|
+
*
|
|
458
|
+
* R16 corrected the old `SUBCOMMAND_TOOLS` set: it exempted EVERY invocation under a listed tool,
|
|
459
|
+
* so `npm exec -- rm -rf .dz` was described as harmless `npm rm`. A subcommand wrapper now names
|
|
460
|
+
* only its non-filesystem removal verbs as exempt, and names command-running subcommands
|
|
461
|
+
* separately. Unknown subcommands stop as ordinary commands; they are never promoted to an
|
|
462
|
+
* exemption merely because their parent tool is familiar.
|
|
463
|
+
*/
|
|
464
|
+
type CommandExecution = 'argv' | 'shell';
|
|
465
|
+
|
|
466
|
+
interface OptionValueCommandCarrier {
|
|
467
|
+
/** The wrapper option whose value is the nested command. */
|
|
468
|
+
options: OptionArity;
|
|
469
|
+
execution: CommandExecution;
|
|
470
|
+
/** `env -S` has its own splitter; shell carriers are parsed recursively as shell source. */
|
|
471
|
+
split: 'env' | 'shell';
|
|
472
|
+
/** GNU env appends the words after its -S value to the split argv. */
|
|
473
|
+
appendRemaining?: boolean;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
type NestedCommandRule =
|
|
477
|
+
| {
|
|
478
|
+
kind: 'argv';
|
|
479
|
+
/** First positional after options (and any subject words) is the command. */
|
|
480
|
+
location: 'first-positional';
|
|
481
|
+
execution: CommandExecution;
|
|
482
|
+
options: OptionArity;
|
|
483
|
+
positionalsBeforeCommand?: number;
|
|
484
|
+
/** Alternate options whose VALUE, rather than a positional, is the command. */
|
|
485
|
+
optionValueCommands?: readonly OptionValueCommandCarrier[];
|
|
486
|
+
/** Boolean options that change positional execution from argv to shell. */
|
|
487
|
+
shellModeOptions?: OptionArity;
|
|
488
|
+
}
|
|
489
|
+
| { kind: 'external-script'; location: 'named-script'; execution: 'shell' };
|
|
490
|
+
|
|
491
|
+
type CommandWrapperStrategy =
|
|
492
|
+
| NestedCommandRule
|
|
493
|
+
| { kind: 'shell-c'; location: 'shell-c-string'; execution: 'shell' }
|
|
494
|
+
| {
|
|
495
|
+
kind: 'subcommand';
|
|
496
|
+
options: OptionArity | null;
|
|
497
|
+
shellModeOptions?: OptionArity;
|
|
498
|
+
nonFilesystem: ReadonlySet<string>;
|
|
499
|
+
commands: ReadonlyMap<string, NestedCommandRule>;
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const NPM_EXEC_OPTIONS: OptionArity = {
|
|
503
|
+
short: 'pwc',
|
|
504
|
+
long: new Set(['package', 'workspace']),
|
|
505
|
+
};
|
|
506
|
+
const NPM_CALL_OPTIONS: OptionArity = {
|
|
507
|
+
short: 'c',
|
|
508
|
+
long: new Set(['call']),
|
|
509
|
+
};
|
|
510
|
+
const PNPM_EXEC_OPTIONS: OptionArity = {
|
|
511
|
+
short: 'C',
|
|
512
|
+
long: new Set(['dir', 'filter']),
|
|
513
|
+
};
|
|
514
|
+
const NPM_GLOBAL_OPTIONS: OptionArity = {
|
|
515
|
+
short: 'Cw',
|
|
516
|
+
long: new Set(['cache', 'loglevel', 'prefix', 'registry', 'userconfig', 'workspace']),
|
|
517
|
+
};
|
|
518
|
+
const PNPM_GLOBAL_OPTIONS: OptionArity = {
|
|
519
|
+
short: 'C',
|
|
520
|
+
long: new Set(['config-dir', 'dir', 'filter', 'global-dir', 'store-dir', 'virtual-store-dir']),
|
|
521
|
+
};
|
|
522
|
+
const PNPM_SHELL_MODE_OPTIONS: OptionArity = {
|
|
523
|
+
short: 'c',
|
|
524
|
+
long: new Set(['shell-mode']),
|
|
525
|
+
};
|
|
526
|
+
const YARN_GLOBAL_OPTIONS: OptionArity = {
|
|
527
|
+
short: '',
|
|
528
|
+
long: new Set(['cwd']),
|
|
529
|
+
};
|
|
530
|
+
const YARN_EXEC_OPTIONS: OptionArity = {
|
|
531
|
+
short: 'p',
|
|
532
|
+
long: new Set(['package']),
|
|
533
|
+
};
|
|
534
|
+
const CONTAINER_EXEC_OPTIONS: OptionArity = {
|
|
535
|
+
short: 'euw',
|
|
536
|
+
long: new Set(['env', 'env-file', 'user', 'workdir', 'detach-keys']),
|
|
537
|
+
};
|
|
538
|
+
const KUBECTL_EXEC_OPTIONS: OptionArity = {
|
|
539
|
+
short: 'cn',
|
|
540
|
+
long: new Set(['container', 'namespace', 'pod-running-timeout', 'request-timeout']),
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const argvCommand = (
|
|
544
|
+
options: OptionArity,
|
|
545
|
+
positionalsBeforeCommand = 0,
|
|
546
|
+
extras: Pick<Extract<NestedCommandRule, { kind: 'argv' }>, 'execution' | 'optionValueCommands' | 'shellModeOptions'> = { execution: 'argv' },
|
|
547
|
+
): NestedCommandRule => ({
|
|
548
|
+
kind: 'argv',
|
|
549
|
+
location: 'first-positional',
|
|
550
|
+
options,
|
|
551
|
+
positionalsBeforeCommand,
|
|
552
|
+
...extras,
|
|
553
|
+
});
|
|
554
|
+
const subcommandWrapper = (
|
|
555
|
+
nonFilesystem: readonly string[],
|
|
556
|
+
commands: readonly (readonly [string, NestedCommandRule])[] = [],
|
|
557
|
+
options: OptionArity | null = null,
|
|
558
|
+
shellModeOptions?: OptionArity,
|
|
559
|
+
): CommandWrapperStrategy => ({
|
|
560
|
+
kind: 'subcommand',
|
|
561
|
+
options,
|
|
562
|
+
...(shellModeOptions === undefined ? {} : { shellModeOptions }),
|
|
563
|
+
nonFilesystem: new Set(nonFilesystem),
|
|
564
|
+
commands: new Map(commands),
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const COMMAND_WRAPPER_STRATEGIES: ReadonlyMap<string, CommandWrapperStrategy> = new Map([
|
|
568
|
+
['sudo', argvCommand(SUDO_WRAPPER_OPTIONS)],
|
|
569
|
+
['doas', argvCommand(DOAS_WRAPPER_OPTIONS)],
|
|
570
|
+
['env', argvCommand(ENV_WRAPPER_OPTIONS, 0, {
|
|
571
|
+
execution: 'argv',
|
|
572
|
+
optionValueCommands: [{ options: ENV_COMMAND_STRING_OPTIONS, execution: 'argv', split: 'env', appendRemaining: true }],
|
|
573
|
+
})],
|
|
574
|
+
['nice', argvCommand(NICE_WRAPPER_OPTIONS)],
|
|
575
|
+
['stdbuf', argvCommand(STDBUF_WRAPPER_OPTIONS)],
|
|
576
|
+
['time', argvCommand(TIME_WRAPPER_OPTIONS)],
|
|
577
|
+
['xargs', argvCommand(XARGS_WRAPPER_OPTIONS)],
|
|
578
|
+
['exec', argvCommand(EXEC_WRAPPER_OPTIONS)],
|
|
579
|
+
['command', argvCommand(NO_VALUE_OPTIONS)],
|
|
580
|
+
['builtin', argvCommand(NO_VALUE_OPTIONS)],
|
|
581
|
+
['nohup', argvCommand(NO_VALUE_OPTIONS)],
|
|
582
|
+
['setsid', argvCommand(NO_VALUE_OPTIONS)],
|
|
583
|
+
['ionice', argvCommand(IONICE_WRAPPER_OPTIONS)],
|
|
584
|
+
...['sh', 'bash', 'zsh', 'dash', 'ksh'].map((name): [string, CommandWrapperStrategy] => [
|
|
585
|
+
name,
|
|
586
|
+
{ kind: 'shell-c', location: 'shell-c-string', execution: 'shell' },
|
|
587
|
+
]),
|
|
588
|
+
['npx', argvCommand(NPM_EXEC_OPTIONS, 0, {
|
|
589
|
+
execution: 'argv',
|
|
590
|
+
optionValueCommands: [{ options: NPM_CALL_OPTIONS, execution: 'shell', split: 'shell' }],
|
|
591
|
+
})],
|
|
592
|
+
['npm', subcommandWrapper(
|
|
593
|
+
['rm', 'remove', 'uninstall', 'un'],
|
|
594
|
+
[
|
|
595
|
+
['exec', argvCommand(NPM_EXEC_OPTIONS, 0, {
|
|
596
|
+
execution: 'argv',
|
|
597
|
+
optionValueCommands: [{ options: NPM_CALL_OPTIONS, execution: 'shell', split: 'shell' }],
|
|
598
|
+
})],
|
|
599
|
+
['x', argvCommand(NPM_EXEC_OPTIONS, 0, {
|
|
600
|
+
execution: 'argv',
|
|
601
|
+
optionValueCommands: [{ options: NPM_CALL_OPTIONS, execution: 'shell', split: 'shell' }],
|
|
602
|
+
})],
|
|
603
|
+
['run', { kind: 'external-script', location: 'named-script', execution: 'shell' }],
|
|
604
|
+
['run-script', { kind: 'external-script', location: 'named-script', execution: 'shell' }],
|
|
605
|
+
],
|
|
606
|
+
NPM_GLOBAL_OPTIONS,
|
|
607
|
+
)],
|
|
608
|
+
['pnpm', subcommandWrapper(
|
|
609
|
+
['rm', 'remove', 'uninstall', 'un'],
|
|
610
|
+
[
|
|
611
|
+
['exec', argvCommand(PNPM_EXEC_OPTIONS)],
|
|
612
|
+
['dlx', argvCommand(PNPM_EXEC_OPTIONS)],
|
|
613
|
+
['run', { kind: 'external-script', location: 'named-script', execution: 'shell' }],
|
|
614
|
+
],
|
|
615
|
+
PNPM_GLOBAL_OPTIONS,
|
|
616
|
+
PNPM_SHELL_MODE_OPTIONS,
|
|
617
|
+
)],
|
|
618
|
+
['yarn', subcommandWrapper(
|
|
619
|
+
['remove'],
|
|
620
|
+
[
|
|
621
|
+
['exec', argvCommand(YARN_EXEC_OPTIONS)],
|
|
622
|
+
['dlx', argvCommand(YARN_EXEC_OPTIONS)],
|
|
623
|
+
['run', { kind: 'external-script', location: 'named-script', execution: 'shell' }],
|
|
624
|
+
],
|
|
625
|
+
YARN_GLOBAL_OPTIONS,
|
|
626
|
+
)],
|
|
627
|
+
['bun', subcommandWrapper(
|
|
628
|
+
['rm', 'remove', 'uninstall'],
|
|
629
|
+
[
|
|
630
|
+
['x', argvCommand(NO_VALUE_OPTIONS)],
|
|
631
|
+
['run', { kind: 'external-script', location: 'named-script', execution: 'shell' }],
|
|
632
|
+
],
|
|
633
|
+
)],
|
|
634
|
+
...['git', 'hg', 'svn', 'jj', 'bzr'].map((name): [string, CommandWrapperStrategy] => [name, subcommandWrapper(['rm'])]),
|
|
635
|
+
['cargo', subcommandWrapper(['rm'], [['run', { kind: 'external-script', location: 'named-script', execution: 'shell' }]])],
|
|
636
|
+
['pip', subcommandWrapper(['uninstall'])],
|
|
637
|
+
['pip3', subcommandWrapper(['uninstall'])],
|
|
638
|
+
['gem', subcommandWrapper(['uninstall'])],
|
|
639
|
+
['apt', subcommandWrapper(['remove'])],
|
|
640
|
+
['apt-get', subcommandWrapper(['remove'])],
|
|
641
|
+
['brew', subcommandWrapper(['rm', 'remove', 'uninstall'])],
|
|
642
|
+
['docker', subcommandWrapper(['rm'], [['exec', argvCommand(CONTAINER_EXEC_OPTIONS, 1)]])],
|
|
643
|
+
['podman', subcommandWrapper(['rm'], [['exec', argvCommand(CONTAINER_EXEC_OPTIONS, 1)]])],
|
|
644
|
+
['kubectl', subcommandWrapper([], [['exec', argvCommand(KUBECTL_EXEC_OPTIONS, 1)]])],
|
|
645
|
+
['helm', subcommandWrapper([])],
|
|
646
|
+
]);
|
|
647
|
+
|
|
648
|
+
const VCS_TOOLS: ReadonlySet<string> = new Set(['git', 'hg', 'svn', 'jj', 'bzr']);
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Shells that RUN an inline string given after `-c`.
|
|
652
|
+
*
|
|
653
|
+
* MEASURED, not theory: on the Codex host the model puts exactly `sh -c '…'` into
|
|
654
|
+
* `tool_input.command` (features/crossrt-2-codex-hooks/07_code_changes/probe-results/
|
|
655
|
+
* spike-arming.md:247), so a guard that only reads the direct call protects nothing there.
|
|
656
|
+
*
|
|
657
|
+
* This does NOT widen the scope — it applies the SAME rule as a quoted operand: a string is data
|
|
658
|
+
* about a command only while the head does not EXECUTE it. Under `echo` it stays prose; under
|
|
659
|
+
* `sh -c` it is the command, and it is classified as one.
|
|
660
|
+
*/
|
|
661
|
+
/**
|
|
662
|
+
* Which shell runners perform BRACE EXPANSION on the string they are given.
|
|
663
|
+
*
|
|
664
|
+
* MEASURED by printing argv from inside each shell present here: `bash -c` and `ksh -c` turn
|
|
665
|
+
* `.d{y..z} x{a,b}` into `<.dy><.dz><xa><xb>`, while `dash -c` passes `<.d{y..z}><x{a,b}>`
|
|
666
|
+
* unchanged — dash has no brace expansion — and `dash -c '<verb> -rf .d{y..z}'` left the target
|
|
667
|
+
* ALIVE. Classifying the inner string with the outer bash-like lexer refused a safe command
|
|
668
|
+
* (cross-family review, gpt-5.6-sol, round 12).
|
|
669
|
+
*
|
|
670
|
+
* `sh` is DELIBERATELY on the expanding side even though `/bin/sh` resolves to `/usr/bin/dash` on
|
|
671
|
+
* this machine, because what `sh` IS varies: it is bash on many systems, and there
|
|
672
|
+
* `sh -c '<verb> -rf .d{y..z}'` deletes the store. Exempting it would trade a false refusal on one
|
|
673
|
+
* machine for a silent MISS on another — the same tie-break as `builtin` (round 6), `sudo --host`
|
|
674
|
+
* (round 8) and the unknown-option decision (round 9). `zsh` is documented to expand and is not
|
|
675
|
+
* installed here, so it stays on the fail-closed side too (F55-guard).
|
|
676
|
+
*/
|
|
677
|
+
const SHELLS_WITHOUT_BRACE_EXPANSION: ReadonlySet<string> = new Set(['dash']);
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Options of a shell runner that take the NEXT word as their value.
|
|
681
|
+
*
|
|
682
|
+
* Same defect class as the wrapper arity table (F3), one command further along: the search for the
|
|
683
|
+
* `-c` string stopped at the bare word `extglob` in `bash -O extglob -c '…'`, so the string the
|
|
684
|
+
* shell actually RUNS was never unpacked and the deletion inside it was allowed (cross-family
|
|
685
|
+
* review, gpt-5.6-sol, round 2). Only these four take a separate word in `sh`/`bash`/`zsh`/`dash`/
|
|
686
|
+
* `ksh`; the list is short on purpose, because a wrong entry here consumes the `-c` itself and the
|
|
687
|
+
* guard goes silent on the very form F7 exists to catch.
|
|
688
|
+
*/
|
|
689
|
+
const SHELL_VALUE_OPTIONS: ReadonlySet<string> = new Set(['-o', '-O', '+o', '+O']);
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* GNU LONG options of a shell runner that take the NEXT word as their value.
|
|
693
|
+
*
|
|
694
|
+
* The same defect as SHELL_VALUE_OPTIONS, one option SPELLING further along: the search for the
|
|
695
|
+
* `-c` string stopped at the bare word `/dev/null` in `bash --rcfile /dev/null -c '<deletion>'`,
|
|
696
|
+
* so the string bash actually RUNS was never unpacked and the deletion inside it was allowed
|
|
697
|
+
* (cross-family review, gpt-5.6-sol, round 3).
|
|
698
|
+
*
|
|
699
|
+
* WHAT WAS CHECKED, so the next reader need not guess at the completeness of a two-entry list.
|
|
700
|
+
* `bash --help` (GNU bash 5.1.16) prints the COMPLETE set of 17 GNU long options: --debug,
|
|
701
|
+
* --debugger, --dump-po-strings, --dump-strings, --help, --init-file, --login, --noediting,
|
|
702
|
+
* --noprofile, --norc, --posix, --pretty-print, --rcfile, --restricted, --verbose, --version.
|
|
703
|
+
* Exactly two of them take a separate word — `--rcfile` and `--init-file`, which are synonyms —
|
|
704
|
+
* and that was PROBED, not read: each value-less one still ran `-c 'echo RAN'` and printed RAN,
|
|
705
|
+
* while both of these two consumed the file name and then ran the `-c` string. `dash` has no long
|
|
706
|
+
* options at all (`dash --help` → `Illegal option --`), and `ksh`/`ksh93` spell their long options
|
|
707
|
+
* as set-option NAMES (`--posix`, `--noglob`, …), none of which takes a value — also probed.
|
|
708
|
+
* `zsh` is NOT installed on this machine and was therefore NOT probed; its long forms are
|
|
709
|
+
* documented as the same set-option names plus `-o option`, which SHELL_VALUE_OPTIONS already
|
|
710
|
+
* covers, so no zsh-specific entry is claimed here.
|
|
711
|
+
*
|
|
712
|
+
* The `--rcfile=FILE` spelling is deliberately absent: bash REJECTS it (`bash --rcfile=/dev/null
|
|
713
|
+
* -c 'echo RAN'` → exit 2, `invalid option`), so that line runs nothing at all.
|
|
714
|
+
*
|
|
715
|
+
* As with every arity table in this file, a WRONG entry consumes the `-c` itself and the guard
|
|
716
|
+
* goes silent on the very form F7 exists to catch — which is why this list is exactly the probed
|
|
717
|
+
* two and not "every long option that looks file-ish".
|
|
718
|
+
*/
|
|
719
|
+
const SHELL_LONG_VALUE_OPTIONS: ReadonlySet<string> = new Set(['--rcfile', '--init-file']);
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Shell options that make the `-c` string PARSED but never executed.
|
|
723
|
+
*
|
|
724
|
+
* MEASURED across four shells, each in a throwaway directory: `bash -n -c`, `sh -n -c`,
|
|
725
|
+
* `dash -n -c` and `ksh -n -c` all exit 0 and leave the target ALIVE, as do the clustered
|
|
726
|
+
* `bash -nc` and `bash -o noexec -c`. The string was unpacked and classified unconditionally, so a
|
|
727
|
+
* syntax check was refused (cross-family review, gpt-5.6-sol, round 10).
|
|
728
|
+
*
|
|
729
|
+
* `n` is the only letter here, and that is measured rather than assumed: `-v` and `-x` print the
|
|
730
|
+
* command AND run it — the target was GONE for both on all four shells — so neither may disarm the
|
|
731
|
+
* unpacking (F47-guard).
|
|
732
|
+
*/
|
|
733
|
+
const SHELL_NO_EXEC_LETTER = 'n';
|
|
734
|
+
const SHELL_NO_EXEC_SET_OPTION = 'noexec';
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Shell options after which the `-c` string is never executed at all.
|
|
738
|
+
*
|
|
739
|
+
* MEASURED with bash 5.1.16, each in a throwaway directory: `--help`, `--version`, `-D`,
|
|
740
|
+
* `--dump-strings` and `--dump-po-strings` print or dump and exit with the target ALIVE, and the
|
|
741
|
+
* clustered `-Dc` behaves the same. On the other shells the same spellings are refused outright —
|
|
742
|
+
* `sh`/`dash` answer `Illegal option --`, `ksh` prints its usage, all non-zero and ALIVE — so
|
|
743
|
+
* nothing runs there either. They were skipped as ordinary flags and the string behind them was
|
|
744
|
+
* then classified as a real deletion (cross-family review, gpt-5.6-sol, round 11).
|
|
745
|
+
*
|
|
746
|
+
* The list is exactly what was RUN, not what reads as terminal on a man page: `--pretty-print`,
|
|
747
|
+
* `--noediting`, `--norc` and `--posix` all left the target GONE, so none of them is here
|
|
748
|
+
* (F49-guard).
|
|
749
|
+
*/
|
|
750
|
+
const SHELL_TERMINAL_OPTIONS: ReadonlySet<string> = new Set([
|
|
751
|
+
'--help', '--version', '--dump-strings', '--dump-po-strings',
|
|
752
|
+
]);
|
|
753
|
+
|
|
754
|
+
/** The short letter of bash's translation-dump mode, which also never runs the string. */
|
|
755
|
+
const SHELL_DUMP_LETTER = 'D';
|
|
756
|
+
|
|
757
|
+
/** How deep an inline `-c` string is unpacked. One level; deeper is answered `undecidable`. */
|
|
758
|
+
const MAX_SHELL_DEPTH = 1;
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Words the shell reads as SYNTAX in command position rather than as the name of a program.
|
|
762
|
+
*
|
|
763
|
+
* `! rm -rf .agentic-qe` and `if true; then rm -rf .agentic-qe; fi` both delete the store. The
|
|
764
|
+
* classifier took `!` and `then` for an unknown executable, and an unknown head STOPS the search
|
|
765
|
+
* (that default is deliberate — see WRAPPERS), so the deletion behind the keyword was never
|
|
766
|
+
* examined (cross-family review, gpt-5.6-sol, round 2).
|
|
767
|
+
*
|
|
768
|
+
* This is an allowlist for the same reason WRAPPERS is one, and it is short: every entry is a word
|
|
769
|
+
* the POSIX shell reserves in command position, so nothing that could be a real program is skipped.
|
|
770
|
+
* `{`, `(`, `)` and `}` are not here because the lexer already treats a standalone brace or
|
|
771
|
+
* parenthesis as a SEPARATOR, so they never reach this function as a word.
|
|
772
|
+
*
|
|
773
|
+
* `if`/`while`/`until` are transparent for the same reason as `then`/`do`: in `if rm -rf .dz; then
|
|
774
|
+
* …` the condition itself is executed. The head of the segment AFTER the condition is reached
|
|
775
|
+
* through `then`/`do`, since `;` already ends the segment.
|
|
776
|
+
*/
|
|
777
|
+
const CONTROL_WORDS: ReadonlySet<string> = new Set([
|
|
778
|
+
'!', 'if', 'then', 'elif', 'else', 'while', 'until', 'do',
|
|
779
|
+
// `coproc` EXECUTES the command that follows it, asynchronously. MEASURED:
|
|
780
|
+
// `coproc <verb> -rf .dzprobe; wait $COPROC_PID` exited 0 and left the target GONE, as did the
|
|
781
|
+
// named form `coproc NAME { <verb> -rf .dzprobe; }`. Read as an unknown executable it stopped the
|
|
782
|
+
// search, so the deletion behind it was allowed (cross-family review, gpt-5.6-sol, round 5).
|
|
783
|
+
'coproc',
|
|
784
|
+
]);
|
|
785
|
+
|
|
786
|
+
// -------------------------------------------------------------------------------------------
|
|
787
|
+
// Stage 1 — LEX. One pass over the raw string, producing the WORDS the shell would hand to the
|
|
788
|
+
// program, plus the separators between commands.
|
|
789
|
+
//
|
|
790
|
+
// WHY A LEXER AND NOT A MASK. The first version blanked every quoted span, so a deletion whose
|
|
791
|
+
// operand was quoted reached the rules with no operand at all and was allowed — while the shell
|
|
792
|
+
// still deletes the store, because quotes are the SHELL's syntax and never part of the file name
|
|
793
|
+
// (found by the cross-family review, gpt-5.6-sol). The lexer DECODES a word instead of erasing it.
|
|
794
|
+
//
|
|
795
|
+
// The ADR limit (now: "quotes are read ONLY under a deletion verb and under sh -c") SURVIVES, narrowed to what it was actually
|
|
796
|
+
// protecting: only a segment whose head is a deletion verb has its operands examined at all, so
|
|
797
|
+
// prose about a command — one quoted word under a head like `echo` or `dz` — is still allowed.
|
|
798
|
+
// -------------------------------------------------------------------------------------------
|
|
799
|
+
|
|
800
|
+
interface Word {
|
|
801
|
+
kind: 'word';
|
|
802
|
+
/** The word as the SHELL would pass it on: quotes removed, escapes decoded. */
|
|
803
|
+
text: string;
|
|
804
|
+
/** True when the word holds something only the shell can resolve: `$`, a backtick, a glob. */
|
|
805
|
+
dynamic: boolean;
|
|
806
|
+
/**
|
|
807
|
+
* One character per character of `text`, saying what the SHELL will do with that position:
|
|
808
|
+
* `0` bare syntax, `1` quoted or escaped literal, `d` something the shell expands.
|
|
809
|
+
*
|
|
810
|
+
* The third state is what lets a MIXED brace group keep the operand it always produces.
|
|
811
|
+
* MEASURED: `{.dz,$OTHER}` arrives as `<.dz>` with OTHER unset and `<.dz><zzz>` with it set — the
|
|
812
|
+
* literal alternative is expanded BEFORE parameter expansion, so it is a guaranteed operand
|
|
813
|
+
* either way and the deletion happened in both runs. A word-wide dynamic flag threw the whole
|
|
814
|
+
* group away and the guaranteed target was never examined (cross-family review, gpt-5.6-sol,
|
|
815
|
+
* round 10). With the mask, `dynamic` is decided PER EXPANSION instead of per word.
|
|
816
|
+
*
|
|
817
|
+
* WHY A MASK AND NOT A FLAG. A word-wide flag disabled every expansion as soon as ONE brace was
|
|
818
|
+
* quoted, and that is a bypass: MEASURED, `"{"x/{foo,.dz}` expands to `<{x/foo><{x/.dz>` — the
|
|
819
|
+
* quoted brace is part of the file name while the SEPARATE unquoted group still expands, and the
|
|
820
|
+
* deletion was allowed (cross-family review, gpt-5.6-sol, round 9). The converse is measured too:
|
|
821
|
+
* a quoted ALTERNATIVE inside a bare group expands (`{"a",b}/.dz` is `<a/.dz><b/.dz>`), so the
|
|
822
|
+
* quoting of the BRACES decides, not the quoting of anything near them.
|
|
823
|
+
*/
|
|
824
|
+
literalMask?: string;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
type Lexeme = Word | Separator | { kind: 'redirect' };
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* One command separator, carrying the CHARACTER it was written with.
|
|
831
|
+
*
|
|
832
|
+
* The character is what tells a function DEFINITION from a subshell: `f` `(` `)` `{` … `}` binds
|
|
833
|
+
* a name and runs nothing, while `(` … `)` on its own really executes. Without it every
|
|
834
|
+
* parenthesis looked alike and the body of a definition was classified as a command the shell
|
|
835
|
+
* never performs (cross-family review, gpt-5.6-sol, round 4; F22).
|
|
836
|
+
*/
|
|
837
|
+
interface Separator { kind: 'sep'; ch: string; }
|
|
838
|
+
|
|
839
|
+
interface LexResult {
|
|
840
|
+
lexemes: readonly Lexeme[];
|
|
841
|
+
/** null when the command lexed; otherwise why it could not be read at all. */
|
|
842
|
+
failure: string | null;
|
|
843
|
+
/**
|
|
844
|
+
* True when the shell RUNS what was parsed before the failure, so the prefix must still be
|
|
845
|
+
* classified.
|
|
846
|
+
*
|
|
847
|
+
* MEASURED, and the two cases are genuinely different: an unterminated HEREDOC is a warning —
|
|
848
|
+
* `<verb> -rf .dzprobe; cat <<EOF` prints `warning: here-document ... delimited by end-of-file`,
|
|
849
|
+
* exits 0 and the target is GONE — while an unclosed QUOTE is a syntax error — the same line with
|
|
850
|
+
* `echo "unclosed` exits 2 with `unexpected EOF while looking for matching` and the target is
|
|
851
|
+
* ALIVE. Discarding every parsed segment answered `undecidable`, which the hook passes, so the
|
|
852
|
+
* deletion in front of the heredoc ran unexamined (cross-family review, gpt-5.6-sol, round 9).
|
|
853
|
+
* Keeping the prefix for the syntax-error case would refuse a command that runs nothing.
|
|
854
|
+
*/
|
|
855
|
+
prefixRuns: boolean;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Characters that end one command and start the next. */
|
|
859
|
+
const SEPARATORS: ReadonlySet<string> = new Set([';', '&', '|', '(', ')', '\n']);
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* `{` and `}` are RESERVED WORDS, not metacharacters: the shell reads them as a group only when
|
|
863
|
+
* each stands alone as a whole word. Splitting on every occurrence broke `rm /tmp/foo{bar}.db` into
|
|
864
|
+
* an operand `/tmp/foo` plus debris, and the guard allowed a deletion the shell performed
|
|
865
|
+
* (cross-family review, gpt-5.6-sol, round 2). Note the contrast with `(`/`)`, which ARE
|
|
866
|
+
* metacharacters and separate a word wherever they appear — that is why they stay in SEPARATORS.
|
|
867
|
+
*/
|
|
868
|
+
const BRACE_ENDS_A_WORD: ReadonlySet<string> = new Set(['', ' ', '\t', '\n', ';', '&', '|', '(', ')']);
|
|
869
|
+
|
|
870
|
+
/** Metacharacters that end a heredoc DELIMITER word (whitespace ends it too). */
|
|
871
|
+
const DELIMITER_ENDS: ReadonlySet<string> = new Set([';', '&', '|', '(', ')', '<', '>']);
|
|
872
|
+
|
|
873
|
+
/** Unescaped occurrences of these always make an unquoted word undecidable by form. */
|
|
874
|
+
const DYNAMIC_CHARS = /[`*?]/;
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Whether `$` at `index` starts an expansion rather than denoting a literal dollar byte.
|
|
878
|
+
*
|
|
879
|
+
* A trailing `$` and `$` before `/` are literal in POSIX-like shells. Marking every dollar dynamic
|
|
880
|
+
* skipped literal operands such as `.dz/$` and let a real deletion pass (R16 P2-2). Named,
|
|
881
|
+
* positional and special parameters, `${...}`, `$()`, legacy `$[...]`, and bash's translated/
|
|
882
|
+
* ANSI-C quote prefixes remain dynamic (the latter is decoded by its dedicated branch).
|
|
883
|
+
*/
|
|
884
|
+
function dollarExpands(command: string, index: number, inDoubleQuotes = false): boolean {
|
|
885
|
+
const next = command.charAt(index + 1);
|
|
886
|
+
if (next === '') return false;
|
|
887
|
+
if (/[A-Za-z0-9_]/.test(next)) return true;
|
|
888
|
+
if (inDoubleQuotes && (next === "'" || next === '"')) return false;
|
|
889
|
+
return '@*#?-$!({[\'"'.includes(next);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** The placeholder a text-less expansion occupies in a word — see addExpandedSpan. */
|
|
893
|
+
const DYNAMIC_SPAN_MARK = '\ue000';
|
|
894
|
+
|
|
895
|
+
/** The single-character ANSI-C escapes, exactly as `bash` expands them inside `$'…'`. */
|
|
896
|
+
const ANSI_C_SIMPLE: ReadonlyMap<string, string> = new Map([
|
|
897
|
+
['a', '\x07'], ['b', '\b'], ['e', '\x1b'], ['E', '\x1b'], ['f', '\f'],
|
|
898
|
+
['n', '\n'], ['r', '\r'], ['t', '\t'], ['v', '\v'],
|
|
899
|
+
['\\', '\\'], ["'", "'"], ['"', '"'], ['?', '?'],
|
|
900
|
+
]);
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* The index of the `'` that closes an ANSI-C string opened at `open` (the index of the quote), or
|
|
904
|
+
* -1 when it never closes. `\'` is an escaped quote there, so the scan skips escaped characters.
|
|
905
|
+
*/
|
|
906
|
+
function ansiCEnd(command: string, open: number): number {
|
|
907
|
+
for (let j = open + 1; j < command.length; j++) {
|
|
908
|
+
const c = command.charAt(j);
|
|
909
|
+
if (c === '\\') { j++; continue; }
|
|
910
|
+
if (c === "'") return j;
|
|
911
|
+
}
|
|
912
|
+
return -1;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Closing backtick for a command substitution, respecting the backtick escape grammar. */
|
|
916
|
+
function backtickEnd(command: string, open: number): number {
|
|
917
|
+
for (let j = open + 1; j < command.length; j++) {
|
|
918
|
+
if (command.charAt(j) === '\\') { j++; continue; }
|
|
919
|
+
if (command.charAt(j) === '`') return j;
|
|
920
|
+
}
|
|
921
|
+
return -1;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Closing ordinary single quote. Kept as one reader so nested scanners do not grow private rules. */
|
|
925
|
+
function singleQuotedEnd(command: string, open: number): number {
|
|
926
|
+
return command.indexOf("'", open + 1);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* `$'…'` decoded the way the shell decodes it.
|
|
931
|
+
*
|
|
932
|
+
* WHY THIS IS NOT THE GENERIC DYNAMIC PATH. `$'…'` is a QUOTING form, not an expansion: the shell
|
|
933
|
+
* resolves it itself and hands the program a fully LITERAL word, so the form IS the fact. The lexer
|
|
934
|
+
* saw the leading `$` and marked the operand dynamic, and a literal path into a protected store was
|
|
935
|
+
* skipped by both hooks (cross-family review, gpt-5.6-sol, round 5). MEASURED in a throwaway
|
|
936
|
+
* directory: `<verb> -rf $'.dzprobe'` and `<verb> -rf $'.dz\x70robe'` both exited 0 and left the
|
|
937
|
+
* target GONE.
|
|
938
|
+
*
|
|
939
|
+
* THE DECODER MUST BE RIGHT, not merely present — a wrong byte either invents a path (a refusal
|
|
940
|
+
* naming a store the command does not touch) or misses one. Every rule below was read off `bash`
|
|
941
|
+
* 5.1.16 with `printf %s $'<case>' | od -An -tx1`, and the whole battery is pinned in F23-oracle:
|
|
942
|
+
* - an UNRECOGNISED escape keeps its backslash (`$'\.dz'` is four characters, `\.dz`);
|
|
943
|
+
* - `\nnn` is octal, at most three digits (`$'\1234'` is `S4`);
|
|
944
|
+
* - `\xHH` takes at most two hex digits and, with none at all, stays the literal `\x`;
|
|
945
|
+
* - `\cX` is a control character;
|
|
946
|
+
* - a NUL TERMINATES the word the shell builds (`$'ab\0cd'` is `ab`).
|
|
947
|
+
*/
|
|
948
|
+
/**
|
|
949
|
+
* A code point rendered the way bash renders it, or null when bash emits bytes a JavaScript string
|
|
950
|
+
* cannot hold.
|
|
951
|
+
*
|
|
952
|
+
* `String.fromCodePoint` THROWS above 0x10FFFF, and the hook's catch turns any exception from this
|
|
953
|
+
* classifier into fail-OPEN — so `rm -rf $'\Uffffffff'; rm -rf .dz` deleted the store while the
|
|
954
|
+
* guard said nothing (cross-family review, gpt-5.6-sol, round 9).
|
|
955
|
+
*
|
|
956
|
+
* MEASURED where the boundary is, because "invalid" is not one behaviour. `$'A\U########B'`:
|
|
957
|
+
* - `\U80000000`, `\UFFFFFFFE`, `\UFFFFFFFF` produce the two bytes `AB` — the escape expands to
|
|
958
|
+
* NOTHING. Reproducing that is load-bearing, not cosmetic: `rm -rf $'.d\Uffffffffz'` deletes
|
|
959
|
+
* `.dz`, so an empty expansion BUILDS a protected path.
|
|
960
|
+
* - `\U110000`, `\U1FFFFF`, `\U3FFFFFF`, `\U7FFFFFFF` emit four to six bytes of old-style UTF-8,
|
|
961
|
+
* which a UTF-16 string cannot represent. Those are answered `null`, and the caller marks the
|
|
962
|
+
* word not-decidable-by-form rather than inventing a spelling for it.
|
|
963
|
+
*/
|
|
964
|
+
const ANSI_C_EMPTY_FROM = 0x80000000;
|
|
965
|
+
|
|
966
|
+
function ansiCCharacter(code: number): string | null {
|
|
967
|
+
if (code >= ANSI_C_EMPTY_FROM) return ''; // bash expands it to nothing at all
|
|
968
|
+
if (code > 0x10ffff) return null; // real bytes, but not expressible here
|
|
969
|
+
return String.fromCodePoint(code);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** The decoded text, plus whether any part of it could not be reproduced faithfully. */
|
|
973
|
+
interface AnsiCResult {
|
|
974
|
+
text: string;
|
|
975
|
+
/** True when bash would emit bytes this string cannot hold — the word is then not decidable. */
|
|
976
|
+
inexpressible: boolean;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function decodeAnsiC(body: string): AnsiCResult {
|
|
980
|
+
let inexpressible = false;
|
|
981
|
+
let out = '';
|
|
982
|
+
for (let i = 0; i < body.length; i++) {
|
|
983
|
+
const c = body.charAt(i);
|
|
984
|
+
if (c !== '\\') { out += c; continue; }
|
|
985
|
+
const e = body.charAt(i + 1);
|
|
986
|
+
if (e === '') { out += '\\'; break; }
|
|
987
|
+
const simple = ANSI_C_SIMPLE.get(e);
|
|
988
|
+
if (simple !== undefined) { out += simple; i++; continue; }
|
|
989
|
+
if (e >= '0' && e <= '7') { // \nnn — octal, up to three
|
|
990
|
+
const m = /^[0-7]{1,3}/.exec(body.slice(i + 1)) as RegExpExecArray;
|
|
991
|
+
const code = parseInt(m[0], 8);
|
|
992
|
+
if (code === 0) return { text: out, inexpressible }; // a NUL ends the shell's word
|
|
993
|
+
out += String.fromCharCode(code);
|
|
994
|
+
i += m[0].length;
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
if (e === 'x' || e === 'u' || e === 'U') { // \xHH / \uHHHH / \UHHHHHHHH
|
|
998
|
+
const width = e === 'x' ? 2 : e === 'u' ? 4 : 8;
|
|
999
|
+
const m = new RegExp(`^[0-9A-Fa-f]{1,${width}}`).exec(body.slice(i + 2));
|
|
1000
|
+
if (m === null) { out += '\\' + e; i++; continue; } // `$'\x'` is the literal `\x`
|
|
1001
|
+
const code = parseInt(m[0], 16);
|
|
1002
|
+
if (code === 0) return { text: out, inexpressible };
|
|
1003
|
+
const rendered = ansiCCharacter(code);
|
|
1004
|
+
if (rendered === null) inexpressible = true;
|
|
1005
|
+
else out += rendered;
|
|
1006
|
+
i += 1 + m[0].length;
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
if (e === 'c') { // \cX — a control character
|
|
1010
|
+
const x = body.charAt(i + 2);
|
|
1011
|
+
if (x === '') { out += '\\c'; i++; continue; }
|
|
1012
|
+
const code = x === '?' ? 0x7f : x.toUpperCase().charCodeAt(0) & 0x1f;
|
|
1013
|
+
if (code === 0) return { text: out, inexpressible };
|
|
1014
|
+
out += String.fromCharCode(code);
|
|
1015
|
+
i += 2;
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
out += '\\' + e; // unrecognised: the backslash stays
|
|
1019
|
+
i++;
|
|
1020
|
+
}
|
|
1021
|
+
return { text: out, inexpressible };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* The subset that still expands INSIDE double quotes: parameter and command substitution, nothing
|
|
1026
|
+
* else. Globbing is filename expansion, and the shell does not perform it on a quoted word.
|
|
1027
|
+
*
|
|
1028
|
+
* MEASURED in a throwaway directory (bash 5.1.16): with a file literally named `*` inside the
|
|
1029
|
+
* store, `<verb> -rf ".dzprobe/*"` deleted THAT file and left the rest of the directory alone; with
|
|
1030
|
+
* both `a?.db` and `ab.db` present, `<verb> ".dzprobe/a?.db"` deleted `a?.db` and left `ab.db`. So
|
|
1031
|
+
* the operand is a LITERAL path and the form IS the fact. Applying the unquoted test inside quotes
|
|
1032
|
+
* marked it undecidable, and a literal path into a protected store was skipped (cross-family
|
|
1033
|
+
* review, gpt-5.6-sol, round 4; F21). The unquoted direction does not move — F21-guard.
|
|
1034
|
+
*/
|
|
1035
|
+
const DQ_DYNAMIC_CHARS = /[`]/;
|
|
1036
|
+
|
|
1037
|
+
type CaseSpanPhase = 'await-in' | 'patterns' | 'body';
|
|
1038
|
+
|
|
1039
|
+
interface CommandSpanFrame {
|
|
1040
|
+
readonly kind: 'command';
|
|
1041
|
+
parenDepth: number;
|
|
1042
|
+
atCommandPosition: boolean;
|
|
1043
|
+
word: string;
|
|
1044
|
+
wordBare: boolean;
|
|
1045
|
+
wordStarted: boolean;
|
|
1046
|
+
readonly cases: { phase: CaseSpanPhase; readonly patternParenDepth: number }[];
|
|
1047
|
+
readonly pendingHeredocs: { readonly delim: string; readonly stripTabs: boolean }[];
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
type ShellSpanFrame =
|
|
1051
|
+
| CommandSpanFrame
|
|
1052
|
+
| { readonly kind: 'double-quote' }
|
|
1053
|
+
| { readonly kind: 'parameter'; braceDepth: number }
|
|
1054
|
+
| { readonly kind: 'arithmetic'; parenDepth: number };
|
|
1055
|
+
|
|
1056
|
+
const SPAN_COMMAND_PREFIX_WORDS: ReadonlySet<string> = new Set([
|
|
1057
|
+
'if', 'then', 'elif', 'else', 'do', 'while', 'until', 'for', 'select', '!', 'time', 'coproc',
|
|
1058
|
+
]);
|
|
1059
|
+
|
|
1060
|
+
function newCommandSpanFrame(): CommandSpanFrame {
|
|
1061
|
+
return {
|
|
1062
|
+
kind: 'command',
|
|
1063
|
+
parenDepth: 1,
|
|
1064
|
+
atCommandPosition: true,
|
|
1065
|
+
word: '',
|
|
1066
|
+
wordBare: true,
|
|
1067
|
+
wordStarted: false,
|
|
1068
|
+
cases: [],
|
|
1069
|
+
pendingHeredocs: [],
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function markSpanWordOpaque(frame: CommandSpanFrame): void {
|
|
1074
|
+
frame.wordStarted = true;
|
|
1075
|
+
frame.wordBare = false;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** Finish one bare word only far enough to distinguish Bash grammar from literal `)` bytes. */
|
|
1079
|
+
function finishSpanWord(frame: CommandSpanFrame): void {
|
|
1080
|
+
if (!frame.wordStarted) return;
|
|
1081
|
+
const token = frame.wordBare ? frame.word : '';
|
|
1082
|
+
const activeCase = frame.cases.at(-1);
|
|
1083
|
+
|
|
1084
|
+
if (token === 'esac' && frame.atCommandPosition && activeCase !== undefined
|
|
1085
|
+
&& activeCase.phase !== 'await-in') {
|
|
1086
|
+
frame.cases.pop();
|
|
1087
|
+
frame.atCommandPosition = false;
|
|
1088
|
+
} else if (token === 'in' && activeCase?.phase === 'await-in') {
|
|
1089
|
+
activeCase.phase = 'patterns';
|
|
1090
|
+
frame.atCommandPosition = true;
|
|
1091
|
+
} else if (token === 'case' && frame.atCommandPosition) {
|
|
1092
|
+
frame.cases.push({ phase: 'await-in', patternParenDepth: frame.parenDepth });
|
|
1093
|
+
frame.atCommandPosition = false;
|
|
1094
|
+
} else if (activeCase?.phase !== 'patterns') {
|
|
1095
|
+
frame.atCommandPosition = SPAN_COMMAND_PREFIX_WORDS.has(token);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
frame.word = '';
|
|
1099
|
+
frame.wordBare = true;
|
|
1100
|
+
frame.wordStarted = false;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** Read and dequote the one shell word naming a heredoc terminator. */
|
|
1104
|
+
function readSpanHeredoc(command: string, operator: number): {
|
|
1105
|
+
readonly delim: string;
|
|
1106
|
+
readonly stripTabs: boolean;
|
|
1107
|
+
readonly next: number;
|
|
1108
|
+
} | null {
|
|
1109
|
+
let j = operator + 2;
|
|
1110
|
+
let stripTabs = false;
|
|
1111
|
+
if (command.charAt(j) === '-') { stripTabs = true; j++; }
|
|
1112
|
+
while (command.charAt(j) === ' ' || command.charAt(j) === '\t') j++;
|
|
1113
|
+
|
|
1114
|
+
let delim = '';
|
|
1115
|
+
let started = false;
|
|
1116
|
+
while (j < command.length) {
|
|
1117
|
+
const c = command.charAt(j);
|
|
1118
|
+
if (/\s/.test(c) || ';&|()<>'.includes(c)) break;
|
|
1119
|
+
if (c === '\\') {
|
|
1120
|
+
const next = command.charAt(j + 1);
|
|
1121
|
+
if (next === '') return null;
|
|
1122
|
+
delim += next;
|
|
1123
|
+
started = true;
|
|
1124
|
+
j += 2;
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
if (c === '$' && command.charAt(j + 1) === "'") {
|
|
1128
|
+
const end = ansiCEnd(command, j + 1);
|
|
1129
|
+
if (end === -1) return null;
|
|
1130
|
+
delim += decodeAnsiC(command.slice(j + 2, end)).text;
|
|
1131
|
+
started = true;
|
|
1132
|
+
j = end + 1;
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
if (c === "'" || c === '"') {
|
|
1136
|
+
let end = j + 1;
|
|
1137
|
+
let text = '';
|
|
1138
|
+
while (end < command.length && command.charAt(end) !== c) {
|
|
1139
|
+
if (c === '"' && command.charAt(end) === '\\' && end + 1 < command.length) {
|
|
1140
|
+
text += command.charAt(end + 1);
|
|
1141
|
+
end += 2;
|
|
1142
|
+
} else {
|
|
1143
|
+
text += command.charAt(end);
|
|
1144
|
+
end++;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
if (end >= command.length) return null;
|
|
1148
|
+
delim += text;
|
|
1149
|
+
started = true;
|
|
1150
|
+
j = end + 1;
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
delim += c;
|
|
1154
|
+
started = true;
|
|
1155
|
+
j++;
|
|
1156
|
+
}
|
|
1157
|
+
return started ? { delim, stripTabs, next: j } : null;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Find a double-quote or command-substitution boundary with one EXPLICIT stack.
|
|
1162
|
+
*
|
|
1163
|
+
* R20 made the two readers mutually recursive. That fixed one nesting level but made call-stack
|
|
1164
|
+
* depth attacker-controlled: enough alternating `"$(` frames threw before the trailing literal
|
|
1165
|
+
* deletion was read. This machine has no input-shaped calls; every byte advances `j` or pushes/
|
|
1166
|
+
* pops a heap frame.
|
|
1167
|
+
*
|
|
1168
|
+
* A `$()` body is shell grammar, not merely balanced punctuation. In particular, the `)` after a
|
|
1169
|
+
* case pattern is a branch delimiter. Command frames therefore keep the small amount of lexical
|
|
1170
|
+
* state needed to assign every bare `)` its grammatical role; quoted, parameter, arithmetic,
|
|
1171
|
+
* process-substitution and nested-command regions are separate frames rather than new recursion,
|
|
1172
|
+
* and heredoc bodies are skipped as data before any of their bytes can acquire syntax.
|
|
1173
|
+
*/
|
|
1174
|
+
function shellSpanEnd(command: string, open: number, initial: 'command' | 'double-quote'): number {
|
|
1175
|
+
const stack: ShellSpanFrame[] = [initial === 'command' ? newCommandSpanFrame() : { kind: 'double-quote' }];
|
|
1176
|
+
let j = open + 1;
|
|
1177
|
+
|
|
1178
|
+
while (j < command.length) {
|
|
1179
|
+
const frame = stack.at(-1) as ShellSpanFrame;
|
|
1180
|
+
const c = command.charAt(j);
|
|
1181
|
+
|
|
1182
|
+
if (frame.kind === 'double-quote') {
|
|
1183
|
+
if (c === '\\') { j += Math.min(2, command.length - j); continue; }
|
|
1184
|
+
if (c === '$' && command.charAt(j + 1) === '(' && command.charAt(j + 2) === '(') {
|
|
1185
|
+
stack.push({ kind: 'arithmetic', parenDepth: 2 });
|
|
1186
|
+
j += 3;
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
if (c === '$' && command.charAt(j + 1) === '(') {
|
|
1190
|
+
stack.push(newCommandSpanFrame());
|
|
1191
|
+
j += 2;
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
if (c === '$' && command.charAt(j + 1) === '{') {
|
|
1195
|
+
stack.push({ kind: 'parameter', braceDepth: 1 });
|
|
1196
|
+
j += 2;
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
if (c === '`') {
|
|
1200
|
+
const end = backtickEnd(command, j);
|
|
1201
|
+
if (end === -1) return -1;
|
|
1202
|
+
j = end + 1;
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
if (c === '"') {
|
|
1206
|
+
stack.pop();
|
|
1207
|
+
if (stack.length === 0) return j;
|
|
1208
|
+
j++;
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
j++;
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (frame.kind === 'parameter') {
|
|
1216
|
+
if (c === '\\') { j += Math.min(2, command.length - j); continue; }
|
|
1217
|
+
if (c === '$' && command.charAt(j + 1) === '(' && command.charAt(j + 2) === '(') {
|
|
1218
|
+
stack.push({ kind: 'arithmetic', parenDepth: 2 });
|
|
1219
|
+
j += 3;
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
if (c === '$' && command.charAt(j + 1) === '(') {
|
|
1223
|
+
stack.push(newCommandSpanFrame());
|
|
1224
|
+
j += 2;
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
if (c === '$' && command.charAt(j + 1) === '{') { frame.braceDepth++; j += 2; continue; }
|
|
1228
|
+
if (c === '"') { stack.push({ kind: 'double-quote' }); j++; continue; }
|
|
1229
|
+
if (c === "'") {
|
|
1230
|
+
const end = singleQuotedEnd(command, j);
|
|
1231
|
+
if (end === -1) return -1;
|
|
1232
|
+
j = end + 1;
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
if (c === '}') {
|
|
1236
|
+
frame.braceDepth--;
|
|
1237
|
+
if (frame.braceDepth === 0) stack.pop();
|
|
1238
|
+
}
|
|
1239
|
+
j++;
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
if (frame.kind === 'arithmetic') {
|
|
1244
|
+
if (c === '\\') { j += Math.min(2, command.length - j); continue; }
|
|
1245
|
+
if (c === '$' && command.charAt(j + 1) === '(' && command.charAt(j + 2) !== '(') {
|
|
1246
|
+
stack.push(newCommandSpanFrame());
|
|
1247
|
+
j += 2;
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
if (c === '$' && command.charAt(j + 1) === '{') {
|
|
1251
|
+
stack.push({ kind: 'parameter', braceDepth: 1 });
|
|
1252
|
+
j += 2;
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
if (c === '(') frame.parenDepth++;
|
|
1256
|
+
if (c === ')') {
|
|
1257
|
+
frame.parenDepth--;
|
|
1258
|
+
if (frame.parenDepth === 0) stack.pop();
|
|
1259
|
+
}
|
|
1260
|
+
j++;
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// From here the frame is a command-substitution body.
|
|
1265
|
+
if (c === '\\') { markSpanWordOpaque(frame); j += Math.min(2, command.length - j); continue; }
|
|
1266
|
+
if (c === '$' && command.charAt(j + 1) === "'") {
|
|
1267
|
+
markSpanWordOpaque(frame);
|
|
1268
|
+
const end = ansiCEnd(command, j + 1);
|
|
1269
|
+
if (end === -1) return -1;
|
|
1270
|
+
j = end + 1;
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
if (c === '$' && command.charAt(j + 1) === '(' && command.charAt(j + 2) === '(') {
|
|
1274
|
+
markSpanWordOpaque(frame);
|
|
1275
|
+
stack.push({ kind: 'arithmetic', parenDepth: 2 });
|
|
1276
|
+
j += 3;
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
if (c === '$' && command.charAt(j + 1) === '(') {
|
|
1280
|
+
markSpanWordOpaque(frame);
|
|
1281
|
+
stack.push(newCommandSpanFrame());
|
|
1282
|
+
j += 2;
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
if (c === '$' && command.charAt(j + 1) === '{') {
|
|
1286
|
+
markSpanWordOpaque(frame);
|
|
1287
|
+
stack.push({ kind: 'parameter', braceDepth: 1 });
|
|
1288
|
+
j += 2;
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
if (c === '<' && command.charAt(j + 1) === '<' && command.charAt(j + 2) !== '<') {
|
|
1292
|
+
finishSpanWord(frame);
|
|
1293
|
+
const heredoc = readSpanHeredoc(command, j);
|
|
1294
|
+
if (heredoc === null) return -1;
|
|
1295
|
+
frame.pendingHeredocs.push({ delim: heredoc.delim, stripTabs: heredoc.stripTabs });
|
|
1296
|
+
j = heredoc.next;
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
if ((c === '<' || c === '>') && command.charAt(j + 1) === '(') {
|
|
1300
|
+
markSpanWordOpaque(frame);
|
|
1301
|
+
stack.push(newCommandSpanFrame());
|
|
1302
|
+
j += 2;
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
if (c === '`') {
|
|
1306
|
+
markSpanWordOpaque(frame);
|
|
1307
|
+
const end = backtickEnd(command, j);
|
|
1308
|
+
if (end === -1) return -1;
|
|
1309
|
+
j = end + 1;
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
if (c === "'") {
|
|
1313
|
+
markSpanWordOpaque(frame);
|
|
1314
|
+
const end = singleQuotedEnd(command, j);
|
|
1315
|
+
if (end === -1) return -1;
|
|
1316
|
+
j = end + 1;
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
if (c === '"') {
|
|
1320
|
+
markSpanWordOpaque(frame);
|
|
1321
|
+
stack.push({ kind: 'double-quote' });
|
|
1322
|
+
j++;
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
if (c === '#' && !frame.wordStarted) {
|
|
1326
|
+
const newline = command.indexOf('\n', j + 1);
|
|
1327
|
+
j = newline === -1 ? command.length : newline;
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
if (c === '\n') {
|
|
1331
|
+
finishSpanWord(frame);
|
|
1332
|
+
j++;
|
|
1333
|
+
for (const heredoc of frame.pendingHeredocs) {
|
|
1334
|
+
let terminated = false;
|
|
1335
|
+
while (j <= command.length) {
|
|
1336
|
+
const newline = command.indexOf('\n', j);
|
|
1337
|
+
const lineEnd = newline === -1 ? command.length : newline;
|
|
1338
|
+
const line = command.slice(j, lineEnd);
|
|
1339
|
+
j = newline === -1 ? command.length + 1 : newline + 1;
|
|
1340
|
+
const comparable = heredoc.stripTabs ? line.replace(/^\t+/, '') : line;
|
|
1341
|
+
if (comparable === heredoc.delim) { terminated = true; break; }
|
|
1342
|
+
if (newline === -1) break;
|
|
1343
|
+
}
|
|
1344
|
+
if (!terminated) return -1;
|
|
1345
|
+
}
|
|
1346
|
+
frame.pendingHeredocs.length = 0;
|
|
1347
|
+
frame.atCommandPosition = true;
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
if (/\s/.test(c)) { finishSpanWord(frame); j++; continue; }
|
|
1351
|
+
if (c === ';' || c === '&' || c === '|') {
|
|
1352
|
+
finishSpanWord(frame);
|
|
1353
|
+
const activeCase = frame.cases.at(-1);
|
|
1354
|
+
const caseEndLength = c === ';' && command.charAt(j + 1) === ';'
|
|
1355
|
+
? (command.charAt(j + 2) === '&' ? 3 : 2)
|
|
1356
|
+
: c === ';' && command.charAt(j + 1) === '&' ? 2 : 0;
|
|
1357
|
+
if (caseEndLength > 0 && activeCase?.phase === 'body') activeCase.phase = 'patterns';
|
|
1358
|
+
frame.atCommandPosition = true;
|
|
1359
|
+
j += Math.max(caseEndLength, command.charAt(j + 1) === c ? 2 : 1);
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1362
|
+
if (c === '(') {
|
|
1363
|
+
finishSpanWord(frame);
|
|
1364
|
+
frame.parenDepth++;
|
|
1365
|
+
frame.atCommandPosition = true;
|
|
1366
|
+
j++;
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
if (c === ')') {
|
|
1370
|
+
finishSpanWord(frame);
|
|
1371
|
+
const activeCase = frame.cases.at(-1);
|
|
1372
|
+
if (activeCase?.phase === 'patterns' && frame.parenDepth === activeCase.patternParenDepth) {
|
|
1373
|
+
activeCase.phase = 'body';
|
|
1374
|
+
frame.atCommandPosition = true;
|
|
1375
|
+
j++;
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
frame.parenDepth--;
|
|
1379
|
+
if (frame.parenDepth === 0) {
|
|
1380
|
+
stack.pop();
|
|
1381
|
+
if (stack.length === 0) return j;
|
|
1382
|
+
}
|
|
1383
|
+
j++;
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
frame.wordStarted = true;
|
|
1388
|
+
frame.word += c;
|
|
1389
|
+
j++;
|
|
1390
|
+
}
|
|
1391
|
+
return -1;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** Closing ordinary double quote, respecting iterative nested shell spans. */
|
|
1395
|
+
function doubleQuotedEnd(command: string, open: number): number {
|
|
1396
|
+
return shellSpanEnd(command, open, 'double-quote');
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/** Closing `)` for a `$(` opened at `open`, respecting iterative nested shell spans. */
|
|
1400
|
+
function commandSubstitutionEnd(command: string, open: number): number {
|
|
1401
|
+
return shellSpanEnd(command, open, 'command');
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function lex(command: string): LexResult {
|
|
1405
|
+
const n = command.length;
|
|
1406
|
+
const lexemes: Lexeme[] = [];
|
|
1407
|
+
const pending: { delim: string; stripTabs: boolean }[] = [];
|
|
1408
|
+
let buf = '';
|
|
1409
|
+
/** One character per character of `buf` — see Word.literalMask. */
|
|
1410
|
+
let mask = '';
|
|
1411
|
+
let hasWord = false;
|
|
1412
|
+
let dynamic = false;
|
|
1413
|
+
/** Append text the shell will NOT read as syntax, because it was quoted or escaped. */
|
|
1414
|
+
const addQuoted = (text: string): void => { buf += text; mask += '1'.repeat(text.length); };
|
|
1415
|
+
/** Append text the shell reads as bare syntax. */
|
|
1416
|
+
const addBare = (text: string): void => { buf += text; mask += '0'.repeat(text.length); };
|
|
1417
|
+
/**
|
|
1418
|
+
* A span the shell EXPANDS but that contributes no text of its own — `$(…)` and a backtick.
|
|
1419
|
+
*
|
|
1420
|
+
* It still has to occupy a POSITION, or the mask cannot say which brace alternative was dynamic:
|
|
1421
|
+
* MEASURED, `{.dzprobe,$(echo x)}` and `{.dzprobe,\`echo x\`}` both delete the store, so the
|
|
1422
|
+
* literal alternative must be refused while the substitution alternative is ignored. The
|
|
1423
|
+
* placeholder is a private-use character, and any expansion containing it is dynamic by
|
|
1424
|
+
* construction, so it can never reach a reported path.
|
|
1425
|
+
*/
|
|
1426
|
+
const addExpandedSpan = (): void => { buf += DYNAMIC_SPAN_MARK; mask += 'd'; };
|
|
1427
|
+
let i = 0;
|
|
1428
|
+
|
|
1429
|
+
const fail = (why: string): LexResult => ({ lexemes: [], failure: why, prefixRuns: false });
|
|
1430
|
+
/** The shell warns and runs what it already parsed — see LexResult.prefixRuns. */
|
|
1431
|
+
const failKeepingPrefix = (why: string): LexResult => ({ lexemes, failure: why, prefixRuns: true });
|
|
1432
|
+
/**
|
|
1433
|
+
* A numeric file descriptor written against a redirection operator (`2>`, `3<<EOF`) belongs to
|
|
1434
|
+
* the OPERATOR, not to the preceding word — the shell never passes it to the program.
|
|
1435
|
+
*
|
|
1436
|
+
* The ordinary-redirection branch had this; the HEREDOC branch did not, so `3<<EOF <deletion>`
|
|
1437
|
+
* flushed `3` as the segment's first word, the head became the unknown command `3`, and an
|
|
1438
|
+
* unknown head STOPS the search — the deletion the shell performs was never examined
|
|
1439
|
+
* (cross-family review, gpt-5.6-sol, round 3). Only an ALL-DIGIT word is dropped: dropping any
|
|
1440
|
+
* word before the operator would erase a live operand, e.g. `rm -rf .dz<<EOF` (F13-guard).
|
|
1441
|
+
*/
|
|
1442
|
+
const dropAttachedDescriptor = (): void => {
|
|
1443
|
+
if (hasWord && /^\d+$/.test(buf)) { buf = ''; mask = ''; hasWord = false; dynamic = false; }
|
|
1444
|
+
};
|
|
1445
|
+
const flush = (): void => {
|
|
1446
|
+
if (!hasWord) return;
|
|
1447
|
+
lexemes.push({ kind: 'word', text: buf, dynamic, literalMask: mask });
|
|
1448
|
+
buf = '';
|
|
1449
|
+
mask = '';
|
|
1450
|
+
hasWord = false;
|
|
1451
|
+
dynamic = false;
|
|
1452
|
+
};
|
|
1453
|
+
|
|
1454
|
+
while (i < n) {
|
|
1455
|
+
const c = command.charAt(i);
|
|
1456
|
+
|
|
1457
|
+
// A heredoc body is swallowed at the newline that opens it — it is data, not command.
|
|
1458
|
+
if (c === '\n' && pending.length > 0) {
|
|
1459
|
+
flush();
|
|
1460
|
+
lexemes.push({ kind: 'sep', ch: '\n' });
|
|
1461
|
+
i++;
|
|
1462
|
+
for (const h of pending) {
|
|
1463
|
+
let terminated = false;
|
|
1464
|
+
while (i < n) {
|
|
1465
|
+
const nl = command.indexOf('\n', i);
|
|
1466
|
+
const lineEnd = nl === -1 ? n : nl;
|
|
1467
|
+
const line = command.slice(i, lineEnd);
|
|
1468
|
+
i = nl === -1 ? n : nl + 1;
|
|
1469
|
+
// EXACT, not trimmed. The shell closes a heredoc only on a line that IS the delimiter:
|
|
1470
|
+
// `EOF ` is data, not the end of the block. Comparing with `trimEnd()` closed the block
|
|
1471
|
+
// one line early and the lines that are still DATA were then classified as commands — a
|
|
1472
|
+
// refusal on a deletion the shell never performs (cross-family review, gpt-5.6-sol,
|
|
1473
|
+
// round 2). `<<-` strips LEADING TABS and nothing else, which is the only relaxation the
|
|
1474
|
+
// shell itself allows.
|
|
1475
|
+
const cmp = h.stripTabs ? line.replace(/^\t+/, '') : line;
|
|
1476
|
+
if (cmp === h.delim) { terminated = true; break; }
|
|
1477
|
+
}
|
|
1478
|
+
if (!terminated) return failKeepingPrefix(`вставной блок не закрыт меткой ${h.delim}`);
|
|
1479
|
+
}
|
|
1480
|
+
pending.length = 0;
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// A backslash makes the NEXT character literal and is then REMOVED by the shell, so the guard
|
|
1485
|
+
// removes it too: `.d\z` is the file `.dz`. The first version erased both characters, which
|
|
1486
|
+
// left `.d` — a path that matches nothing (cross-family review, gpt-5.6-sol). An escaped
|
|
1487
|
+
// metacharacter stays a plain character: not a separator, not a quote, and not a glob, which
|
|
1488
|
+
// is why nothing here sets `dynamic`.
|
|
1489
|
+
if (c === '\\') {
|
|
1490
|
+
if (i + 1 >= n) { addQuoted('\\'); hasWord = true; i++; continue; }
|
|
1491
|
+
const e = command.charAt(i + 1);
|
|
1492
|
+
if (e === '\n') { i += 2; continue; } // line continuation: the shell drops both
|
|
1493
|
+
addQuoted(e);
|
|
1494
|
+
hasWord = true;
|
|
1495
|
+
i += 2;
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// Inside SINGLE quotes the shell expands NOTHING: `$`, a backtick and a glob are ordinary
|
|
1500
|
+
// characters of the file name, so `rm -rf '.agentic-qe/$x'` deletes a literal path. Applying
|
|
1501
|
+
// the dynamic-character test to this body marked a perfectly decidable word undecidable and the
|
|
1502
|
+
// guard skipped it — the form WAS the fact and we chose not to look (cross-family review,
|
|
1503
|
+
// gpt-5.6-sol, round 2). Nothing here sets `dynamic`, for exactly the same reason the backslash
|
|
1504
|
+
// branch does not.
|
|
1505
|
+
if (c === "'") {
|
|
1506
|
+
const end = singleQuotedEnd(command, i);
|
|
1507
|
+
if (end === -1) return fail('незакрытая одинарная кавычка');
|
|
1508
|
+
addQuoted(command.slice(i + 1, end));
|
|
1509
|
+
hasWord = true;
|
|
1510
|
+
i = end + 1;
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
if (c === '"') {
|
|
1515
|
+
let j = i + 1;
|
|
1516
|
+
let closed = false;
|
|
1517
|
+
let acc = '';
|
|
1518
|
+
let accMask = '';
|
|
1519
|
+
const keep = (text: string, kind: string): void => { acc += text; accMask += kind.repeat(text.length); };
|
|
1520
|
+
while (j < n) {
|
|
1521
|
+
const d = command.charAt(j);
|
|
1522
|
+
if (d === '\\') {
|
|
1523
|
+
if (j + 1 >= n) { keep('\\', '1'); j++; continue; }
|
|
1524
|
+
const e = command.charAt(j + 1);
|
|
1525
|
+
if (e === '"' || e === '\\' || e === '$' || e === '`') { keep(e, '1'); j += 2; continue; }
|
|
1526
|
+
if (e === '\n') { j += 2; continue; }
|
|
1527
|
+
keep('\\' + e, '1');
|
|
1528
|
+
j += 2;
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
// `$()` remains active inside double quotes, but every byte of its BODY belongs to the
|
|
1532
|
+
// nested shell text. In particular, a `"` quoted inside that body cannot close THIS word.
|
|
1533
|
+
// Preserve the whole expansion as one dynamic position and let the shared balanced reader
|
|
1534
|
+
// skip it. Backticks are the same shell feature in the legacy spelling.
|
|
1535
|
+
if (d === '$' && command.charAt(j + 1) === '(') {
|
|
1536
|
+
const end = commandSubstitutionEnd(command, j + 1);
|
|
1537
|
+
if (end === -1) return fail('незакрытая подстановка команды $( в двойных кавычках');
|
|
1538
|
+
dynamic = true;
|
|
1539
|
+
keep(DYNAMIC_SPAN_MARK, 'd');
|
|
1540
|
+
j = end + 1;
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
if (d === '`') {
|
|
1544
|
+
const end = backtickEnd(command, j);
|
|
1545
|
+
if (end === -1) return fail('незакрытая обратная кавычка в двойных кавычках');
|
|
1546
|
+
dynamic = true;
|
|
1547
|
+
keep(DYNAMIC_SPAN_MARK, 'd');
|
|
1548
|
+
j = end + 1;
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
if (d === '"') { closed = true; break; }
|
|
1552
|
+
// Only `$` and a backtick still expand inside double quotes — a glob does not. See
|
|
1553
|
+
// DQ_DYNAMIC_CHARS for the measurement that separates the two.
|
|
1554
|
+
if (DQ_DYNAMIC_CHARS.test(d) || (d === '$' && dollarExpands(command, j, true))) {
|
|
1555
|
+
dynamic = true;
|
|
1556
|
+
keep(d, 'd');
|
|
1557
|
+
}
|
|
1558
|
+
else keep(d, '1');
|
|
1559
|
+
j++;
|
|
1560
|
+
}
|
|
1561
|
+
if (!closed) return fail('незакрытая двойная кавычка');
|
|
1562
|
+
buf += acc;
|
|
1563
|
+
mask += accMask;
|
|
1564
|
+
hasWord = true;
|
|
1565
|
+
i = j + 1;
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// `$(…)` is a command substitution — the SAME shell feature as a backtick in its modern
|
|
1570
|
+
// spelling, and the same deliberate refusal to read it (ADR-001, option C). It is a dynamic
|
|
1571
|
+
// SPAN INSIDE the word, not a separator. Reading its two parentheses as unconditional
|
|
1572
|
+
// separators STARTED a new segment headed by the word that followed, so `echo $(pwd) <deletion
|
|
1573
|
+
// words>` — which the shell hands to `echo`, deleting nothing — was REFUSED (cross-family
|
|
1574
|
+
// review, gpt-5.6-sol, round 3). Bare parentheses stay in SEPARATORS: `( <deletion> )` really
|
|
1575
|
+
// is a subshell, and if they stopped separating, the head of the group would become the
|
|
1576
|
+
// parenthesis itself and the deletion behind it would read as an unknown executable.
|
|
1577
|
+
//
|
|
1578
|
+
// The OUTCOME is the backtick's outcome, deliberately: a `$(…)` target is `allow` (the target
|
|
1579
|
+
// is resolved by the shell — the guard's first printed limit) and an UNCLOSED one is
|
|
1580
|
+
// `undecidable`. Two spellings of one shell feature may not decide differently.
|
|
1581
|
+
// `$'…'` is ANSI-C QUOTING — the shell resolves it and passes a literal word, so it is decoded
|
|
1582
|
+
// here rather than falling into the dynamic path below. See decodeAnsiC for the measurement.
|
|
1583
|
+
if (c === '$' && command.charAt(i + 1) === "'") {
|
|
1584
|
+
const end = ansiCEnd(command, i + 1);
|
|
1585
|
+
if (end === -1) return fail("незакрытая кавычка ANSI-C $'");
|
|
1586
|
+
const ansiC = decodeAnsiC(command.slice(i + 2, end));
|
|
1587
|
+
addQuoted(ansiC.text);
|
|
1588
|
+
if (ansiC.inexpressible) { dynamic = true; addExpandedSpan(); }
|
|
1589
|
+
hasWord = true;
|
|
1590
|
+
i = end + 1;
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
if (c === '$' && command.charAt(i + 1) === '(') {
|
|
1595
|
+
const end = commandSubstitutionEnd(command, i + 1);
|
|
1596
|
+
if (end === -1) return fail('незакрытая подстановка команды $(');
|
|
1597
|
+
addExpandedSpan();
|
|
1598
|
+
hasWord = true;
|
|
1599
|
+
dynamic = true;
|
|
1600
|
+
i = end + 1;
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// A command substitution is a command we deliberately DO NOT read (ADR-001, option C).
|
|
1605
|
+
if (c === '`') {
|
|
1606
|
+
const j = backtickEnd(command, i);
|
|
1607
|
+
if (j === -1) return fail('незакрытая обратная кавычка');
|
|
1608
|
+
addExpandedSpan();
|
|
1609
|
+
hasWord = true;
|
|
1610
|
+
dynamic = true;
|
|
1611
|
+
i = j + 1;
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// `#` starts a comment only at the start of a word, so `http://x#y` is left alone.
|
|
1616
|
+
if (c === '#' && !hasWord) {
|
|
1617
|
+
const nl = command.indexOf('\n', i);
|
|
1618
|
+
i = nl === -1 ? n : nl;
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// Heredoc: `<<WORD`, `<<-WORD`, `<<'WORD'`. `<<<` is a here-STRING, not a heredoc.
|
|
1623
|
+
if (c === '<' && command.charAt(i + 1) === '<' && command.charAt(i + 2) !== '<') {
|
|
1624
|
+
let j = i + 2;
|
|
1625
|
+
let stripTabs = false;
|
|
1626
|
+
if (command.charAt(j) === '-') { stripTabs = true; j++; }
|
|
1627
|
+
while (j < n && (command.charAt(j) === ' ' || command.charAt(j) === '\t')) j++;
|
|
1628
|
+
// The delimiter is one WORD, and quoting is the SHELL's syntax inside it, not part of the
|
|
1629
|
+
// label: `<<E"OF"` is dequoted to `EOF`, exactly like `<<"EOF"` and `<<E\\OF`. Reading only up
|
|
1630
|
+
// to the first quote recorded `E`, so the block closed at the first `E` line and the lines
|
|
1631
|
+
// that are still DATA were classified as commands — a refusal on a deletion the shell never
|
|
1632
|
+
// performs (cross-family review, gpt-5.6-sol, round 5). MEASURED: `cat <<E"OF"` with a body
|
|
1633
|
+
// of `E` then `<verb> -rf .dzprobe`, closed by `EOF`, printed BOTH lines and left the target
|
|
1634
|
+
// ALIVE. Whether the delimiter was quoted decides expansion INSIDE the body, which this guard
|
|
1635
|
+
// never reads, so only the label text matters here.
|
|
1636
|
+
let delim = '';
|
|
1637
|
+
let sawLabel = false;
|
|
1638
|
+
while (j < n) {
|
|
1639
|
+
const d = command.charAt(j);
|
|
1640
|
+
if (d === "'" || d === '"') {
|
|
1641
|
+
const end = command.indexOf(d, j + 1);
|
|
1642
|
+
if (end === -1) return fail('незакрытая кавычка в метке вставного блока');
|
|
1643
|
+
delim += command.slice(j + 1, end);
|
|
1644
|
+
sawLabel = true;
|
|
1645
|
+
j = end + 1;
|
|
1646
|
+
continue;
|
|
1647
|
+
}
|
|
1648
|
+
// `$'…'` is a quoting form here too — MEASURED: `cat <<$'EOF'` with a plain `EOF`
|
|
1649
|
+
// terminator prints the body and runs on, so bash dequotes the delimiter to `EOF`. Keeping
|
|
1650
|
+
// the `$` recorded `$EOF`, the block never closed, the whole command failed to lex and the
|
|
1651
|
+
// hook failed open (cross-family review, gpt-5.6-sol, round 9).
|
|
1652
|
+
if (d === '$' && command.charAt(j + 1) === "'") {
|
|
1653
|
+
const close = ansiCEnd(command, j + 1);
|
|
1654
|
+
if (close === -1) return fail("незакрытая кавычка ANSI-C $' в метке вставного блока");
|
|
1655
|
+
delim += decodeAnsiC(command.slice(j + 2, close)).text;
|
|
1656
|
+
sawLabel = true;
|
|
1657
|
+
j = close + 1;
|
|
1658
|
+
continue;
|
|
1659
|
+
}
|
|
1660
|
+
if (d === '\\') {
|
|
1661
|
+
if (j + 1 >= n) break;
|
|
1662
|
+
delim += command.charAt(j + 1);
|
|
1663
|
+
sawLabel = true;
|
|
1664
|
+
j += 2;
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
if (/\s/.test(d) || DELIMITER_ENDS.has(d)) break;
|
|
1668
|
+
delim += d;
|
|
1669
|
+
sawLabel = true;
|
|
1670
|
+
j++;
|
|
1671
|
+
}
|
|
1672
|
+
if (!sawLabel) return fail('вставной блок без метки');
|
|
1673
|
+
dropAttachedDescriptor();
|
|
1674
|
+
flush();
|
|
1675
|
+
i = j;
|
|
1676
|
+
pending.push({ delim, stripTabs });
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// A redirection is an OPERATOR, not text. Without this, a deletion with the redirection glued
|
|
1681
|
+
// to its operand arrives as ONE token whose first path segment is `.dz>` — which is not the
|
|
1682
|
+
// protected store, so the guard allowed it while the shell deleted the store (cross-family
|
|
1683
|
+
// review, gpt-5.6-sol). Must be tested BEFORE the separators, so `&>` is not read as `&`.
|
|
1684
|
+
if (c === '>' || c === '<' || (c === '&' && command.charAt(i + 1) === '>')) {
|
|
1685
|
+
// A leading file descriptor (`2>`) belongs to the operator, not to the previous word.
|
|
1686
|
+
dropAttachedDescriptor();
|
|
1687
|
+
flush();
|
|
1688
|
+
if (c === '&') i++;
|
|
1689
|
+
i++;
|
|
1690
|
+
while (command.charAt(i) === '>' || command.charAt(i) === '<') i++; // `>>`, `<<<`
|
|
1691
|
+
if (command.charAt(i) === '&') i++; // `>&`, `<&`
|
|
1692
|
+
lexemes.push({ kind: 'redirect' });
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// A brace separates only when it is a word of its own — see BRACE_ENDS_A_WORD.
|
|
1697
|
+
if (c === '{' || c === '}') {
|
|
1698
|
+
if (!hasWord && BRACE_ENDS_A_WORD.has(command.charAt(i + 1))) {
|
|
1699
|
+
flush();
|
|
1700
|
+
lexemes.push({ kind: 'sep', ch: c });
|
|
1701
|
+
i++;
|
|
1702
|
+
continue;
|
|
1703
|
+
}
|
|
1704
|
+
addBare(c);
|
|
1705
|
+
hasWord = true;
|
|
1706
|
+
i++;
|
|
1707
|
+
continue;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// Keep the identity of shell short-circuit operators. Treating both characters as unrelated
|
|
1711
|
+
// separators erased the one fact that can prove the right-hand command does not execute.
|
|
1712
|
+
if ((c === '&' || c === '|') && command.charAt(i + 1) === c) {
|
|
1713
|
+
flush();
|
|
1714
|
+
lexemes.push({ kind: 'sep', ch: c + c });
|
|
1715
|
+
i += 2;
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
if (SEPARATORS.has(c)) {
|
|
1720
|
+
flush();
|
|
1721
|
+
lexemes.push({ kind: 'sep', ch: c });
|
|
1722
|
+
i++;
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
if (/\s/.test(c)) {
|
|
1727
|
+
flush();
|
|
1728
|
+
i++;
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if (DYNAMIC_CHARS.test(c) || (c === '$' && dollarExpands(command, i))) {
|
|
1733
|
+
dynamic = true;
|
|
1734
|
+
buf += c;
|
|
1735
|
+
mask += 'd';
|
|
1736
|
+
}
|
|
1737
|
+
else addBare(c);
|
|
1738
|
+
hasWord = true;
|
|
1739
|
+
i++;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
if (pending.length > 0) {
|
|
1743
|
+
flush();
|
|
1744
|
+
return failKeepingPrefix(`вставной блок не закрыт меткой ${pending[0]?.delim ?? ''}`);
|
|
1745
|
+
}
|
|
1746
|
+
flush();
|
|
1747
|
+
return { lexemes, failure: null, prefixRuns: false };
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Every word of one segment, with its brace groups expanded — the shell's own order, head included.
|
|
1752
|
+
*
|
|
1753
|
+
* MEASURED: bash expands braces in COMMAND position as well, and the FIRST expanded word becomes
|
|
1754
|
+
* the verb: `r{m,m} -rf x` arrives as `<rm><rm><-rf><x>` and `{rm,ls} -rf x` as
|
|
1755
|
+
* `<rm><ls><-rf><x>`, both deleting the target. Expanding only operands compared the head
|
|
1756
|
+
* unexpanded, an unknown head STOPS the search, and the deletion was allowed — a direct bypass
|
|
1757
|
+
* (cross-family review, gpt-5.6-sol, round 10). Doing it here, once, for the whole segment is also
|
|
1758
|
+
* what makes a MIXED group work: each expansion carries its OWN mask, so `{.dz,$OTHER}` becomes a
|
|
1759
|
+
* literal `.dz` and a dynamic `$OTHER`, exactly like `rm -rf .dz $OTHER`.
|
|
1760
|
+
*
|
|
1761
|
+
* null when the expansion is too large to enumerate — reported as "could not read", never as safe.
|
|
1762
|
+
*/
|
|
1763
|
+
function expandSegment(words: readonly Word[], expandsBraces: boolean): { words: Word[]; overflowed: boolean } {
|
|
1764
|
+
const out: Word[] = [];
|
|
1765
|
+
let overflowed = false;
|
|
1766
|
+
for (const word of words) {
|
|
1767
|
+
// A shell WITHOUT brace expansion passes the word through untouched — see SHELL_EXPANDS_BRACES.
|
|
1768
|
+
const parts = expandsBraces ? expandBraces(word.text, word.literalMask) : null;
|
|
1769
|
+
if (parts === null) {
|
|
1770
|
+
// Either this shell does not expand, or the expansion overflowed. In the OVERFLOW case only
|
|
1771
|
+
// THIS word is unreadable: MEASURED, ten adjacent `{a,b}` groups make exactly 1024 words and
|
|
1772
|
+
// `<verb> -rf {a,b}×10 .dzprobe` still deletes the store, so dropping the whole segment to
|
|
1773
|
+
// `undecidable` let the hooks pass it through (cross-family review, gpt-5.6-sol, round 12).
|
|
1774
|
+
// The word itself is marked dynamic so it can never produce a refusal of its own.
|
|
1775
|
+
if (expandsBraces) {
|
|
1776
|
+
overflowed = true;
|
|
1777
|
+
// `exactOptionalPropertyTypes` means an OPTIONAL property may be absent or a string, never
|
|
1778
|
+
// an explicit `undefined` — assigning `word.literalMask` straight through is TS2379 and it
|
|
1779
|
+
// fails `npm run typecheck`, i.e. it blocks the build and the publish (cross-family review,
|
|
1780
|
+
// gpt-5.6-sol, round 13). Round 13 shipped without running typecheck; vitest does not run it.
|
|
1781
|
+
out.push(
|
|
1782
|
+
word.literalMask === undefined
|
|
1783
|
+
? { kind: 'word', text: word.text, dynamic: true }
|
|
1784
|
+
: { kind: 'word', text: word.text, dynamic: true, literalMask: word.literalMask },
|
|
1785
|
+
);
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
out.push(word);
|
|
1789
|
+
continue;
|
|
1790
|
+
}
|
|
1791
|
+
for (const part of parts) {
|
|
1792
|
+
out.push({
|
|
1793
|
+
kind: 'word',
|
|
1794
|
+
text: part.text,
|
|
1795
|
+
// Decided PER EXPANSION: only an expansion that still holds something the shell resolves
|
|
1796
|
+
// is undecidable by form. That is the whole point of the third mask state.
|
|
1797
|
+
dynamic: part.mask.includes('d'),
|
|
1798
|
+
literalMask: part.mask,
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
return { words: out, overflowed };
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
interface CommandSegment {
|
|
1806
|
+
words: Word[];
|
|
1807
|
+
/** The shell separator immediately before this segment, or null for the first one. */
|
|
1808
|
+
connector: string | null;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function splitSegmentsWithConnectors(lexemes: readonly Lexeme[]): CommandSegment[] {
|
|
1812
|
+
const segments: CommandSegment[] = [];
|
|
1813
|
+
let current: Word[] = [];
|
|
1814
|
+
let connector: string | null = null;
|
|
1815
|
+
for (let k = 0; k < lexemes.length; k++) {
|
|
1816
|
+
const lx = lexemes[k] as Lexeme;
|
|
1817
|
+
if (lx.kind === 'sep') {
|
|
1818
|
+
const hadWords = current.length > 0;
|
|
1819
|
+
if (hadWords) segments.push({ words: current, connector });
|
|
1820
|
+
current = [];
|
|
1821
|
+
// An opening grouping delimiter starts the right-hand command; it does not replace the
|
|
1822
|
+
// `&&`/`||` that decides whether that command is reachable. Consecutive separators used to
|
|
1823
|
+
// erase the short-circuit here (`false && (rm …)`, `true || { rm …; }`).
|
|
1824
|
+
if (!hadWords && (lx.ch === '(' || lx.ch === '{') && (connector === '&&' || connector === '||')) {
|
|
1825
|
+
continue;
|
|
1826
|
+
}
|
|
1827
|
+
connector = lx.ch;
|
|
1828
|
+
continue;
|
|
1829
|
+
}
|
|
1830
|
+
if (lx.kind === 'redirect') {
|
|
1831
|
+
// The word after an operator is the redirection TARGET. It is not an operand of the verb:
|
|
1832
|
+
// `> .dz/log` creates or truncates a file, it does not delete one, and a refusal that names
|
|
1833
|
+
// a rule the command does not break is the first step to being switched off (see R-flags).
|
|
1834
|
+
if (lexemes[k + 1]?.kind === 'word') k++;
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
current.push(lx);
|
|
1838
|
+
}
|
|
1839
|
+
if (current.length > 0) segments.push({ words: current, connector });
|
|
1840
|
+
return segments;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
type StaticExitStatus = 'success' | 'failure' | 'unknown';
|
|
1844
|
+
|
|
1845
|
+
/** Only the literal shell builtins requested by F68 are treated as statically decidable. */
|
|
1846
|
+
function literalExitStatus(words: readonly Word[]): StaticExitStatus {
|
|
1847
|
+
if (words.length !== 1 || words[0]?.dynamic) return 'unknown';
|
|
1848
|
+
if (words[0]?.text === 'true') return 'success';
|
|
1849
|
+
if (words[0]?.text === 'false') return 'failure';
|
|
1850
|
+
return 'unknown';
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* Drop only branches whose non-execution follows from literal `true`/`false` status. Unknown
|
|
1855
|
+
* commands remain reachable in both directions, preserving the guard's prior fail-closed choice.
|
|
1856
|
+
*/
|
|
1857
|
+
function reachableSegments(lexemes: readonly Lexeme[]): Word[][] {
|
|
1858
|
+
const reachable: Word[][] = [];
|
|
1859
|
+
let status: StaticExitStatus = 'unknown';
|
|
1860
|
+
|
|
1861
|
+
for (const segment of splitSegmentsWithConnectors(lexemes)) {
|
|
1862
|
+
const literal = literalExitStatus(segment.words);
|
|
1863
|
+
if (segment.connector === '&&') {
|
|
1864
|
+
if (status !== 'failure') reachable.push(segment.words);
|
|
1865
|
+
if (status === 'success') status = literal;
|
|
1866
|
+
else if (status === 'unknown') status = literal === 'failure' ? 'failure' : 'unknown';
|
|
1867
|
+
continue;
|
|
1868
|
+
}
|
|
1869
|
+
if (segment.connector === '||') {
|
|
1870
|
+
if (status !== 'success') reachable.push(segment.words);
|
|
1871
|
+
if (status === 'failure') status = literal;
|
|
1872
|
+
else if (status === 'unknown') status = literal === 'success' ? 'success' : 'unknown';
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
reachable.push(segment.words);
|
|
1876
|
+
status = literal;
|
|
1877
|
+
}
|
|
1878
|
+
return reachable;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
/** A function body lifted out of the main stream, together with the name it was bound to. */
|
|
1882
|
+
interface FunctionBody {
|
|
1883
|
+
name: string;
|
|
1884
|
+
lexemes: Lexeme[];
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/** A name the shell may bind a function to. Deliberately narrower than bash allows. */
|
|
1888
|
+
const FUNCTION_NAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* Split the stream into what the shell EXECUTES and the function bodies it merely BINDS.
|
|
1892
|
+
*
|
|
1893
|
+
* MEASURED with bash 5.1.16, each case in its own temp directory: `f() { <verb> -rf .dzprobe; }`,
|
|
1894
|
+
* `f() ( <verb> -rf .dzprobe )` and `function f { <verb> -rf .dzprobe; }` all exit 0 and leave the
|
|
1895
|
+
* target ALIVE, while the same line followed by `; f` leaves it GONE. A definition binds a name and
|
|
1896
|
+
* runs nothing. The classifier read the body as an ordinary segment and refused a deletion the
|
|
1897
|
+
* shell never performs — the false-refusal class this feature exists to avoid (cross-family review,
|
|
1898
|
+
* gpt-5.6-sol, round 4; F22).
|
|
1899
|
+
*
|
|
1900
|
+
* FAIL-CLOSED IN EVERY DOUBTFUL DIRECTION, because the opposite error is a silent miss on a live
|
|
1901
|
+
* deletion:
|
|
1902
|
+
* - a body whose opening brace or parenthesis is never matched is NOT a definition, so it stays in
|
|
1903
|
+
* the executed stream and is classified in full;
|
|
1904
|
+
* - a name that does not look like a function name is not a definition either;
|
|
1905
|
+
* - a lifted body is put BACK the moment its name appears as a word anywhere else in the command —
|
|
1906
|
+
* that criterion is deliberately blunter than "a segment head", so that a body reached through
|
|
1907
|
+
* another body (`f() { g; }; g() { <verb> …; }; f`) cannot slip out. It over-refuses on
|
|
1908
|
+
* `f() { … }; echo f`, which is the cheap direction of the error;
|
|
1909
|
+
* - a body that IS reinstated is classified as written, so a definition nested inside it counts as
|
|
1910
|
+
* executed. One level of leniency, never two.
|
|
1911
|
+
*/
|
|
1912
|
+
function liftFunctionBodies(lexemes: readonly Lexeme[]): { main: Lexeme[]; bodies: FunctionBody[] } {
|
|
1913
|
+
const main: Lexeme[] = [];
|
|
1914
|
+
const bodies: FunctionBody[] = [];
|
|
1915
|
+
const sepAt = (k: number): string | null => {
|
|
1916
|
+
const lx = lexemes[k];
|
|
1917
|
+
return lx !== undefined && lx.kind === 'sep' ? lx.ch : null;
|
|
1918
|
+
};
|
|
1919
|
+
/**
|
|
1920
|
+
* Step over NEWLINE separators only, which is what may stand between a function header and its
|
|
1921
|
+
* body. MEASURED with bash 5.1.16: `f()` on one line and `{ <verb> -rf .dzprobe; }` on the next
|
|
1922
|
+
* exited 0 and left the target ALIVE, as did a blank line between them, the `function f`
|
|
1923
|
+
* spelling, and a `( … )` body. A COMMAND separator is NOT part of a header — bash answers
|
|
1924
|
+
* `f() ; { … }` with `syntax error near unexpected token`, exit 2 — so nothing else is skipped:
|
|
1925
|
+
* lifting a body over a separator the shell itself rejects would widen the non-executed region
|
|
1926
|
+
* on input we cannot reason about.
|
|
1927
|
+
*/
|
|
1928
|
+
const skipNewlines = (from: number): number => {
|
|
1929
|
+
let j = from;
|
|
1930
|
+
while (sepAt(j) === '\n') j++;
|
|
1931
|
+
return j;
|
|
1932
|
+
};
|
|
1933
|
+
/** The index of the separator closing a body opened at `open`, or -1 when it never closes. */
|
|
1934
|
+
const bodyEnd = (open: number): number => {
|
|
1935
|
+
const opener = sepAt(open);
|
|
1936
|
+
const closer = opener === '{' ? '}' : ')';
|
|
1937
|
+
if (opener === null) return -1;
|
|
1938
|
+
let depth = 0;
|
|
1939
|
+
for (let k = open; k < lexemes.length; k++) {
|
|
1940
|
+
const ch = sepAt(k);
|
|
1941
|
+
if (ch === opener) depth++;
|
|
1942
|
+
else if (ch === closer) { depth--; if (depth === 0) return k; }
|
|
1943
|
+
}
|
|
1944
|
+
return -1;
|
|
1945
|
+
};
|
|
1946
|
+
|
|
1947
|
+
for (let k = 0; k < lexemes.length; k++) {
|
|
1948
|
+
const lx = lexemes[k] as Lexeme;
|
|
1949
|
+
if (lx.kind === 'word') {
|
|
1950
|
+
// `name () {` … `}` — and `name () (` … `)`, which binds a subshell body.
|
|
1951
|
+
const isKeyword = lx.text === 'function';
|
|
1952
|
+
const nameWord = isKeyword ? lexemes[k + 1] : lx;
|
|
1953
|
+
if (nameWord !== undefined && nameWord.kind === 'word' && FUNCTION_NAME.test(nameWord.text)) {
|
|
1954
|
+
let cursor = k + (isKeyword ? 2 : 1);
|
|
1955
|
+
// `function name` may write the parentheses or omit them; `name` alone may not.
|
|
1956
|
+
if (sepAt(cursor) === '(' && sepAt(cursor + 1) === ')') cursor += 2;
|
|
1957
|
+
else if (!isKeyword) { main.push(lx); continue; }
|
|
1958
|
+
cursor = skipNewlines(cursor);
|
|
1959
|
+
const opener = sepAt(cursor);
|
|
1960
|
+
if (opener === '{' || opener === '(') {
|
|
1961
|
+
const end = bodyEnd(cursor);
|
|
1962
|
+
if (end !== -1) {
|
|
1963
|
+
bodies.push({ name: nameWord.text, lexemes: lexemes.slice(cursor + 1, end) });
|
|
1964
|
+
k = end;
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
main.push(lx);
|
|
1971
|
+
}
|
|
1972
|
+
return { main, bodies };
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// -------------------------------------------------------------------------------------------
|
|
1976
|
+
// Stage 2 — resolve WHAT EACH SEGMENT IS, before any deletion rule runs.
|
|
1977
|
+
// -------------------------------------------------------------------------------------------
|
|
1978
|
+
|
|
1979
|
+
type Invocation =
|
|
1980
|
+
| { kind: 'tool'; name: string; args: readonly Word[] }
|
|
1981
|
+
| { kind: 'delete'; name: string; args: readonly Word[]; optionMode: PosixOptionMode }
|
|
1982
|
+
| { kind: 'shell'; name: string; args: readonly Word[]; optionMode: PosixOptionMode }
|
|
1983
|
+
| { kind: 'shell-command'; name: string; command: Word; optionMode: PosixOptionMode }
|
|
1984
|
+
/** A named script whose body lives outside the command bytes supplied to this pure classifier. */
|
|
1985
|
+
| { kind: 'external-script'; name: string; subcommand: string }
|
|
1986
|
+
/** The segment carries a command the SHELL builds — it cannot be read at all (F18-guard). */
|
|
1987
|
+
| { kind: 'opaque'; why: string }
|
|
1988
|
+
| { kind: 'other'; name: string | null; args: readonly Word[] };
|
|
1989
|
+
|
|
1990
|
+
/** Whether POSIXLY_CORRECT's option grammar is known from the command bytes themselves. */
|
|
1991
|
+
type PosixOptionMode = 'enabled' | 'disabled' | 'unknown';
|
|
1992
|
+
|
|
1993
|
+
/**
|
|
1994
|
+
* A word bash reads as an ENVIRONMENT ASSIGNMENT in front of a command, not as the command.
|
|
1995
|
+
*
|
|
1996
|
+
* MEASURED with bash 5.1.16, each case in its own temp directory: `FOO+=x <verb> -rf .dzprobe`
|
|
1997
|
+
* exited 0 and left the target GONE, and so did `FOO+=`, `A=1 B=2`, `A=`, `_A=1` and `A1=1`. The
|
|
1998
|
+
* APPEND form was not accepted here, so `FOO+=x` read as an unknown executable, an unknown head
|
|
1999
|
+
* STOPS the search (see WRAPPERS), and the deletion behind it was allowed (cross-family review,
|
|
2000
|
+
* gpt-5.6-sol, round 6).
|
|
2001
|
+
*
|
|
2002
|
+
* The subscripted form is included because bash RUNS THE COMMAND ANYWAY: `FOO[0]=x <verb> -rf
|
|
2003
|
+
* .dzprobe` prints `bash: FOO[0]: not a valid identifier`, exits 0 and the target is GONE — the
|
|
2004
|
+
* assignment fails, the deletion does not.
|
|
2005
|
+
*
|
|
2006
|
+
* The pattern is no looser than that, because a pattern that swallowed a real command word would
|
|
2007
|
+
* move the head past it. MEASURED in the other direction: `1A=1 <verb> -rf .dzprobe` and
|
|
2008
|
+
* `A-B=1 <verb> -rf .dzprobe` both exit 127 with `command not found` and leave the target ALIVE —
|
|
2009
|
+
* bash reads those as the COMMAND — so they must stop the search, exactly as they already did.
|
|
2010
|
+
*/
|
|
2011
|
+
const ASSIGNMENT_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=/;
|
|
2012
|
+
|
|
2013
|
+
/**
|
|
2014
|
+
* Wrappers that accept `VAR=value` in front of the command they run.
|
|
2015
|
+
*
|
|
2016
|
+
* MEASURED, one throwaway directory per wrapper, `<wrapper> FOO=bar <verb> -rf .dzprobe`:
|
|
2017
|
+
* - ACCEPTS (target GONE): `env`, `sudo`.
|
|
2018
|
+
* - REJECTS (target ALIVE, exit 125/127): `command` (`bash: FOO=bar: command not found`),
|
|
2019
|
+
* `builtin`, `nohup`, `setsid`, `nice`, `stdbuf`, `ionice`, `exec`, `xargs`, and the BINARY
|
|
2020
|
+
* `/usr/bin/time` (`cannot run FOO=bar`).
|
|
2021
|
+
* Skipping the word for every wrapper refused all of the second group even though the command
|
|
2022
|
+
* never reaches the verb (cross-family review, gpt-5.6-sol, round 13).
|
|
2023
|
+
*
|
|
2024
|
+
* `time` is on the ACCEPTING side anyway, and the reason is measured rather than assumed: the bare
|
|
2025
|
+
* word is a shell KEYWORD here (`type -t time` answers `keyword`), so `time FOO=bar <verb> -rf`
|
|
2026
|
+
* really does delete — the assignment is the SHELL's. A basename cannot tell the keyword from
|
|
2027
|
+
* `/usr/bin/time`, so the guard keeps the fail-closed reading of the two.
|
|
2028
|
+
*
|
|
2029
|
+
* `doas` is here for the same fail-closed reason and NOT because it was measured: it is not
|
|
2030
|
+
* installed on this machine, so its behaviour could not be run. Where the live answer is
|
|
2031
|
+
* unavailable the guard keeps the refusal rather than inventing a pass.
|
|
2032
|
+
*/
|
|
2033
|
+
const WRAPPERS_TAKING_ASSIGNMENTS: ReadonlySet<string> = new Set(['env', 'sudo', 'doas', 'time']);
|
|
2034
|
+
|
|
2035
|
+
/**
|
|
2036
|
+
* Wrappers that are RESERVED WORDS of the shell rather than programs, so what follows them is still
|
|
2037
|
+
* shell syntax.
|
|
2038
|
+
*
|
|
2039
|
+
* MEASURED: `time ! <verb> -rf .dzprobe` exits 1 and the target is GONE — `time` is a keyword, the
|
|
2040
|
+
* pipeline behind it still reads `!` as negation, and the deletion runs; `time -p !` and
|
|
2041
|
+
* `time time !` behave the same. Treating `time` as an ordinary wrapper cleared command position,
|
|
2042
|
+
* `!` became an unknown executable and the search stopped (cross-family review, gpt-5.6-sol,
|
|
2043
|
+
* round 14).
|
|
2044
|
+
*
|
|
2045
|
+
* Only the syntactically BARE word is the keyword. A path, quote or escape is a discriminator —
|
|
2046
|
+
* MEASURED, `/usr/bin/time`, `\time`, `'time'`, and `t"ime"` followed by `! <verb> -rf .dzprobe`
|
|
2047
|
+
* all exit 127 and leave the target ALIVE, because the binary tries to execute `!`. The lexer has
|
|
2048
|
+
* already decoded quotes/escapes, so `Word.literalMask` is what preserves that distinction.
|
|
2049
|
+
*/
|
|
2050
|
+
const SHELL_KEYWORD_WRAPPERS: ReadonlySet<string> = new Set(['time']);
|
|
2051
|
+
|
|
2052
|
+
const basename = (token: string): string => {
|
|
2053
|
+
const parts = token.split('/');
|
|
2054
|
+
return parts[parts.length - 1] || token;
|
|
2055
|
+
};
|
|
2056
|
+
|
|
2057
|
+
/**
|
|
2058
|
+
* The value of a COMMAND-STRING option, when `flag` is one — `null` when it is an ordinary flag.
|
|
2059
|
+
*
|
|
2060
|
+
* The short spelling is read through the SAME cluster walk as every other option, so `-iS 'str'`
|
|
2061
|
+
* and `-Sstr` are answered consistently with `-iu FOO` and `-uFOO`, and a cluster whose first
|
|
2062
|
+
* value-taking letter is an ORDINARY one (`-uS`, where `S` is `-u`'s value) is not mistaken for a
|
|
2063
|
+
* command string.
|
|
2064
|
+
*/
|
|
2065
|
+
function commandStringValue(
|
|
2066
|
+
arity: OptionArity,
|
|
2067
|
+
cmd: OptionArity,
|
|
2068
|
+
flag: string,
|
|
2069
|
+
): { attached: string | null } | null {
|
|
2070
|
+
if (flag.startsWith('--')) {
|
|
2071
|
+
const eq = flag.indexOf('=');
|
|
2072
|
+
const name = eq === -1 ? flag.slice(2) : flag.slice(2, eq);
|
|
2073
|
+
if (!cmd.long.has(name)) return null;
|
|
2074
|
+
return { attached: eq === -1 ? null : flag.slice(eq + 1) };
|
|
2075
|
+
}
|
|
2076
|
+
const hit = shortClusterValue(arity.short, flag);
|
|
2077
|
+
if (hit === null || !cmd.short.includes(hit.letter)) return null;
|
|
2078
|
+
return { attached: hit.attached };
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
function optionValueCommand(
|
|
2082
|
+
rule: Extract<NestedCommandRule, { kind: 'argv' }>,
|
|
2083
|
+
flag: string,
|
|
2084
|
+
): { carrier: OptionValueCommandCarrier; attached: string | null } | null {
|
|
2085
|
+
for (const carrier of rule.optionValueCommands ?? []) {
|
|
2086
|
+
const value = commandStringValue(rule.options, carrier.options, flag);
|
|
2087
|
+
if (value !== null) return { carrier, attached: value.attached };
|
|
2088
|
+
}
|
|
2089
|
+
return null;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
type OptionValueCommandResolution =
|
|
2093
|
+
| { kind: 'missing-value' }
|
|
2094
|
+
| { kind: 'opaque' }
|
|
2095
|
+
| { kind: 'shell'; command: Word }
|
|
2096
|
+
| { kind: 'argv'; words: readonly Word[] }
|
|
2097
|
+
| { kind: 'invalid' };
|
|
2098
|
+
|
|
2099
|
+
function resolveOptionValueCommand(
|
|
2100
|
+
rule: Extract<NestedCommandRule, { kind: 'argv' }>,
|
|
2101
|
+
words: readonly Word[],
|
|
2102
|
+
flagIndex: number,
|
|
2103
|
+
): OptionValueCommandResolution | null {
|
|
2104
|
+
const flagWord = words[flagIndex] as Word;
|
|
2105
|
+
const carried = optionValueCommand(rule, flagWord.text);
|
|
2106
|
+
if (carried === null) return null;
|
|
2107
|
+
const value: Word | undefined = carried.attached !== null
|
|
2108
|
+
? { kind: 'word', text: carried.attached, dynamic: flagWord.dynamic }
|
|
2109
|
+
: words[flagIndex + 1];
|
|
2110
|
+
if (value === undefined) return { kind: 'missing-value' };
|
|
2111
|
+
if (value.dynamic) return { kind: 'opaque' };
|
|
2112
|
+
if (carried.carrier.execution === 'shell') return { kind: 'shell', command: value };
|
|
2113
|
+
if (carried.carrier.split !== 'env') return { kind: 'invalid' };
|
|
2114
|
+
const split = splitStringWords(value.text);
|
|
2115
|
+
if (split === null) return { kind: 'invalid' };
|
|
2116
|
+
const rest = carried.carrier.appendRemaining === true
|
|
2117
|
+
? words.slice(carried.attached !== null ? flagIndex + 1 : flagIndex + 2)
|
|
2118
|
+
: [];
|
|
2119
|
+
return { kind: 'argv', words: [...split, ...rest] };
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
/**
|
|
2123
|
+
* Locate the command word in an argv-style command carrier.
|
|
2124
|
+
*
|
|
2125
|
+
* Options are consumed only before the command. `--` ends the carrier's option grammar; any
|
|
2126
|
+
* required positional subjects (a container or pod for `docker exec` / `kubectl exec`) are then
|
|
2127
|
+
* skipped before the nested command is returned. The returned index points into `words`.
|
|
2128
|
+
*/
|
|
2129
|
+
interface ArgvCommandLocation {
|
|
2130
|
+
index: number;
|
|
2131
|
+
execution: CommandExecution;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/** True when a boolean option occurs before any value-taking short option swallows the suffix. */
|
|
2135
|
+
function hasOptionFlag(arity: OptionArity, sought: OptionArity, flag: string): boolean {
|
|
2136
|
+
if (flag.startsWith('--')) {
|
|
2137
|
+
const eq = flag.indexOf('=');
|
|
2138
|
+
return sought.long.has(eq === -1 ? flag.slice(2) : flag.slice(2, eq));
|
|
2139
|
+
}
|
|
2140
|
+
for (let i = 1; i < flag.length; i++) {
|
|
2141
|
+
const letter = flag.charAt(i);
|
|
2142
|
+
if (sought.short.includes(letter)) return true;
|
|
2143
|
+
if (arity.short.includes(letter)) return false;
|
|
2144
|
+
}
|
|
2145
|
+
return false;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function argvCommandIndex(
|
|
2149
|
+
words: readonly Word[],
|
|
2150
|
+
start: number,
|
|
2151
|
+
rule: Extract<NestedCommandRule, { kind: 'argv' }>,
|
|
2152
|
+
executionOverride?: CommandExecution,
|
|
2153
|
+
): ArgvCommandLocation | null {
|
|
2154
|
+
let optionsEnded = false;
|
|
2155
|
+
let remainingPositionals = rule.positionalsBeforeCommand ?? 0;
|
|
2156
|
+
let execution = executionOverride ?? rule.execution;
|
|
2157
|
+
for (let i = start; i < words.length; i++) {
|
|
2158
|
+
const t = words[i]?.text ?? '';
|
|
2159
|
+
if (!optionsEnded && t === '--') {
|
|
2160
|
+
optionsEnded = true;
|
|
2161
|
+
continue;
|
|
2162
|
+
}
|
|
2163
|
+
if (!optionsEnded && (t.startsWith('-') || t.startsWith('+')) && t.length > 1) {
|
|
2164
|
+
if (rule.shellModeOptions !== undefined && hasOptionFlag(rule.options, rule.shellModeOptions, t)) {
|
|
2165
|
+
execution = 'shell';
|
|
2166
|
+
}
|
|
2167
|
+
if (consumesNextWord(rule.options, t)) i++;
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2170
|
+
if (remainingPositionals > 0) {
|
|
2171
|
+
remainingPositionals--;
|
|
2172
|
+
continue;
|
|
2173
|
+
}
|
|
2174
|
+
return { index: i, execution };
|
|
2175
|
+
}
|
|
2176
|
+
return null;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
/** A terminal option in the option prefix makes the carrier exit before any nested command. */
|
|
2180
|
+
function hasTerminalOptionBeforeFirstPositional(
|
|
2181
|
+
words: readonly Word[],
|
|
2182
|
+
start: number,
|
|
2183
|
+
arity: OptionArity,
|
|
2184
|
+
): boolean {
|
|
2185
|
+
for (let i = start; i < words.length; i++) {
|
|
2186
|
+
const flag = words[i]?.text ?? '';
|
|
2187
|
+
if (flag === '--') return false;
|
|
2188
|
+
if (!(flag.startsWith('-') && flag.length > 1)) return false;
|
|
2189
|
+
if (WRAPPER_TERMINAL_OPTIONS.has(flag)) return true;
|
|
2190
|
+
if (consumesNextWord(arity, flag)) i++;
|
|
2191
|
+
}
|
|
2192
|
+
return false;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/** The shell source pnpm constructs from the command tail in `--shell-mode`. */
|
|
2196
|
+
function shellCommandTail(words: readonly Word[]): Word | null {
|
|
2197
|
+
if (words.length === 0) return null;
|
|
2198
|
+
return {
|
|
2199
|
+
kind: 'word',
|
|
2200
|
+
text: words.map((word) => word.text).join(' '),
|
|
2201
|
+
dynamic: words.some((word) => word.dynamic),
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
const ENV_CLEAR_OPTIONS: OptionArity = { short: 'i', long: new Set(['ignore-environment']) };
|
|
2206
|
+
const ENV_UNSET_OPTIONS: OptionArity = { short: 'u', long: new Set(['unset']) };
|
|
2207
|
+
|
|
2208
|
+
function resolveInvocation(words: readonly Word[], inheritedOptionMode: PosixOptionMode = 'unknown'): Invocation {
|
|
2209
|
+
let list: readonly Word[] = words;
|
|
2210
|
+
let i = 0;
|
|
2211
|
+
let rewrites = 0;
|
|
2212
|
+
let optionMode = inheritedOptionMode;
|
|
2213
|
+
/**
|
|
2214
|
+
* True while the resolver is still where the SHELL would accept a reserved word.
|
|
2215
|
+
*
|
|
2216
|
+
* MEASURED: once a wrapper or an assignment has taken command position, `!`, `if` and `then` are
|
|
2217
|
+
* ordinary program NAMES — `env '!' <verb> -rf .dzprobe` exits 127 with
|
|
2218
|
+
* `env: '!': No such file or directory` and the target is ALIVE, `sudo if …` answers
|
|
2219
|
+
* `sudo: if: command not found`, and `A=1 ! <verb> …` exits 127 — while at the head of a segment
|
|
2220
|
+
* they really are syntax and the deletion behind them HAPPENS (`! <verb> -rf .dzprobe` and
|
|
2221
|
+
* `! A=1 <verb> …` both left the target GONE). Skipping them unconditionally refused every one of
|
|
2222
|
+
* the first group (cross-family review, gpt-5.6-sol, round 11).
|
|
2223
|
+
*/
|
|
2224
|
+
let atCommandPosition = true;
|
|
2225
|
+
/** The most recent wrapper that took command position — see WRAPPERS_TAKING_ASSIGNMENTS. */
|
|
2226
|
+
let lastWrapper: string | null = null;
|
|
2227
|
+
outer: while (i < list.length) {
|
|
2228
|
+
const t = list[i]?.text ?? '';
|
|
2229
|
+
// A leading shell assignment is always a prefix; after a WRAPPER has taken command position the
|
|
2230
|
+
// same shape is a program NAME unless that wrapper accepts assignments itself.
|
|
2231
|
+
// Before any wrapper, ANY number of leading assignments are a prefix (`A=1 B=2 <verb> …`
|
|
2232
|
+
// deletes — MEASURED round 7), so the test is the WRAPPER, not the command position: an
|
|
2233
|
+
// assignment ends control-word position but not assignment position.
|
|
2234
|
+
const assignmentAllowed = lastWrapper === null || WRAPPERS_TAKING_ASSIGNMENTS.has(lastWrapper);
|
|
2235
|
+
if (ASSIGNMENT_PREFIX.test(t) && assignmentAllowed) {
|
|
2236
|
+
if (/^POSIXLY_CORRECT(?:\+)?=/.test(t)) optionMode = 'enabled';
|
|
2237
|
+
i++;
|
|
2238
|
+
atCommandPosition = false;
|
|
2239
|
+
continue;
|
|
2240
|
+
}
|
|
2241
|
+
if (atCommandPosition && CONTROL_WORDS.has(t)) { i++; continue; } // ! rm / then rm
|
|
2242
|
+
const name = basename(t);
|
|
2243
|
+
const strategy = COMMAND_WRAPPER_STRATEGIES.get(name);
|
|
2244
|
+
if (strategy?.kind === 'argv') {
|
|
2245
|
+
const arity = strategy.options;
|
|
2246
|
+
// A shell KEYWORD does not take command position away from what follows it — but only when
|
|
2247
|
+
// every character was syntactically bare. `\time`, `'time'`, `t"ime"`, a path spelling, and
|
|
2248
|
+
// a word produced by env -S all invoke the binary; text alone cannot distinguish them after
|
|
2249
|
+
// lexing, so the literal mask is load-bearing here.
|
|
2250
|
+
const isShellKeyword = SHELL_KEYWORD_WRAPPERS.has(name)
|
|
2251
|
+
&& !t.includes('/')
|
|
2252
|
+
&& (list[i]?.literalMask ?? '') === '0'.repeat(t.length);
|
|
2253
|
+
if (!isShellKeyword) atCommandPosition = false;
|
|
2254
|
+
lastWrapper = name;
|
|
2255
|
+
i++;
|
|
2256
|
+
while (i < list.length) {
|
|
2257
|
+
const flagWord = list[i] as Word;
|
|
2258
|
+
const flag = flagWord.text;
|
|
2259
|
+
if (flag === '--') { i++; break; }
|
|
2260
|
+
// A lone `-` is env's `-i`, and it ends the options — see LONE_DASH_WRAPPERS.
|
|
2261
|
+
if (flag === '-' && LONE_DASH_WRAPPERS.has(name)) {
|
|
2262
|
+
if (name === 'env') optionMode = 'disabled';
|
|
2263
|
+
i++;
|
|
2264
|
+
break;
|
|
2265
|
+
}
|
|
2266
|
+
if (!flag.startsWith('-') || flag.length < 2) break;
|
|
2267
|
+
|
|
2268
|
+
if (name === 'env') {
|
|
2269
|
+
if (hasOptionFlag(ENV_WRAPPER_OPTIONS, ENV_CLEAR_OPTIONS, flag)) optionMode = 'disabled';
|
|
2270
|
+
const unset = commandStringValue(ENV_WRAPPER_OPTIONS, ENV_UNSET_OPTIONS, flag);
|
|
2271
|
+
if (unset !== null) {
|
|
2272
|
+
const value = unset.attached ?? list[i + 1]?.text ?? '';
|
|
2273
|
+
if (value === 'POSIXLY_CORRECT') optionMode = 'disabled';
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
// `--help` / `--version` make the WRAPPER print and exit, so the segment execs nothing.
|
|
2278
|
+
// Reached only when the spelling is in an OPTION slot: a previous iteration has already
|
|
2279
|
+
// stepped over any word that was some option's VALUE (`sudo -h --help <verb> …`).
|
|
2280
|
+
if (WRAPPER_TERMINAL_OPTIONS.has(flag)) return { kind: 'other', name, args: [] };
|
|
2281
|
+
|
|
2282
|
+
// A lookup option means the segment RUNS nothing — see INSPECTION_OPTIONS.
|
|
2283
|
+
const inspect = INSPECTION_OPTIONS.get(name);
|
|
2284
|
+
if (inspect !== undefined && isInspectionFlag(inspect, flag)) {
|
|
2285
|
+
return { kind: 'other', name, args: [] };
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// An option whose VALUE IS THE COMMAND (`env -S '<verb> -rf .dz'`): the string is split into
|
|
2289
|
+
// words and the REST of the segment is appended to it, which is what makes
|
|
2290
|
+
// `env --split-string=<verb> -rf .dz` delete. Then the whole resolution restarts on the
|
|
2291
|
+
// rewritten word list, so the head is found by the ordinary rules.
|
|
2292
|
+
const carried = resolveOptionValueCommand(strategy, list, i);
|
|
2293
|
+
if (carried !== null) {
|
|
2294
|
+
if (carried.kind === 'missing-value') break;
|
|
2295
|
+
if (carried.kind === 'opaque') {
|
|
2296
|
+
return { kind: 'opaque', why: `«${name} ${flag}» получает команду, собранную оболочкой` };
|
|
2297
|
+
}
|
|
2298
|
+
if (rewrites >= MAX_COMMAND_STRING_REWRITES) {
|
|
2299
|
+
return { kind: 'opaque', why: `«${name} ${flag}» вложен глубже ${MAX_COMMAND_STRING_REWRITES} раз` };
|
|
2300
|
+
}
|
|
2301
|
+
if (carried.kind === 'shell') {
|
|
2302
|
+
return { kind: 'shell-command', name: `${name} ${flag}`, command: carried.command, optionMode };
|
|
2303
|
+
}
|
|
2304
|
+
if (carried.kind === 'invalid') {
|
|
2305
|
+
return { kind: 'opaque', why: `«${name} ${flag}» получает строку, которую env отвергает целиком — не исполняется ничего` };
|
|
2306
|
+
}
|
|
2307
|
+
rewrites++;
|
|
2308
|
+
list = carried.words;
|
|
2309
|
+
i = 0;
|
|
2310
|
+
continue outer;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
i++;
|
|
2314
|
+
if (consumesNextWord(arity, flag)) i++; // the value is NOT the command
|
|
2315
|
+
}
|
|
2316
|
+
continue;
|
|
2317
|
+
}
|
|
2318
|
+
if (strategy?.kind === 'subcommand') {
|
|
2319
|
+
if (strategy.options !== null
|
|
2320
|
+
&& hasTerminalOptionBeforeFirstPositional(list, i + 1, strategy.options)) {
|
|
2321
|
+
return { kind: 'other', name, args: [] };
|
|
2322
|
+
}
|
|
2323
|
+
const subcommandLocation: ArgvCommandLocation | null = strategy.options === null
|
|
2324
|
+
? { index: i + 1, execution: 'argv' }
|
|
2325
|
+
: argvCommandIndex(list, i + 1, {
|
|
2326
|
+
kind: 'argv',
|
|
2327
|
+
location: 'first-positional',
|
|
2328
|
+
execution: 'argv',
|
|
2329
|
+
options: strategy.options,
|
|
2330
|
+
...(strategy.shellModeOptions === undefined ? {} : { shellModeOptions: strategy.shellModeOptions }),
|
|
2331
|
+
});
|
|
2332
|
+
if (subcommandLocation === null) return { kind: 'other', name, args: [] };
|
|
2333
|
+
const subcommandAt = subcommandLocation.index;
|
|
2334
|
+
const subcommandWord = list[subcommandAt];
|
|
2335
|
+
if (subcommandWord === undefined) return { kind: 'other', name, args: [] };
|
|
2336
|
+
if (subcommandWord.dynamic) {
|
|
2337
|
+
return { kind: 'opaque', why: `«${name}» получает подкоманду, собранную оболочкой` };
|
|
2338
|
+
}
|
|
2339
|
+
const subcommand = subcommandWord.text;
|
|
2340
|
+
if (strategy.nonFilesystem.has(subcommand)) {
|
|
2341
|
+
return { kind: 'tool', name, args: list.slice(i + 1) };
|
|
2342
|
+
}
|
|
2343
|
+
const nested = strategy.commands.get(subcommand);
|
|
2344
|
+
if (nested?.kind === 'external-script') {
|
|
2345
|
+
return {
|
|
2346
|
+
kind: 'external-script',
|
|
2347
|
+
name,
|
|
2348
|
+
subcommand,
|
|
2349
|
+
};
|
|
2350
|
+
}
|
|
2351
|
+
if (nested?.kind === 'argv') {
|
|
2352
|
+
if (hasTerminalOptionBeforeFirstPositional(list, subcommandAt + 1, nested.options)) {
|
|
2353
|
+
return { kind: 'other', name, args: [] };
|
|
2354
|
+
}
|
|
2355
|
+
for (let k = subcommandAt + 1; k < list.length; k++) {
|
|
2356
|
+
const flagWord = list[k] as Word;
|
|
2357
|
+
const flag = flagWord.text;
|
|
2358
|
+
if (flag === '--') break;
|
|
2359
|
+
if (!flag.startsWith('-') || flag.length < 2) break;
|
|
2360
|
+
const carried = resolveOptionValueCommand(nested, list, k);
|
|
2361
|
+
if (carried === null) {
|
|
2362
|
+
if (consumesNextWord(nested.options, flag)) k++;
|
|
2363
|
+
continue;
|
|
2364
|
+
}
|
|
2365
|
+
if (carried.kind === 'missing-value') return { kind: 'other', name, args: [] };
|
|
2366
|
+
if (carried.kind === 'opaque') {
|
|
2367
|
+
return { kind: 'opaque', why: `«${name} ${subcommand} ${flag}» получает команду, собранную оболочкой` };
|
|
2368
|
+
}
|
|
2369
|
+
if (carried.kind === 'shell') {
|
|
2370
|
+
return { kind: 'shell-command', name: `${name} ${subcommand} ${flag}`, command: carried.command, optionMode };
|
|
2371
|
+
}
|
|
2372
|
+
if (rewrites >= MAX_COMMAND_STRING_REWRITES) {
|
|
2373
|
+
return { kind: 'opaque', why: `«${name} ${subcommand} ${flag}» вложен глубже ${MAX_COMMAND_STRING_REWRITES} раз` };
|
|
2374
|
+
}
|
|
2375
|
+
if (carried.kind === 'invalid') {
|
|
2376
|
+
return { kind: 'opaque', why: `«${name} ${subcommand} ${flag}» получает строку, которую носитель отвергает целиком — не исполняется ничего` };
|
|
2377
|
+
}
|
|
2378
|
+
rewrites++;
|
|
2379
|
+
list = carried.words;
|
|
2380
|
+
i = 0;
|
|
2381
|
+
continue outer;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
const commandLocation = argvCommandIndex(
|
|
2385
|
+
list,
|
|
2386
|
+
subcommandAt + 1,
|
|
2387
|
+
nested,
|
|
2388
|
+
subcommandLocation.execution === 'shell' ? 'shell' : undefined,
|
|
2389
|
+
);
|
|
2390
|
+
if (commandLocation === null) return { kind: 'other', name, args: [] };
|
|
2391
|
+
const command = list[commandLocation.index] as Word;
|
|
2392
|
+
if (command.dynamic) {
|
|
2393
|
+
return { kind: 'opaque', why: `«${name} ${subcommand}» получает имя команды, собранное оболочкой` };
|
|
2394
|
+
}
|
|
2395
|
+
if (rewrites >= MAX_COMMAND_STRING_REWRITES) {
|
|
2396
|
+
return { kind: 'opaque', why: `«${name} ${subcommand}» вложен глубже ${MAX_COMMAND_STRING_REWRITES} раз` };
|
|
2397
|
+
}
|
|
2398
|
+
if (commandLocation.execution === 'shell') {
|
|
2399
|
+
const shellCommand = shellCommandTail(list.slice(commandLocation.index));
|
|
2400
|
+
if (shellCommand === null) return { kind: 'other', name, args: [] };
|
|
2401
|
+
if (shellCommand.dynamic) {
|
|
2402
|
+
return { kind: 'opaque', why: `«${name} ${subcommand}» получает команду, собранную оболочкой` };
|
|
2403
|
+
}
|
|
2404
|
+
return { kind: 'shell-command', name: `${name} ${subcommand}`, command: shellCommand, optionMode };
|
|
2405
|
+
}
|
|
2406
|
+
rewrites++;
|
|
2407
|
+
list = list.slice(commandLocation.index);
|
|
2408
|
+
i = 0;
|
|
2409
|
+
continue;
|
|
2410
|
+
}
|
|
2411
|
+
break;
|
|
2412
|
+
}
|
|
2413
|
+
break;
|
|
2414
|
+
}
|
|
2415
|
+
if (i >= list.length) return { kind: 'other', name: null, args: [] };
|
|
2416
|
+
|
|
2417
|
+
const head = basename(list[i]?.text ?? '');
|
|
2418
|
+
|
|
2419
|
+
if (DELETE_VERBS.has(head)) {
|
|
2420
|
+
return { kind: 'delete', name: head, args: list.slice(i + 1), optionMode };
|
|
2421
|
+
}
|
|
2422
|
+
if (COMMAND_WRAPPER_STRATEGIES.get(head)?.kind === 'shell-c') {
|
|
2423
|
+
return { kind: 'shell', name: head, args: list.slice(i + 1), optionMode };
|
|
2424
|
+
}
|
|
2425
|
+
return { kind: 'other', name: head, args: list.slice(i + 1) };
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
/**
|
|
2429
|
+
* The inline string a shell was told to RUN: the first non-option word after option processing has
|
|
2430
|
+
* seen `c` (`-c`, `-lc`, `-ec`). Shells keep accepting options between `-c` and that word:
|
|
2431
|
+
* `bash -c -- '<cmd>'`, `bash -c -x '<cmd>'`, and `bash -c -o posix '<cmd>'` all execute `<cmd>`.
|
|
2432
|
+
*
|
|
2433
|
+
* A positional word BEFORE any `-c` is a script PATH, not an inline command — `sh script.sh` runs
|
|
2434
|
+
* a file this guard never reads, so the search stops there rather than guessing.
|
|
2435
|
+
*
|
|
2436
|
+
* `--` ends the options, so the word after it is a script NAME even when it is spelled `-c`:
|
|
2437
|
+
* `bash -- -c '<string>'` runs the FILE called `-c` and hands the string to it as `$1`. Reading it
|
|
2438
|
+
* would refuse on a deletion that never happens — the false-refusal class, in the OTHER direction.
|
|
2439
|
+
*/
|
|
2440
|
+
function inlineShellCommand(args: readonly Word[]): Word | null {
|
|
2441
|
+
// No-exec is a STATE, not a single match: the options are applied left to right and the LAST one
|
|
2442
|
+
// wins. MEASURED — `bash -n -c '<cmd>'` leaves the target ALIVE but `bash -n +n -c '<cmd>'`
|
|
2443
|
+
// leaves it GONE, and `-o noexec +o noexec -c` is GONE while `+o noexec -o noexec -c` is ALIVE.
|
|
2444
|
+
// Returning on the first no-exec option classified a real deletion as allowed (cross-family
|
|
2445
|
+
// review, gpt-5.6-sol, round 11).
|
|
2446
|
+
let noExec = false;
|
|
2447
|
+
let terminal = false;
|
|
2448
|
+
let commandRequested = false;
|
|
2449
|
+
for (let k = 0; k < args.length; k++) {
|
|
2450
|
+
const t = args[k]?.text ?? '';
|
|
2451
|
+
if (t === '--') {
|
|
2452
|
+
// Before `-c`, the next word is a script name. After `-c`, `--` merely ends the remaining
|
|
2453
|
+
// option scan and the next word is the requested command string (R16 live shell matrix).
|
|
2454
|
+
return commandRequested && !noExec && !terminal ? args[k + 1] ?? null : null;
|
|
2455
|
+
}
|
|
2456
|
+
if (SHELL_TERMINAL_OPTIONS.has(t)) { terminal = true; continue; }
|
|
2457
|
+
if (SHELL_VALUE_OPTIONS.has(t)) {
|
|
2458
|
+
// `-o noexec` sets it, `+o noexec` clears it; any other set-option is irrelevant here.
|
|
2459
|
+
if (args[k + 1]?.text === SHELL_NO_EXEC_SET_OPTION) noExec = t.startsWith('-');
|
|
2460
|
+
k++;
|
|
2461
|
+
continue;
|
|
2462
|
+
}
|
|
2463
|
+
if (SHELL_LONG_VALUE_OPTIONS.has(t)) { k++; continue; } // `--rcfile FILE`: same, long spelling
|
|
2464
|
+
if (/^[-+][A-Za-z]+$/.test(t)) {
|
|
2465
|
+
const minus = t.startsWith('-');
|
|
2466
|
+
if (minus && t.includes(SHELL_DUMP_LETTER)) terminal = true;
|
|
2467
|
+
if (t.includes(SHELL_NO_EXEC_LETTER)) noExec = minus;
|
|
2468
|
+
// A CLUSTERED set-option letter still takes the next word as its value, and it does so
|
|
2469
|
+
// BEFORE the `-c` string is located. MEASURED: `bash -co posix '<cmd>'` deletes, and
|
|
2470
|
+
// `bash -co posix '<argv-printer> one two'` prints `<one><two>` — `posix` went to `-o`, and
|
|
2471
|
+
// the string is the word after it. Reading the `c` first returned `posix` as the script and
|
|
2472
|
+
// the deletion was allowed (cross-family review, gpt-5.6-sol, round 12). Letter order inside
|
|
2473
|
+
// the cluster does not matter: `-oc posix` behaves identically.
|
|
2474
|
+
const values = (t.match(/[oO]/g) ?? []).length;
|
|
2475
|
+
for (let v = 1; v <= values; v++) {
|
|
2476
|
+
if (args[k + v]?.text === SHELL_NO_EXEC_SET_OPTION) noExec = minus;
|
|
2477
|
+
}
|
|
2478
|
+
// `+c` runs the string exactly like `-c` — MEASURED on bash, sh, dash and ksh, all GONE.
|
|
2479
|
+
// Seeing `c` does NOT stop option processing; the first non-option does. Once that word is
|
|
2480
|
+
// reached, later words are `$0`, `$1`, ... and cannot change the option state (F48-guard).
|
|
2481
|
+
if (t.includes('c')) commandRequested = true;
|
|
2482
|
+
k += values;
|
|
2483
|
+
continue;
|
|
2484
|
+
}
|
|
2485
|
+
if ((t.startsWith('-') || t.startsWith('+')) && t.length > 1) continue;
|
|
2486
|
+
return commandRequested && !noExec && !terminal ? args[k] ?? null : null;
|
|
2487
|
+
}
|
|
2488
|
+
return null;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// -------------------------------------------------------------------------------------------
|
|
2492
|
+
// Stage 3 — the rules. A literal path into a protected store, or a literal database file.
|
|
2493
|
+
// -------------------------------------------------------------------------------------------
|
|
2494
|
+
|
|
2495
|
+
/**
|
|
2496
|
+
* The path as a list of segments, with `.` dropped and `..` cancelled LEXICALLY.
|
|
2497
|
+
*
|
|
2498
|
+
* WHY `..` HAD TO JOIN `.`. Dropping only `.` left `rm -rf .dz/../ordinary` matching on the segment
|
|
2499
|
+
* `.dz` in a path that leads OUT of the store, i.e. a refusal naming a store the command does not
|
|
2500
|
+
* touch — the false-refusal class this feature exists to avoid (cross-family review, gpt-5.6-sol,
|
|
2501
|
+
* round 2). Cancelling is safe in the other direction too: `ordinary/../.dz` still ends in `.dz`.
|
|
2502
|
+
*
|
|
2503
|
+
* LEXICAL, not resolved: a LEADING `..` has nothing to cancel against and is KEPT, because where it
|
|
2504
|
+
* points is exactly the guard's second printed limit — a relative path we do not resolve. No
|
|
2505
|
+
* filesystem is touched, so a symlink in the middle of the path can still make the lexical answer
|
|
2506
|
+
* differ from the real one; that is the same limit, not a new one.
|
|
2507
|
+
*
|
|
2508
|
+
* HONEST NOTE ON THE `push('..')` BRANCH. It is deliberately NOT claimed to be covered by a test,
|
|
2509
|
+
* because no test can cover it by outcome: a `..` segment matches neither PROTECTED_SEGMENTS nor
|
|
2510
|
+
* DATABASE_FILE, so keeping it and dropping it give the SAME verdict on every input (MEASURED
|
|
2511
|
+
* 2026-09-05, both normalisers run side by side over `../.dz`, `../../.agentic-qe`, `a/../../.dz`,
|
|
2512
|
+
* `../.dz/../x`, `../../x/../.dz`, `.dz/..`, `.dz/../..`, `../ordinary` — eight identical verdicts).
|
|
2513
|
+
* It is kept because it is the lexically correct answer, and because it is what stops this function
|
|
2514
|
+
* from silently becoming wrong if a future rule ever counts segments. The branch that IS observable
|
|
2515
|
+
* — cancelling `..` against a real segment — is pinned by F12 and F12-guard.
|
|
2516
|
+
*/
|
|
2517
|
+
function normalisedSegments(path: string): string[] {
|
|
2518
|
+
const out: string[] = [];
|
|
2519
|
+
for (const s of path.split('/')) {
|
|
2520
|
+
if (s === '' || s === '.') continue;
|
|
2521
|
+
if (s === '..') {
|
|
2522
|
+
const last = out[out.length - 1];
|
|
2523
|
+
if (last !== undefined && last !== '..') out.pop();
|
|
2524
|
+
else out.push('..');
|
|
2525
|
+
continue;
|
|
2526
|
+
}
|
|
2527
|
+
out.push(s);
|
|
2528
|
+
}
|
|
2529
|
+
return out;
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
/** How many words one brace expansion may produce before the guard gives up on reading it. */
|
|
2533
|
+
const MAX_BRACE_EXPANSIONS = 1000;
|
|
2534
|
+
|
|
2535
|
+
/**
|
|
2536
|
+
* One brace group the shell would EXPAND, or null when the word holds none.
|
|
2537
|
+
*
|
|
2538
|
+
* MEASURED with bash 5.1.16 by printing argv, which is what makes this narrow rather than
|
|
2539
|
+
* "anything in braces": a group expands only when it holds an unquoted top-level COMMA
|
|
2540
|
+
* (`.d{z,z}` → `<.dz><.dz>`, `{,.dz}` → `<.dz>`, the empty alternative disappearing) or a RANGE
|
|
2541
|
+
* (`{1..3}`, `{a..c}`, `{3..1}` descending, `{1..9..3}` stepped, `{01..3}` zero-padded to width,
|
|
2542
|
+
* `{-1..1}` signed). Everything else stays part of the file name: `{bar}` with no comma, `{}`,
|
|
2543
|
+
* an unclosed `x{a,b`, and a group whose braces were quoted or escaped.
|
|
2544
|
+
*
|
|
2545
|
+
* This is the discriminator F11 (round 2) never had. F11's assertions are unchanged and still
|
|
2546
|
+
* measured — `rm /tmp/foo{bar}.db` really does delete a file with braces in its name — but its
|
|
2547
|
+
* WORDING ("a brace inside a word is part of the file name") was broader than its evidence, and
|
|
2548
|
+
* the gap between the two was a direct bypass of every protected segment and database suffix
|
|
2549
|
+
* (cross-family review, gpt-5.6-sol, round 8).
|
|
2550
|
+
*/
|
|
2551
|
+
interface BraceGroup {
|
|
2552
|
+
start: number;
|
|
2553
|
+
end: number;
|
|
2554
|
+
alternatives: readonly string[];
|
|
2555
|
+
/** Where each alternative began in the ORIGINAL word, so its mask slice can follow it. */
|
|
2556
|
+
altStarts: readonly number[];
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
/** The alternatives of a `{a..b}` / `{a..b..step}` range, or null when the body is not a range. */
|
|
2560
|
+
/**
|
|
2561
|
+
* `bodyMask` is the quoting mask of the body — a range is syntax only when ALL of it is bare.
|
|
2562
|
+
*
|
|
2563
|
+
* MEASURED: bash passes `.d{y..z}` UNCHANGED for `.d{y"."."z"}`, `.d{y".."z}`, `.d"{y..z}"` and
|
|
2564
|
+
* `.d{"y"..z}`, while the bare `.d{y..z}` expands to `<.dy><.dz>`. Reading only the dequeued body
|
|
2565
|
+
* manufactured `.dy`/`.dz` and refused a command that never touches the store (cross-family review,
|
|
2566
|
+
* gpt-5.6-sol, round 12). Note the contrast with a COMMA group, where a quoted ALTERNATIVE still
|
|
2567
|
+
* expands (`{"a",.dz}` is `<a><.dz>`) — that is a different rule, and it is unchanged.
|
|
2568
|
+
*/
|
|
2569
|
+
function braceRange(body: string, bodyMask: string): string[] | null {
|
|
2570
|
+
if (bodyMask.length === body.length && /[^0]/.test(bodyMask)) return null;
|
|
2571
|
+
const parts = body.split('..');
|
|
2572
|
+
if (parts.length < 2 || parts.length > 3) return null;
|
|
2573
|
+
const [rawFrom, rawTo, rawStep] = parts as [string, string, string | undefined];
|
|
2574
|
+
const step = rawStep === undefined ? null : Number(rawStep);
|
|
2575
|
+
if (rawStep !== undefined && (!Number.isInteger(step) || step === 0)) return null;
|
|
2576
|
+
|
|
2577
|
+
if (/^-?\d+$/.test(rawFrom) && /^-?\d+$/.test(rawTo)) {
|
|
2578
|
+
const from = Number(rawFrom);
|
|
2579
|
+
const to = Number(rawTo);
|
|
2580
|
+
// A leading zero on either endpoint pads every result to the wider of the two spellings.
|
|
2581
|
+
const pad = /^-?0\d/.test(rawFrom) || /^-?0\d/.test(rawTo)
|
|
2582
|
+
? Math.max(rawFrom.length, rawTo.length)
|
|
2583
|
+
: 0;
|
|
2584
|
+
const delta = Math.abs(step ?? 1) * (to >= from ? 1 : -1);
|
|
2585
|
+
const out: string[] = [];
|
|
2586
|
+
for (let v = from; delta > 0 ? v <= to : v >= to; v += delta) {
|
|
2587
|
+
const digits = String(Math.abs(v));
|
|
2588
|
+
const sign = v < 0 ? '-' : '';
|
|
2589
|
+
out.push(pad > 0 ? sign + digits.padStart(pad - sign.length, '0') : String(v));
|
|
2590
|
+
if (out.length > MAX_BRACE_EXPANSIONS) return null;
|
|
2591
|
+
}
|
|
2592
|
+
return out;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
if (/^[A-Za-z]$/.test(rawFrom) && /^[A-Za-z]$/.test(rawTo)) {
|
|
2596
|
+
const from = rawFrom.charCodeAt(0);
|
|
2597
|
+
const to = rawTo.charCodeAt(0);
|
|
2598
|
+
const delta = Math.abs(step ?? 1) * (to >= from ? 1 : -1);
|
|
2599
|
+
const out: string[] = [];
|
|
2600
|
+
for (let v = from; delta > 0 ? v <= to : v >= to; v += delta) {
|
|
2601
|
+
out.push(String.fromCharCode(v));
|
|
2602
|
+
if (out.length > MAX_BRACE_EXPANSIONS) return null;
|
|
2603
|
+
}
|
|
2604
|
+
return out;
|
|
2605
|
+
}
|
|
2606
|
+
return null;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
function findBraceGroup(word: string, mask: string): BraceGroup | null {
|
|
2610
|
+
/** A character the shell reads as SYNTAX — quoted or escaped ones are part of the name. */
|
|
2611
|
+
const bare = (at: number): boolean => mask.charAt(at) !== '1';
|
|
2612
|
+
for (let i = 0; i < word.length; i++) {
|
|
2613
|
+
if (word.charAt(i) !== '{' || !bare(i)) continue;
|
|
2614
|
+
let depth = 0;
|
|
2615
|
+
const commas: number[] = [];
|
|
2616
|
+
for (let j = i; j < word.length; j++) {
|
|
2617
|
+
const c = word.charAt(j);
|
|
2618
|
+
if (!bare(j)) continue;
|
|
2619
|
+
if (c === '{') { depth++; continue; }
|
|
2620
|
+
if (c === ',' && depth === 1) { commas.push(j); continue; }
|
|
2621
|
+
if (c !== '}') continue;
|
|
2622
|
+
depth--;
|
|
2623
|
+
if (depth > 0) continue;
|
|
2624
|
+
const body = word.slice(i + 1, j);
|
|
2625
|
+
if (commas.length > 0) {
|
|
2626
|
+
const alternatives: string[] = [];
|
|
2627
|
+
const altStarts: number[] = [];
|
|
2628
|
+
let from = i + 1;
|
|
2629
|
+
for (const at of commas) { altStarts.push(from); alternatives.push(word.slice(from, at)); from = at + 1; }
|
|
2630
|
+
altStarts.push(from);
|
|
2631
|
+
alternatives.push(word.slice(from, j));
|
|
2632
|
+
return { start: i, end: j, alternatives, altStarts };
|
|
2633
|
+
}
|
|
2634
|
+
const range = braceRange(body, mask.slice(i + 1, j));
|
|
2635
|
+
// A range's words are GENERATED, not lifted out of the text, so they carry no quoting: the
|
|
2636
|
+
// mask slice for them is empty and `walk` pads it to bare.
|
|
2637
|
+
if (range !== null) return { start: i, end: j, alternatives: range, altStarts: range.map(() => j) };
|
|
2638
|
+
break; // `{bar}`: not expandable — look for a later group instead
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
return null;
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
/**
|
|
2645
|
+
* Every word the shell would produce from `word`, in the shell's own order, or null when the
|
|
2646
|
+
* expansion is too large to enumerate (which is reported as "could not read", never as safe).
|
|
2647
|
+
*
|
|
2648
|
+
* The order matters: the verdict names the FIRST operand that breaks a rule, and `{x,y}/{a,.dz}`
|
|
2649
|
+
* arrives as `<x/a><x/.dz><y/a><y/.dz>`, so the leftmost group is expanded first.
|
|
2650
|
+
*/
|
|
2651
|
+
interface Expansion {
|
|
2652
|
+
text: string;
|
|
2653
|
+
mask: string;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
function expandBraces(word: string, literalMask?: string): Expansion[] | null {
|
|
2657
|
+
const out: Expansion[] = [];
|
|
2658
|
+
// The mask travels WITH the text through every substitution: an alternative lifted out of a bare
|
|
2659
|
+
// group keeps its own quoting, so `{"a",b}/.dz` still expands while `pre"{a,.dz}"` still does not.
|
|
2660
|
+
const walk = (w: string, m: string): boolean => {
|
|
2661
|
+
const group = findBraceGroup(w, m);
|
|
2662
|
+
if (group === null) {
|
|
2663
|
+
out.push({ text: w, mask: m });
|
|
2664
|
+
return out.length <= MAX_BRACE_EXPANSIONS;
|
|
2665
|
+
}
|
|
2666
|
+
const pre = w.slice(0, group.start);
|
|
2667
|
+
const post = w.slice(group.end + 1);
|
|
2668
|
+
const preMask = m.slice(0, group.start);
|
|
2669
|
+
const postMask = m.slice(group.end + 1);
|
|
2670
|
+
for (let k = 0; k < group.alternatives.length; k++) {
|
|
2671
|
+
const alt = group.alternatives[k] as string;
|
|
2672
|
+
const at = group.altStarts[k] as number;
|
|
2673
|
+
const altMask = (m.slice(at, at + alt.length) + '0'.repeat(alt.length)).slice(0, alt.length);
|
|
2674
|
+
if (!walk(pre + alt + post, preMask + altMask + postMask)) return false;
|
|
2675
|
+
}
|
|
2676
|
+
return true;
|
|
2677
|
+
};
|
|
2678
|
+
const mask = literalMask !== undefined && literalMask.length === word.length
|
|
2679
|
+
? literalMask
|
|
2680
|
+
: '0'.repeat(word.length);
|
|
2681
|
+
return walk(word, mask) ? out : null;
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
function matchProtectedPath(token: string): { rule: string; what: string } | null {
|
|
2685
|
+
const trimmed = token.replace(/\/+$/, '');
|
|
2686
|
+
if (trimmed === '') return null;
|
|
2687
|
+
const segments = normalisedSegments(trimmed);
|
|
2688
|
+
|
|
2689
|
+
for (const segment of segments) {
|
|
2690
|
+
const rule = PROTECTED_SEGMENTS.get(segment);
|
|
2691
|
+
if (rule) return { rule, what: describeRule(rule) };
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
const leaf = segments[segments.length - 1] ?? '';
|
|
2695
|
+
if (DATABASE_FILE.test(leaf)) return { rule: 'database-file', what: describeRule('database-file') };
|
|
2696
|
+
|
|
2697
|
+
return null;
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
const describeRule = (id: string): string =>
|
|
2701
|
+
DESTRUCTIVE_RULES.find((r) => r.id === id)?.what ?? id;
|
|
2702
|
+
|
|
2703
|
+
interface ProtectedOperand {
|
|
2704
|
+
path: string;
|
|
2705
|
+
rule: string;
|
|
2706
|
+
what: string;
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
type DeleteAnalysis =
|
|
2710
|
+
| { kind: 'terminal'; option: string }
|
|
2711
|
+
| { kind: 'delete'; protected: ProtectedOperand[] }
|
|
2712
|
+
| { kind: 'undecidable'; why: string };
|
|
2713
|
+
|
|
2714
|
+
/** Whether shell expansion of this source word could produce an exact terminal-mode option. */
|
|
2715
|
+
function dynamicWordCouldBeTerminalOption(word: Word): boolean {
|
|
2716
|
+
const mask = word.literalMask ?? '';
|
|
2717
|
+
const firstDynamic = mask.indexOf('d');
|
|
2718
|
+
if (firstDynamic === -1) return false;
|
|
2719
|
+
|
|
2720
|
+
// Parameter expansion keeps its source spelling in `text`, while only `$` is marked dynamic.
|
|
2721
|
+
// The name characters are syntax, not output, so only the fixed prefix before `$` constrains the
|
|
2722
|
+
// resulting word. A non-dash prefix can never become either exact terminal option.
|
|
2723
|
+
const dollar = word.text.indexOf('$');
|
|
2724
|
+
if (dollar !== -1 && mask.charAt(dollar) === 'd') {
|
|
2725
|
+
const fixedPrefix = word.text.slice(0, dollar);
|
|
2726
|
+
return [...TERMINAL_MODE_OPTIONS].some((option) => option.startsWith(fixedPrefix));
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
// For glob/substitution masks, ask the narrower question we actually care about: can the
|
|
2730
|
+
// pattern produce exactly `--help` or `--version`? A late `.dz/backup-*` is dynamic, but it can
|
|
2731
|
+
// never become an option and therefore must not downgrade a real protected deletion.
|
|
2732
|
+
let pattern = '^';
|
|
2733
|
+
for (let i = 0; i < word.text.length; i++) {
|
|
2734
|
+
const ch = word.text.charAt(i);
|
|
2735
|
+
if (mask.charAt(i) === 'd') pattern += ch === '?' ? '.' : '.*';
|
|
2736
|
+
else pattern += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2737
|
+
}
|
|
2738
|
+
pattern += '$';
|
|
2739
|
+
const possible = new RegExp(pattern);
|
|
2740
|
+
return [...TERMINAL_MODE_OPTIONS].some((option) => possible.test(option));
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
/** Analyze one deletion under one explicit option grammar. */
|
|
2744
|
+
function analyzeDeleteInvocation(
|
|
2745
|
+
name: string,
|
|
2746
|
+
args: readonly Word[],
|
|
2747
|
+
arity: OptionArity,
|
|
2748
|
+
posixlyCorrect: boolean,
|
|
2749
|
+
): DeleteAnalysis {
|
|
2750
|
+
let terminal: string | null = null;
|
|
2751
|
+
for (let k = 0; k < args.length; k++) {
|
|
2752
|
+
const arg = args[k] as Word;
|
|
2753
|
+
if (arg.text === '--') break;
|
|
2754
|
+
if (TERMINAL_MODE_OPTIONS.has(arg.text)) { terminal = arg.text; break; }
|
|
2755
|
+
if (arg.text.startsWith('-') && arg.text.length > 1) {
|
|
2756
|
+
if (consumesNextWord(arity, arg.text)) k++;
|
|
2757
|
+
continue;
|
|
2758
|
+
}
|
|
2759
|
+
if (posixlyCorrect) break;
|
|
2760
|
+
}
|
|
2761
|
+
if (terminal !== null) return { kind: 'terminal', option: terminal };
|
|
2762
|
+
|
|
2763
|
+
// A shell-built word in a live option slot might become a terminal option. GNU option grammar
|
|
2764
|
+
// keeps scanning after operands; POSIXLY_CORRECT ends option parsing at the first operand.
|
|
2765
|
+
let dynamicOption = false;
|
|
2766
|
+
for (let k = 0; k < args.length; k++) {
|
|
2767
|
+
const arg = args[k] as Word;
|
|
2768
|
+
if (arg.text === '--') break;
|
|
2769
|
+
if (dynamicWordCouldBeTerminalOption(arg)) { dynamicOption = true; break; }
|
|
2770
|
+
if (arg.text.startsWith('-') && arg.text.length > 1) {
|
|
2771
|
+
if (consumesNextWord(arity, arg.text)) k++;
|
|
2772
|
+
continue;
|
|
2773
|
+
}
|
|
2774
|
+
if (posixlyCorrect) break;
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
const protectedOperands: ProtectedOperand[] = [];
|
|
2778
|
+
let flagsEnded = false;
|
|
2779
|
+
for (let k = 0; k < args.length; k++) {
|
|
2780
|
+
const arg = args[k] as Word;
|
|
2781
|
+
if (!flagsEnded && arg.text === '--') { flagsEnded = true; continue; }
|
|
2782
|
+
if (!flagsEnded && arg.text.startsWith('-') && arg.text.length > 1) {
|
|
2783
|
+
if (consumesNextWord(arity, arg.text)) k++;
|
|
2784
|
+
continue;
|
|
2785
|
+
}
|
|
2786
|
+
if (posixlyCorrect) flagsEnded = true;
|
|
2787
|
+
if (arg.dynamic) continue;
|
|
2788
|
+
const hit = matchProtectedPath(arg.text);
|
|
2789
|
+
if (hit !== null) protectedOperands.push({ path: arg.text, ...hit });
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
if (dynamicOption && protectedOperands.length > 0) {
|
|
2793
|
+
return {
|
|
2794
|
+
kind: 'undecidable',
|
|
2795
|
+
why: `«${name}» получает аргумент, собранный оболочкой, там, где ещё возможна опция: он мог бы оказаться терминальным режимом, и тогда ${protectedOperands[0]?.path ?? 'цель'} не удаляется`,
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
return { kind: 'delete', protected: protectedOperands };
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
/**
|
|
2802
|
+
* Decide whether a shell command must be refused BEFORE it runs.
|
|
2803
|
+
*
|
|
2804
|
+
* Refuses only when it can name a concrete literal path and the rule that path broke. Everything
|
|
2805
|
+
* else is allowed, and every verdict prints the guard's limits (LIMITS) so it is never read as a
|
|
2806
|
+
* total guarantee.
|
|
2807
|
+
*/
|
|
2808
|
+
export function classifyDestructive(command: string): DestructiveVerdict {
|
|
2809
|
+
try {
|
|
2810
|
+
return classifyAtDepth(command, 0);
|
|
2811
|
+
} catch (err) {
|
|
2812
|
+
// TOTALITY IS A SAFETY PROPERTY, not tidiness. The consuming hooks wrap this call in a catch
|
|
2813
|
+
// and treat an exception as "no verdict", i.e. they fail OPEN — so a single unhandled input
|
|
2814
|
+
// disarms the guard for the command that carries it, which is exactly how
|
|
2815
|
+
// `rm -rf $'\Uffffffff'; rm -rf .dz` got through (cross-family review, gpt-5.6-sol, round 9).
|
|
2816
|
+
// The known cause is fixed at its source in decodeAnsiC; this is the belt, and it answers
|
|
2817
|
+
// `undecidable` — never `allow`, which would dress a crash up as a clean review.
|
|
2818
|
+
// Pinned by F39-total: 10 000 fuzzed strings over the metacharacters this lexer gives meaning
|
|
2819
|
+
// to, no throw and a well-formed verdict every time.
|
|
2820
|
+
return {
|
|
2821
|
+
outcome: 'undecidable',
|
|
2822
|
+
path: null,
|
|
2823
|
+
rule: null,
|
|
2824
|
+
reason: `разобрать не удалось: классификатор не смог вынести вердикт (${String((err as Error)?.message ?? err)}); вердикт «проверить не удалось», потребитель обязан ПРОПУСТИТЬ`,
|
|
2825
|
+
limits: LIMITS,
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
function classifyAtDepth(
|
|
2831
|
+
command: string,
|
|
2832
|
+
depth: number,
|
|
2833
|
+
expandsBraces = true,
|
|
2834
|
+
inheritedOptionMode: PosixOptionMode = 'unknown',
|
|
2835
|
+
): DestructiveVerdict {
|
|
2836
|
+
const verdict = (
|
|
2837
|
+
outcome: DestructiveVerdict['outcome'],
|
|
2838
|
+
reason: string,
|
|
2839
|
+
path: string | null = null,
|
|
2840
|
+
rule: string | null = null,
|
|
2841
|
+
): DestructiveVerdict => ({ outcome, path, rule, reason, limits: LIMITS });
|
|
2842
|
+
|
|
2843
|
+
if (typeof command !== 'string') {
|
|
2844
|
+
return verdict('undecidable', 'разобрать не удалось: вход не является строкой; вердикт «проверить не удалось», потребитель обязан ПРОПУСТИТЬ');
|
|
2845
|
+
}
|
|
2846
|
+
if (command.trim() === '') {
|
|
2847
|
+
return verdict('allow', 'пустая команда — классифицировать нечего');
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
const lexed = lex(command);
|
|
2851
|
+
if (lexed.failure !== null && !lexed.prefixRuns) {
|
|
2852
|
+
// NEVER `refuse` here, even if a protected path is plainly visible in the raw text. A refusal
|
|
2853
|
+
// derived from an inability to read the command is a false guarantee (AC-10).
|
|
2854
|
+
return verdict('undecidable', `разобрать не удалось: ${lexed.failure}; вердикт «проверить не удалось», потребитель обязан ПРОПУСТИТЬ`);
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
let sawDelete = false;
|
|
2858
|
+
let sawTool: string | null = null;
|
|
2859
|
+
let sawExternalScript: { name: string; subcommand: string } | null = null;
|
|
2860
|
+
/** A deletion verb that was asked to print its help or version, so it removes nothing. */
|
|
2861
|
+
let sawTerminal: string | null = null;
|
|
2862
|
+
let innerAllow: DestructiveVerdict | null = null;
|
|
2863
|
+
/**
|
|
2864
|
+
* The first segment that could not be read, REMEMBERED rather than returned.
|
|
2865
|
+
*
|
|
2866
|
+
* The verdict of a multi-segment command is `refuse` if ANY segment refuses, otherwise
|
|
2867
|
+
* `undecidable` if any segment is unreadable, otherwise `allow` (F17-guard). Returning on the
|
|
2868
|
+
* first unreadable segment broke the first half of that: the hook exits 0 on `undecidable`
|
|
2869
|
+
* (AC-10), so in `sh -c "$CMD"; <verb> -rf .dz` the literal deletion behind the unreadable
|
|
2870
|
+
* segment was never examined and ran (cross-family review, gpt-5.6-sol, round 4; F17). A
|
|
2871
|
+
* `refuse` may still return at once — it is the top of the order and nothing later can beat it.
|
|
2872
|
+
*/
|
|
2873
|
+
let unreadable: DestructiveVerdict | null = null;
|
|
2874
|
+
const cannotRead = (why: string): void => {
|
|
2875
|
+
unreadable = unreadable ?? verdict('undecidable', `разобрать не удалось: ${why}; вердикт «проверить не удалось», потребитель обязан ПРОПУСТИТЬ`);
|
|
2876
|
+
};
|
|
2877
|
+
|
|
2878
|
+
// The prefix in front of an unterminated heredoc RUNS, so it is classified — but the tail was
|
|
2879
|
+
// never read, and "could not check" must not decay into "checked and fine". A refusal found in
|
|
2880
|
+
// the prefix still wins, by the same precedence as F17.
|
|
2881
|
+
if (lexed.failure !== null) cannotRead(lexed.failure);
|
|
2882
|
+
|
|
2883
|
+
// A function DEFINITION binds a name and executes nothing, so its body is classified only when
|
|
2884
|
+
// the same command uses that name again — see liftFunctionBodies for the measurement.
|
|
2885
|
+
const plan = liftFunctionBodies(lexed.lexemes);
|
|
2886
|
+
const streams: Lexeme[][] = [plan.main];
|
|
2887
|
+
if (plan.bodies.length > 0) {
|
|
2888
|
+
const mentioned = new Set<string>();
|
|
2889
|
+
for (const stream of [plan.main, ...plan.bodies.map((b) => b.lexemes)]) {
|
|
2890
|
+
// Function invocation happens after brace expansion. Comparing a bound name to the raw word
|
|
2891
|
+
// missed `f{,}`, `{f,f}` and `f{,,}` even though bash turns each into one or more calls to f.
|
|
2892
|
+
for (const rawWords of reachableSegments(stream)) {
|
|
2893
|
+
const expanded = expandSegment(rawWords, expandsBraces);
|
|
2894
|
+
if (expanded.overflowed) {
|
|
2895
|
+
cannotRead(`раскрытие имени функции даёт больше ${MAX_BRACE_EXPANSIONS} слов`);
|
|
2896
|
+
}
|
|
2897
|
+
for (const word of expanded.words) mentioned.add(word.text);
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
for (const body of plan.bodies) if (mentioned.has(body.name)) streams.push(body.lexemes);
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
for (const rawWords of streams.flatMap((stream) => reachableSegments(stream))) {
|
|
2904
|
+
const expanded = expandSegment(rawWords, expandsBraces);
|
|
2905
|
+
if (expanded.overflowed) {
|
|
2906
|
+
cannotRead(`раскрытие фигурных скобок даёт больше ${MAX_BRACE_EXPANSIONS} слов`);
|
|
2907
|
+
}
|
|
2908
|
+
const words = expanded.words;
|
|
2909
|
+
const invocation = resolveInvocation(words, inheritedOptionMode);
|
|
2910
|
+
if (invocation.kind === 'tool') { sawTool = sawTool ?? invocation.name; continue; }
|
|
2911
|
+
if (invocation.kind === 'external-script') {
|
|
2912
|
+
sawExternalScript = sawExternalScript ?? invocation;
|
|
2913
|
+
continue;
|
|
2914
|
+
}
|
|
2915
|
+
if (invocation.kind === 'opaque') { cannotRead(invocation.why); continue; }
|
|
2916
|
+
|
|
2917
|
+
// Every table row whose execution is `shell` converges here: a shell binary locates its `-c`
|
|
2918
|
+
// string, while npm/npx/pnpm already supplied the string from their carrier rule.
|
|
2919
|
+
if (invocation.kind === 'shell' || invocation.kind === 'shell-command') {
|
|
2920
|
+
const inline = invocation.kind === 'shell'
|
|
2921
|
+
? inlineShellCommand(invocation.args)
|
|
2922
|
+
: invocation.command;
|
|
2923
|
+
if (inline === null) continue;
|
|
2924
|
+
if (inline.dynamic) {
|
|
2925
|
+
cannotRead(`«${invocation.name}» получает команду, собранную оболочкой`);
|
|
2926
|
+
continue;
|
|
2927
|
+
}
|
|
2928
|
+
if (depth >= MAX_SHELL_DEPTH) {
|
|
2929
|
+
cannotRead(`вложенная оболочка глубже ${MAX_SHELL_DEPTH} уровня`);
|
|
2930
|
+
continue;
|
|
2931
|
+
}
|
|
2932
|
+
const inner = classifyAtDepth(
|
|
2933
|
+
inline.text,
|
|
2934
|
+
depth + 1,
|
|
2935
|
+
!SHELLS_WITHOUT_BRACE_EXPANSION.has(invocation.name),
|
|
2936
|
+
invocation.optionMode,
|
|
2937
|
+
);
|
|
2938
|
+
if (inner.outcome === 'refuse') return inner;
|
|
2939
|
+
if (inner.outcome === 'undecidable') { unreadable = unreadable ?? inner; continue; }
|
|
2940
|
+
innerAllow = innerAllow ?? inner;
|
|
2941
|
+
continue;
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
if (invocation.kind !== 'delete') continue;
|
|
2945
|
+
|
|
2946
|
+
const arity = DELETE_OPTIONS.get(invocation.name) ?? NO_VALUE_OPTIONS;
|
|
2947
|
+
|
|
2948
|
+
const analyses = invocation.optionMode === 'unknown'
|
|
2949
|
+
? [
|
|
2950
|
+
analyzeDeleteInvocation(invocation.name, invocation.args, arity, false),
|
|
2951
|
+
analyzeDeleteInvocation(invocation.name, invocation.args, arity, true),
|
|
2952
|
+
]
|
|
2953
|
+
: [analyzeDeleteInvocation(
|
|
2954
|
+
invocation.name,
|
|
2955
|
+
invocation.args,
|
|
2956
|
+
arity,
|
|
2957
|
+
invocation.optionMode === 'enabled',
|
|
2958
|
+
)];
|
|
2959
|
+
|
|
2960
|
+
if (analyses.some((analysis) => analysis.kind === 'undecidable')) {
|
|
2961
|
+
const uncertain = analyses.find((analysis): analysis is Extract<DeleteAnalysis, { kind: 'undecidable' }> =>
|
|
2962
|
+
analysis.kind === 'undecidable');
|
|
2963
|
+
cannotRead(uncertain?.why ?? `«${invocation.name}» получает неоднозначные аргументы`);
|
|
2964
|
+
continue;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
const terminals = analyses.filter(
|
|
2968
|
+
(analysis): analysis is Extract<DeleteAnalysis, { kind: 'terminal' }> => analysis.kind === 'terminal',
|
|
2969
|
+
);
|
|
2970
|
+
if (terminals.length === analyses.length) {
|
|
2971
|
+
sawTerminal = sawTerminal ?? `${invocation.name} ${terminals[0]?.option ?? '--help'}`;
|
|
2972
|
+
continue;
|
|
2973
|
+
}
|
|
2974
|
+
if (terminals.length > 0) {
|
|
2975
|
+
cannotRead(`эффективный режим разбора опций «${invocation.name}» зависит от невидимого стражу POSIXLY_CORRECT: один режим завершает команду, другой обрабатывает операнды`);
|
|
2976
|
+
continue;
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
const deletes = analyses as Extract<DeleteAnalysis, { kind: 'delete' }>[];
|
|
2980
|
+
const certain = deletes[0]?.protected.find((candidate) =>
|
|
2981
|
+
deletes.every((analysis) => analysis.protected.some(
|
|
2982
|
+
(other) => other.path === candidate.path && other.rule === candidate.rule,
|
|
2983
|
+
)),
|
|
2984
|
+
);
|
|
2985
|
+
if (certain !== undefined) {
|
|
2986
|
+
return verdict(
|
|
2987
|
+
'refuse',
|
|
2988
|
+
`Отказ [${certain.rule}]: ${certain.path} — ${certain.what}`,
|
|
2989
|
+
certain.path,
|
|
2990
|
+
certain.rule,
|
|
2991
|
+
);
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
if (deletes.some((analysis) => analysis.protected.length > 0)) {
|
|
2995
|
+
cannotRead(`эффективный режим разбора опций «${invocation.name}» зависит от невидимого стражу POSIXLY_CORRECT: режимы расходятся в том, какие слова являются операндами`);
|
|
2996
|
+
continue;
|
|
2997
|
+
}
|
|
2998
|
+
sawDelete = true;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
// No segment refused. An unreadable one now decides, because "could not check" must never be
|
|
3002
|
+
// reported as "checked and fine" — the second step of the refuse > undecidable > allow order.
|
|
3003
|
+
if (unreadable !== null) return unreadable;
|
|
3004
|
+
|
|
3005
|
+
if (sawTool !== null) {
|
|
3006
|
+
const how = VCS_TOOLS.has(sawTool)
|
|
3007
|
+
? `«${sawTool} rm» — операция индекса (index operation), а не файловой системы`
|
|
3008
|
+
: `«${sawTool} rm» — подкоманда инструмента, а не удаление файлов`;
|
|
3009
|
+
return verdict('allow', `${how}; правила удаления к ней не применяются`);
|
|
3010
|
+
}
|
|
3011
|
+
if (sawDelete) {
|
|
3012
|
+
return verdict('allow', 'удаление, но ни один аргумент не является буквальным путём в защищаемое хранилище');
|
|
3013
|
+
}
|
|
3014
|
+
if (sawTerminal !== null) {
|
|
3015
|
+
return verdict('allow', `«${sawTerminal}» печатает текст и завершается — операнды не удаляются`);
|
|
3016
|
+
}
|
|
3017
|
+
// The reason of the unpacked command, so the verdict says what was actually read, not that an
|
|
3018
|
+
// `sh -c` wrapper "is not a deletion".
|
|
3019
|
+
if (innerAllow !== null) return innerAllow;
|
|
3020
|
+
if (sawExternalScript !== null) {
|
|
3021
|
+
return verdict(
|
|
3022
|
+
'allow',
|
|
3023
|
+
`«${sawExternalScript.name} ${sawExternalScript.subcommand}» запускает именованный сценарий, чьё тело находится вне строки команды; чистый классификатор его не читает`,
|
|
3024
|
+
);
|
|
3025
|
+
}
|
|
3026
|
+
return verdict('allow', 'не команда удаления файлов');
|
|
3027
|
+
}
|