@lannguyensi/harness 0.48.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +825 -0
- package/dist/cli/add/index.js +15 -3
- package/dist/cli/add/index.js.map +1 -1
- package/dist/cli/apply/apply.js +19 -3
- package/dist/cli/apply/apply.js.map +1 -1
- package/dist/cli/approve/risk.d.ts +28 -2
- package/dist/cli/approve/risk.js +14 -4
- package/dist/cli/approve/risk.js.map +1 -1
- package/dist/cli/diff/index.js +22 -1
- package/dist/cli/diff/index.js.map +1 -1
- package/dist/cli/doctor/format.js +29 -2
- package/dist/cli/doctor/format.js.map +1 -1
- package/dist/cli/doctor/index.js +62 -3
- package/dist/cli/doctor/index.js.map +1 -1
- package/dist/cli/doctor/types.d.ts +57 -0
- package/dist/cli/explain-policy.d.ts +4 -1
- package/dist/cli/explain-policy.js +12 -2
- package/dist/cli/explain-policy.js.map +1 -1
- package/dist/cli/export.js +12 -1
- package/dist/cli/export.js.map +1 -1
- package/dist/cli/index.js +29 -7
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/templates.d.ts +34 -1
- package/dist/cli/init/templates.js +137 -3
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/list.js +6 -0
- package/dist/cli/list.js.map +1 -1
- package/dist/cli/loader.js +15 -0
- package/dist/cli/loader.js.map +1 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.js +11 -6
- package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-post-tool-use.d.ts +6 -4
- package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js +23 -22
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/policy/intercept.js +59 -9
- package/dist/cli/policy/intercept.js.map +1 -1
- package/dist/cli/remove/index.d.ts +12 -0
- package/dist/cli/remove/index.js +89 -0
- package/dist/cli/remove/index.js.map +1 -1
- package/dist/cli/validate/checks.d.ts +49 -0
- package/dist/cli/validate/checks.js +416 -9
- package/dist/cli/validate/checks.js.map +1 -1
- package/dist/cli/validate/index.js +11 -0
- package/dist/cli/validate/index.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +2 -2
- package/dist/policy-packs/builtin/understanding-before-execution/index.js +33 -27
- package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js +2 -2
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +46 -8
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +71 -12
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +6 -3
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution.js +9 -8
- package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
- package/dist/runtime/bash-prefix-parse.d.ts +12 -1
- package/dist/runtime/bash-prefix-parse.js +2 -2
- package/dist/runtime/bash-prefix-parse.js.map +1 -1
- package/dist/runtime/command-normalize.d.ts +112 -0
- package/dist/runtime/command-normalize.js +169 -55
- package/dist/runtime/command-normalize.js.map +1 -1
- package/dist/runtime/deletion-target-resolve.d.ts +28 -0
- package/dist/runtime/deletion-target-resolve.js +1055 -0
- package/dist/runtime/deletion-target-resolve.js.map +1 -0
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/intercept.js +17 -1
- package/dist/runtime/intercept.js.map +1 -1
- package/dist/runtime/kubectl-target-parse.d.ts +31 -0
- package/dist/runtime/kubectl-target-parse.js +249 -0
- package/dist/runtime/kubectl-target-parse.js.map +1 -0
- package/dist/runtime/read-only-bash.d.ts +11 -0
- package/dist/runtime/read-only-bash.js +288 -7
- package/dist/runtime/read-only-bash.js.map +1 -1
- package/dist/runtime/risk-classifier.js +14 -1
- package/dist/runtime/risk-classifier.js.map +1 -1
- package/dist/runtime/when-eval.d.ts +11 -2
- package/dist/runtime/when-eval.js +25 -0
- package/dist/runtime/when-eval.js.map +1 -1
- package/dist/runtime/workflow-policies.d.ts +164 -0
- package/dist/runtime/workflow-policies.js +439 -0
- package/dist/runtime/workflow-policies.js.map +1 -0
- package/dist/schema/index.d.ts +24 -0
- package/dist/schema/policies.d.ts +25 -0
- package/dist/schema/policies.js +29 -0
- package/dist/schema/policies.js.map +1 -1
- package/dist/schema/risk.d.ts +6 -0
- package/dist/schema/risk.js +80 -0
- package/dist/schema/risk.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1055 @@
|
|
|
1
|
+
// Risk Gate — static deletion-target resolver (task d03af8f6).
|
|
2
|
+
//
|
|
3
|
+
// The existing `dangerous-shell` classifier + `gate-prod-destructive*`
|
|
4
|
+
// policies gate a destructive shell action only when the Context
|
|
5
|
+
// Resolver resolves `environment: production`. On a task branch the
|
|
6
|
+
// environment is `unknown`, and every deletion — including one whose
|
|
7
|
+
// target is a typo or a stale variable pointing outside any scratch
|
|
8
|
+
// area — runs unconfirmed. This module closes that specific gap: it
|
|
9
|
+
// recognizes a deletion-verb command (`rm -r*`/`-f*`, `find ... -delete`
|
|
10
|
+
// or `find ... -exec*/-execdir/-ok/-okdir rm ...`, `git clean -f*`) and
|
|
11
|
+
// decides, STATICALLY (no filesystem I/O, no process-env read, no
|
|
12
|
+
// shell-variable expansion, no cwd substitution), whether every target
|
|
13
|
+
// it names is provably inside a declared `risk.safe_deletion_roots`
|
|
14
|
+
// entry. `src/runtime/when-eval.ts`'s `action.deletion_target_unresolvable`
|
|
15
|
+
// clause reads the verdict this module produces to gate
|
|
16
|
+
// environment-INDEPENDENTLY, deliberately NOT reusing the `risk.*`
|
|
17
|
+
// when-clauses (see that module's own doc comment for why: those
|
|
18
|
+
// clauses fail-close to matched=true for ANY unclassified action, which
|
|
19
|
+
// would make an unscoped policy fire on every unrelated unclassified
|
|
20
|
+
// Bash call in every environment — approval spam, not a deletion-
|
|
21
|
+
// specific gate).
|
|
22
|
+
//
|
|
23
|
+
// Recognition rule (current):
|
|
24
|
+
// 1. Segments come from BOTH of `command-normalize.ts`'s segmentation
|
|
25
|
+
// arms — `segmentViewOf` (the primary alphabet) AND
|
|
26
|
+
// `segmentViewOfAmpAware` (the same alphabet plus a bare `&`) —
|
|
27
|
+
// each arm walked in command order (`collectSegmentArms`), verdicts
|
|
28
|
+
// combined additively and de-duplicated by exact segment text. A
|
|
29
|
+
// verdict from EITHER arm counts toward the combined result:
|
|
30
|
+
// unresolvable if any recognized segment is, resolved only if every
|
|
31
|
+
// recognized segment is (`combineVerdicts`). Within one arm, a
|
|
32
|
+
// `find`-headed segment's search roots are carried into a directly
|
|
33
|
+
// following segment headed by a `find` primitive (`SegmentCarry`):
|
|
34
|
+
// `;` is a segment boundary, so `find <root> -exec echo {} \; -exec
|
|
35
|
+
// rm -rf {} \;` puts the deleting `-exec` in a segment of its own.
|
|
36
|
+
// When both arms decline (a command past `MAX_NORMALIZE_LENGTH`)
|
|
37
|
+
// this module falls back to a single-first-segment contract.
|
|
38
|
+
// 2. Per segment, before the head test: a leading `cd <path> &&` /
|
|
39
|
+
// `VAR=value` prefix is skipped (`parseBashPrefix`); a `#` word
|
|
40
|
+
// starts a comment and ends the segment; a leading run of shell
|
|
41
|
+
// keywords and group markers that precede a command (`if`, `then`,
|
|
42
|
+
// `else`, `elif`, `while`, `until`, `do`, `!`, `{`, `)`, `){`) is
|
|
43
|
+
// stripped, as is a trailing `}`/`;` token, a trailing `)` glued
|
|
44
|
+
// to the last token, and a trailing bare `&`.
|
|
45
|
+
// 3. A wrapper prefix on the deletion verb is peeled with
|
|
46
|
+
// `command-normalize.ts`'s own exported `peelWrapperPrefixes`
|
|
47
|
+
// (`sudo`, `doas`, `command`, `env`, `time`, `timeout`, `nice`,
|
|
48
|
+
// `stdbuf`, `setsid`, `exec`, `nohup`). Three wrappers are peeled
|
|
49
|
+
// LOCALLY first (`peelWrapperHeadsAndXargs`): `exec -a <name>` (the
|
|
50
|
+
// shared peeler treats `-a` as boolean, which would leave `<name>`
|
|
51
|
+
// as the head), the multi-call binaries `busybox`/`toybox` (their
|
|
52
|
+
// first argument IS the verb), and `xargs` (bare or path-qualified,
|
|
53
|
+
// `XARGS_HEAD_RE`). After `xargs`, a bounded forward scan finds the
|
|
54
|
+
// first token that is itself a recognized deletion-verb head, with
|
|
55
|
+
// no parsing of `xargs`'s own option vocabulary; when one is found
|
|
56
|
+
// the invocation is `xargsWrapped`, and EVERY resolver then returns
|
|
57
|
+
// UNRESOLVABLE regardless of any explicit operand: `xargs` appends
|
|
58
|
+
// (or substitutes) stdin-supplied operands at runtime, which are
|
|
59
|
+
// never statically knowable, so `xargs rm -rf /tmp/known` gates
|
|
60
|
+
// exactly like `xargs rm -rf` does. The forward scan does not stop
|
|
61
|
+
// at an intervening non-verb command word, so `xargs echo rm -rf
|
|
62
|
+
// /home/x` (which only prints) is gated too — accepted over-gate.
|
|
63
|
+
// 4. A recognised `rm` head accepts a path-qualified spelling
|
|
64
|
+
// (`/bin/rm`, `RM_HEAD_RE`); a `git` head likewise (`GIT_HEAD_RE`),
|
|
65
|
+
// and `git`'s own global options between `git` and `clean` are
|
|
66
|
+
// skipped (`-C`/`--git-dir`/`--work-tree`/`--namespace`/`-c`/
|
|
67
|
+
// `--config-env` with their value, any other `-`-prefixed token
|
|
68
|
+
// alone) so an option `command-normalize.ts` does not canonicalize
|
|
69
|
+
// away cannot hide the subcommand.
|
|
70
|
+
// 5. Every token is decoded with `decodeShellWord` (`shell-word.ts`)
|
|
71
|
+
// before any verb/flag/`-delete` comparison; the raw tokenizer
|
|
72
|
+
// honours single/double/ANSI-C quotes and a backslash escape, so
|
|
73
|
+
// `rm -rf /tmp/x\ y` is one operand, as it is for bash.
|
|
74
|
+
// 6. `find`: leading `-H`/`-L` and the `-follow` primary make the
|
|
75
|
+
// verdict unresolvable (find would follow a symlink out of the
|
|
76
|
+
// root); `-P`, `-E`, `-X`, `-d`, `-s`, `-x`, `-O<n>`, `-D <opts>` are
|
|
77
|
+
// skipped and BSD `-f <path>` contributes its path. The search
|
|
78
|
+
// roots are the leading non-flag operands (`findPathOperands`,
|
|
79
|
+
// stopping at the first `!` or `-`-prefixed token). An `-exec`/
|
|
80
|
+
// `-execdir`/`-ok`/`-okdir` whose payload head matches `RM_HEAD_RE`
|
|
81
|
+
// is a recognized deletion; the payload's own explicit operands
|
|
82
|
+
// (every non-flag token other than the exact `{}` placeholder, up to
|
|
83
|
+
// a `+` terminator or the end of the segment — the `;` terminator is
|
|
84
|
+
// a segment boundary, so its orphaned escape `\`/`'`/`"` token is
|
|
85
|
+
// dropped) are ADDITIONAL targets resolved like any other, so `find
|
|
86
|
+
// /tmp/x -exec rm -rf /home/y \;` gates on `/home/y`.
|
|
87
|
+
// 7. `git clean`: recognized with `-f`/`--force`/a short cluster
|
|
88
|
+
// containing `f`, OR when the command text anywhere mentions
|
|
89
|
+
// `clean.requireForce` (case-insensitive: `git -c
|
|
90
|
+
// clean.requireForce=false clean`, `GIT_CONFIG_PARAMETERS=...`, a
|
|
91
|
+
// `git config clean.requireForce false &&` earlier in the same
|
|
92
|
+
// command) — `command-normalize.ts` canonicalizes `git -c k=v
|
|
93
|
+
// clean` to `git clean`, so the override must be read from the raw
|
|
94
|
+
// command. `-e`/`--exclude` consume their value; a bare `(`/`)`
|
|
95
|
+
// token is never a pathspec; a bare `-n`/`--dry-run` invocation
|
|
96
|
+
// without a force flag stays unrecognized (git refuses to delete).
|
|
97
|
+
// 8. A target is resolved ONLY when ALL of the following hold; each
|
|
98
|
+
// failing check is a closed CLASS, not an instance:
|
|
99
|
+
// - it contains no `$` (variable/command substitution), no backtick
|
|
100
|
+
// (command substitution), and no `{` (brace expansion, or an
|
|
101
|
+
// `xargs`/`find` placeholder);
|
|
102
|
+
// - it starts with `/` (relative and every `~`-prefixed form is
|
|
103
|
+
// unresolvable — this module never consults cwd);
|
|
104
|
+
// - it does not end in `/` or `/.` (a trailing slash makes `rm`
|
|
105
|
+
// and `find` follow a symlinked directory into its target —
|
|
106
|
+
// measured: `rm -rf <link>/` removed the link's target directory);
|
|
107
|
+
// - no path component is `..` (a lexical collapse assumes no
|
|
108
|
+
// component is a symlink; `/tmp/<link>/../y` physically resolves
|
|
109
|
+
// relative to the link's TARGET);
|
|
110
|
+
// - no path component is a glob that can expand to `..`: a
|
|
111
|
+
// component starting with an explicit `.` whose remainder can
|
|
112
|
+
// match `.` (`.*`, `..*`, `.?`, `.[.]`, `.[!x]`; see
|
|
113
|
+
// `globComponentCanMatchDotDot`) — bash only ever matches `.`/`..`
|
|
114
|
+
// against an explicit leading dot, so `.[!.]*` and `.??*` stay
|
|
115
|
+
// resolvable;
|
|
116
|
+
// - its final component is not a bare `*`/`**`;
|
|
117
|
+
// - when the command contains an extglob opener (`?(`, `*(`, `+(`,
|
|
118
|
+
// `@(`, `!(`), the target does not end in one of those characters
|
|
119
|
+
// (the `(` is a segment boundary, so the token is the cut-off
|
|
120
|
+
// pattern head);
|
|
121
|
+
// - after `.`/`//` normalization it lies STRICTLY inside a declared
|
|
122
|
+
// root (`root + "/"` prefix; the root itself never counts, for
|
|
123
|
+
// every verb alike).
|
|
124
|
+
// 9. Redirection operands are never targets (`REDIRECT_TOKEN_RE`; a
|
|
125
|
+
// bare operator also drops the following filename token), and
|
|
126
|
+
// target collection stops at a bare `&`.
|
|
127
|
+
//
|
|
128
|
+
// Never throws. Returns `null` when no shell segment of `command`, by
|
|
129
|
+
// the narrow head test above, names a recognized deletion verb at all.
|
|
130
|
+
//
|
|
131
|
+
// NOT COVERED (deliberate residuals, pinned as a `toBeNull()`/no-op test
|
|
132
|
+
// in `tests/runtime/deletion-target-resolve.test.ts` so a future change
|
|
133
|
+
// that accidentally starts "recognizing" one of these shapes is caught):
|
|
134
|
+
// - `bash -c 'rm -rf /home/x'` / `sh -c '...'` / `env -S '...'` /
|
|
135
|
+
// `find ... -exec sh -c '...'`: the wrapped command lives inside a
|
|
136
|
+
// single string argument this module does not parse into. More
|
|
137
|
+
// generally, a `find -exec`/`-execdir`/`-ok`/`-okdir` payload whose
|
|
138
|
+
// head is not `rm` (`xargs rm -rf {} +`, `bash -c`, `perl -e`, ...)
|
|
139
|
+
// is not recognized as a deletion at all: only an `rm` payload is.
|
|
140
|
+
// - Backslash-newline line continuation (`rm -rf \<newline>/tmp/x`):
|
|
141
|
+
// the newline is a segment boundary before the tokenizer can join
|
|
142
|
+
// the lines, so the lone `\` becomes a relative target and the
|
|
143
|
+
// command over-gates (fail-closed, never a missed deletion).
|
|
144
|
+
// - `eval "rm -rf /home/x"`: `eval`'s argument is a STRING to be
|
|
145
|
+
// re-parsed, not "the command to run" positionally.
|
|
146
|
+
// - `sh script.sh` / `bash script.sh` / any script FILE the agent
|
|
147
|
+
// writes and then executes: this module never reads a file's
|
|
148
|
+
// contents.
|
|
149
|
+
// - `shred`, `rmdir`, `unlink`: real deletion-shaped verbs outside
|
|
150
|
+
// this module's closed head-token set (`rm`, `find`, `git clean`).
|
|
151
|
+
// - `npm run clean` (or any `package.json` script / Makefile target /
|
|
152
|
+
// CI job whose NAME suggests deletion): this module inspects the
|
|
153
|
+
// literal command line only, never a script's own body.
|
|
154
|
+
// - `` `rm -rf /home/x` `` (backtick command substitution): the
|
|
155
|
+
// deletion command lives inside a substitution this module does not
|
|
156
|
+
// parse into.
|
|
157
|
+
// - `find /home '(' -name a ')' -delete` (grouped `find` expression
|
|
158
|
+
// with QUOTED parentheses): `(` is a segment boundary in both
|
|
159
|
+
// `command-normalize.ts` alphabets, quote-unaware, and the cut lands
|
|
160
|
+
// inside the quoted run, so the continuation segment's own tokens
|
|
161
|
+
// are mis-quoted and its `-delete` is never seen. The escaped
|
|
162
|
+
// spelling `\( ... \)` IS covered, via the continuation carry
|
|
163
|
+
// (rule 1): the cut leaves `-delete` at the head of its own segment.
|
|
164
|
+
// - A `case` arm (`case x in *) rm -rf /home/x;; esac`): the arm's
|
|
165
|
+
// pattern and `)` are glued to the command's own segment head.
|
|
166
|
+
// - Runners outside the peeled wrapper set that hand their argv to
|
|
167
|
+
// another program: `parallel`, `ionice`, `chrt`, `taskset`,
|
|
168
|
+
// `caffeinate`, `flock <file>`, `strace`, `ssh <host>`, `docker
|
|
169
|
+
// exec <c>`, `chroot <dir>`, `su -c`, `watch`.
|
|
170
|
+
// - A symlink inside a root pointing outside it, when the command
|
|
171
|
+
// names the link WITHOUT a trailing slash (`rm -rf /tmp/link/y`
|
|
172
|
+
// deletes `<target>/y`): only the trailing-slash and `..` spellings
|
|
173
|
+
// are lexically visible.
|
|
174
|
+
// - A `clean.requireForce=false` git config set OUTSIDE the command
|
|
175
|
+
// text (repository config, an earlier command): same literal-
|
|
176
|
+
// command-line ceiling as scripts.
|
|
177
|
+
// - Any command past `MAX_NORMALIZE_LENGTH` (100,000 characters,
|
|
178
|
+
// `command-normalize.ts`) falls back to a single-first-segment
|
|
179
|
+
// contract: only the FIRST shell segment of such an oversized
|
|
180
|
+
// command is inspected, so a recognized deletion verb in a LATER
|
|
181
|
+
// segment goes unrecognized.
|
|
182
|
+
//
|
|
183
|
+
// History and measurements: CHANGELOG.md, task d03af8f6.
|
|
184
|
+
import { parseBashPrefix } from "./bash-prefix-parse.js";
|
|
185
|
+
import { GIT_GLOBAL_VALUE_TAKING_FLAGS, peelWrapperPrefixes, segmentViewOf, segmentViewOfAmpAware, } from "./command-normalize.js";
|
|
186
|
+
import { firstSegment } from "./kubectl-target-parse.js";
|
|
187
|
+
import { decodeShellWord } from "./shell-word.js";
|
|
188
|
+
const WS = /\s/;
|
|
189
|
+
function skipWs(s, i) {
|
|
190
|
+
while (i < s.length && WS.test(s[i]))
|
|
191
|
+
i++;
|
|
192
|
+
return i;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Whitespace-delimited tokenizer that preserves each token's raw text
|
|
196
|
+
* (quote/escape characters included, nothing stripped) so
|
|
197
|
+
* `decodeShellWord` can decode it afterward. A single-quoted, double-
|
|
198
|
+
* quoted, or ANSI-C (`$'...'`) run is tracked so embedded whitespace or
|
|
199
|
+
* a chain-boundary-looking character inside it does not split the
|
|
200
|
+
* token early, and an unquoted backslash escapes the character after
|
|
201
|
+
* it (so `/tmp/x\ y` is ONE token, as it is one operand for bash). A
|
|
202
|
+
* token that STARTS with an unquoted `#` begins a comment: it and
|
|
203
|
+
* everything after it are dropped. The boundary-tracking shape mirrors
|
|
204
|
+
* `kubectl-target-parse.ts`'s own tokenizer, duplicated deliberately
|
|
205
|
+
* rather than imported: that module's tokenizer strips quote characters
|
|
206
|
+
* inline as it scans, discarding exactly the raw text this module needs
|
|
207
|
+
* to hand to `decodeShellWord`. NOT reused from `command-normalize.ts`
|
|
208
|
+
* either: that module's own `tokenizeWithOffsets` is deliberately
|
|
209
|
+
* whitespace-naive (no quote tracking at all — see its own module
|
|
210
|
+
* header's "quoted directory arguments containing whitespace" NOT-
|
|
211
|
+
* SUPPORTED note), which would lose the obfuscated-flag coverage
|
|
212
|
+
* (`find /home/x $'\x2ddelete'`) if reused wholesale; this module's
|
|
213
|
+
* peeling reuses `command-normalize.ts`'s wrapper-peeling FUNCTIONS
|
|
214
|
+
* (which only ever read a token's already-decoded `.text`, never
|
|
215
|
+
* re-tokenize), not its tokenizer.
|
|
216
|
+
*/
|
|
217
|
+
function tokenizeRaw(segment) {
|
|
218
|
+
const tokens = [];
|
|
219
|
+
let i = 0;
|
|
220
|
+
const n = segment.length;
|
|
221
|
+
while (i < n) {
|
|
222
|
+
i = skipWs(segment, i);
|
|
223
|
+
if (i >= n)
|
|
224
|
+
break;
|
|
225
|
+
if (segment[i] === "#")
|
|
226
|
+
break; // comment: the rest of the segment is not a command
|
|
227
|
+
const start = i;
|
|
228
|
+
while (i < n && !WS.test(segment[i])) {
|
|
229
|
+
const c = segment[i];
|
|
230
|
+
if (c === "\\") {
|
|
231
|
+
i += i + 1 < n ? 2 : 1;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (c === "'") {
|
|
235
|
+
const end = segment.indexOf("'", i + 1);
|
|
236
|
+
i = end < 0 ? n : end + 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (c === '"') {
|
|
240
|
+
let j = i + 1;
|
|
241
|
+
while (j < n && segment[j] !== '"') {
|
|
242
|
+
if (segment[j] === "\\")
|
|
243
|
+
j++;
|
|
244
|
+
j++;
|
|
245
|
+
}
|
|
246
|
+
i = j < n ? j + 1 : n;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (c === "$" && segment[i + 1] === "'") {
|
|
250
|
+
let j = i + 2;
|
|
251
|
+
while (j < n && segment[j] !== "'") {
|
|
252
|
+
if (segment[j] === "\\")
|
|
253
|
+
j++;
|
|
254
|
+
j++;
|
|
255
|
+
}
|
|
256
|
+
i = j < n ? j + 1 : n;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
i++;
|
|
260
|
+
}
|
|
261
|
+
tokens.push(segment.slice(start, i));
|
|
262
|
+
}
|
|
263
|
+
return tokens;
|
|
264
|
+
}
|
|
265
|
+
/** A path-qualified `rm` head (`/bin/rm`, `/usr/bin/rm`) as well as the
|
|
266
|
+
* bare word. No nested quantifiers — a single bounded `\S*` alternative. */
|
|
267
|
+
const RM_HEAD_RE = /^(?:\S*\/)?rm$/;
|
|
268
|
+
/** Same shape for `git` — `command-normalize.ts` canonicalizes a
|
|
269
|
+
* path-qualified `git` already, but the oversized-command fallback
|
|
270
|
+
* hands this module un-canonicalized text. */
|
|
271
|
+
const GIT_HEAD_RE = /^(?:\S*\/)?git$/;
|
|
272
|
+
/** Same shape for `xargs` (`/usr/bin/xargs`). */
|
|
273
|
+
const XARGS_HEAD_RE = /^(?:\S*\/)?xargs$/;
|
|
274
|
+
/** Normalize a `safeRoots` entry: strip a trailing `/**`, `/*`, or `/`. */
|
|
275
|
+
function normalizeRoot(root) {
|
|
276
|
+
let r = root.trim();
|
|
277
|
+
if (r.endsWith("/**"))
|
|
278
|
+
r = r.slice(0, -3);
|
|
279
|
+
else if (r.endsWith("/*"))
|
|
280
|
+
r = r.slice(0, -2);
|
|
281
|
+
while (r.length > 1 && r.endsWith("/"))
|
|
282
|
+
r = r.slice(0, -1);
|
|
283
|
+
return r;
|
|
284
|
+
}
|
|
285
|
+
/** POSIX path normalization without any filesystem I/O — collapses
|
|
286
|
+
* `.`/`..`/empty segments lexically. Manual (not `node:path`) so this
|
|
287
|
+
* module has no platform-path dependency: harness's own scratchpad
|
|
288
|
+
* convention and every fixture in this task are POSIX paths regardless
|
|
289
|
+
* of host OS. A TARGET never reaches this with a `..` component (module
|
|
290
|
+
* header point 8 rejects it first); the `..` branch serves root entries. */
|
|
291
|
+
function normalizePosixPath(p) {
|
|
292
|
+
const absolute = p.startsWith("/");
|
|
293
|
+
const parts = p.split("/");
|
|
294
|
+
const out = [];
|
|
295
|
+
for (const part of parts) {
|
|
296
|
+
if (part === "" || part === ".")
|
|
297
|
+
continue;
|
|
298
|
+
if (part === "..") {
|
|
299
|
+
if (out.length > 0 && out[out.length - 1] !== "..")
|
|
300
|
+
out.pop();
|
|
301
|
+
else if (!absolute)
|
|
302
|
+
out.push("..");
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
out.push(part);
|
|
306
|
+
}
|
|
307
|
+
const joined = out.join("/");
|
|
308
|
+
return absolute ? `/${joined}` : joined || ".";
|
|
309
|
+
}
|
|
310
|
+
/** True when `target` (already known absolute) lies STRICTLY inside one
|
|
311
|
+
* of `safeRoots` — the root path itself never counts, for ANY
|
|
312
|
+
* recognized verb (`rm`, `find`, `git-clean` alike; `find`'s own start
|
|
313
|
+
* point is removed by `-delete`/`-exec`, the same way `rm`'s operand
|
|
314
|
+
* is, so it gets no exception). A target whose final path segment is a
|
|
315
|
+
* bare glob-sugar `*`/`**` never counts either, regardless of which
|
|
316
|
+
* root it would otherwise sit under. */
|
|
317
|
+
function isInsideAllowlist(target, safeRoots) {
|
|
318
|
+
const normalizedTarget = normalizePosixPath(target);
|
|
319
|
+
const lastSlash = normalizedTarget.lastIndexOf("/");
|
|
320
|
+
const lastSegment = normalizedTarget.slice(lastSlash + 1);
|
|
321
|
+
if (lastSegment === "*" || lastSegment === "**")
|
|
322
|
+
return false;
|
|
323
|
+
for (const rawRoot of safeRoots) {
|
|
324
|
+
const root = normalizePosixPath(normalizeRoot(rawRoot));
|
|
325
|
+
if (normalizedTarget.startsWith(`${root}/`))
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
const GLOB_META_RE = /[*?[]/;
|
|
331
|
+
/**
|
|
332
|
+
* Minimal `fnmatch` over `*`, `?`, and `[...]` (with `!`/`^` negation,
|
|
333
|
+
* ranges, a leading `]` literal, and a POSIX class `[:name:]` treated
|
|
334
|
+
* conservatively as matching anything). Only ever asked about very
|
|
335
|
+
* short subjects (`.`) — see `globComponentCanMatchDotDot`. Iterative
|
|
336
|
+
* two-pointer `*` backtracking; bounded by the pattern length.
|
|
337
|
+
*/
|
|
338
|
+
function globMatches(pattern, subject) {
|
|
339
|
+
let p = 0;
|
|
340
|
+
let s = 0;
|
|
341
|
+
let starP = -1;
|
|
342
|
+
let starS = -1;
|
|
343
|
+
while (s < subject.length) {
|
|
344
|
+
if (p < pattern.length && pattern[p] === "*") {
|
|
345
|
+
starP = p++;
|
|
346
|
+
starS = s;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const step = matchOne(pattern, p, subject[s]);
|
|
350
|
+
if (step !== null) {
|
|
351
|
+
p = step;
|
|
352
|
+
s++;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (starP >= 0) {
|
|
356
|
+
p = starP + 1;
|
|
357
|
+
s = ++starS;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
while (p < pattern.length && pattern[p] === "*")
|
|
363
|
+
p++;
|
|
364
|
+
return p === pattern.length;
|
|
365
|
+
}
|
|
366
|
+
/** Match ONE subject character at `pattern[p]`; returns the next pattern
|
|
367
|
+
* index or `null` when it does not match. */
|
|
368
|
+
function matchOne(pattern, p, ch) {
|
|
369
|
+
if (p >= pattern.length)
|
|
370
|
+
return null;
|
|
371
|
+
const c = pattern[p];
|
|
372
|
+
if (c === "?")
|
|
373
|
+
return p + 1;
|
|
374
|
+
if (c !== "[")
|
|
375
|
+
return c === ch ? p + 1 : null;
|
|
376
|
+
const close = findBracketClose(pattern, p);
|
|
377
|
+
if (close < 0)
|
|
378
|
+
return c === ch ? p + 1 : null; // unterminated: literal `[`
|
|
379
|
+
const body = pattern.slice(p + 1, close);
|
|
380
|
+
if (body.includes("[:"))
|
|
381
|
+
return close + 1; // POSIX class: assume it can match
|
|
382
|
+
let negate = false;
|
|
383
|
+
let i = 0;
|
|
384
|
+
if (body[0] === "!" || body[0] === "^") {
|
|
385
|
+
negate = true;
|
|
386
|
+
i = 1;
|
|
387
|
+
}
|
|
388
|
+
let hit = false;
|
|
389
|
+
while (i < body.length) {
|
|
390
|
+
const lo = body[i];
|
|
391
|
+
if (body[i + 1] === "-" && i + 2 < body.length) {
|
|
392
|
+
const hi = body[i + 2];
|
|
393
|
+
if (lo <= ch && ch <= hi)
|
|
394
|
+
hit = true;
|
|
395
|
+
i += 3;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (lo === ch)
|
|
399
|
+
hit = true;
|
|
400
|
+
i++;
|
|
401
|
+
}
|
|
402
|
+
return hit !== negate ? close + 1 : null;
|
|
403
|
+
}
|
|
404
|
+
function findBracketClose(pattern, open) {
|
|
405
|
+
let i = open + 1;
|
|
406
|
+
if (pattern[i] === "!" || pattern[i] === "^")
|
|
407
|
+
i++;
|
|
408
|
+
if (pattern[i] === "]")
|
|
409
|
+
i++; // a leading `]` is literal
|
|
410
|
+
while (i < pattern.length && pattern[i] !== "]")
|
|
411
|
+
i++;
|
|
412
|
+
return i < pattern.length ? i : -1;
|
|
413
|
+
}
|
|
414
|
+
/** True when a path component is a glob pattern that can expand to
|
|
415
|
+
* `..` (module header point 8): bash matches `.`/`..` only against an
|
|
416
|
+
* EXPLICIT leading `.` (never `*`, `?`, or `[.]` — measured on bash
|
|
417
|
+
* 3.2, with and without `dotglob`), so the test is "starts with a
|
|
418
|
+
* literal `.`, contains a glob metacharacter, and the remainder can
|
|
419
|
+
* match a single `.`". `..*` (remainder `.*`), `.?`, `.[.]`, `.[!x]`,
|
|
420
|
+
* `.*` all qualify; `.[!.]*` and `.??*` (the safe idioms) do not. */
|
|
421
|
+
function globComponentCanMatchDotDot(component) {
|
|
422
|
+
if (!component.startsWith(".") || !GLOB_META_RE.test(component))
|
|
423
|
+
return false;
|
|
424
|
+
return globMatches(component.slice(1), ".");
|
|
425
|
+
}
|
|
426
|
+
const EXTGLOB_OPENER_RE = /[?*+@!]\(/;
|
|
427
|
+
const EXTGLOB_TAIL_RE = /[?*+@!]$/;
|
|
428
|
+
/** Resolve one raw (decoded) target token. `false` = unresolvable (gate
|
|
429
|
+
* it). Every check is a CLASS rule — module header point 8. */
|
|
430
|
+
function targetIsResolvedSafe(token, ctx) {
|
|
431
|
+
if (token.includes("$"))
|
|
432
|
+
return false; // unexpanded variable / $(...) substitution
|
|
433
|
+
if (token.includes("`"))
|
|
434
|
+
return false; // backtick command substitution
|
|
435
|
+
if (token.includes("{"))
|
|
436
|
+
return false; // brace expansion, or an xargs/find placeholder
|
|
437
|
+
// Relative (does not start with `/`) — this also covers every
|
|
438
|
+
// `~`-prefixed token, since `~foo` is never `/`-prefixed.
|
|
439
|
+
if (!token.startsWith("/"))
|
|
440
|
+
return false;
|
|
441
|
+
// A trailing `/` or `/.` follows a symlinked directory into its target.
|
|
442
|
+
if (token.endsWith("/") || token.endsWith("/."))
|
|
443
|
+
return false;
|
|
444
|
+
for (const component of token.split("/")) {
|
|
445
|
+
if (component === "..")
|
|
446
|
+
return false; // physical `..` through a symlink escapes
|
|
447
|
+
if (globComponentCanMatchDotDot(component))
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
if (ctx.extglobOpener && EXTGLOB_TAIL_RE.test(token))
|
|
451
|
+
return false;
|
|
452
|
+
return isInsideAllowlist(token, ctx.safeRoots);
|
|
453
|
+
}
|
|
454
|
+
function buildVerdict(verb, targets, ctx) {
|
|
455
|
+
const unresolvedTargets = targets.filter((t) => !targetIsResolvedSafe(t, ctx));
|
|
456
|
+
const unresolvable = unresolvedTargets.length > 0;
|
|
457
|
+
const reason = unresolvable
|
|
458
|
+
? `${verb}: target(s) not statically resolvable inside a declared risk.safe_deletion_roots entry: ${unresolvedTargets.join(", ")}`
|
|
459
|
+
: `${verb}: every target resolves inside a declared risk.safe_deletion_roots entry`;
|
|
460
|
+
return { verb, targets, unresolvedTargets, unresolvable, reason };
|
|
461
|
+
}
|
|
462
|
+
/** A verdict that is unresolvable REGARDLESS of what its targets are —
|
|
463
|
+
* every target is reported as unresolved and `why` explains the
|
|
464
|
+
* invocation-level reason (an `xargs` wrapper, a symlink-following
|
|
465
|
+
* `find`). Never resolves anything. */
|
|
466
|
+
function forcedUnresolvableVerdict(verb, targets, why) {
|
|
467
|
+
return {
|
|
468
|
+
verb,
|
|
469
|
+
targets,
|
|
470
|
+
unresolvedTargets: targets,
|
|
471
|
+
unresolvable: true,
|
|
472
|
+
reason: `${verb}: ${why}: ${targets.join(", ")}`,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
const XARGS_SYNTHETIC_TARGET = "(xargs-supplied target, not statically known)";
|
|
476
|
+
const XARGS_WHY = "xargs supplies its operand(s) from stdin at runtime, never statically knowable";
|
|
477
|
+
/** Module header point 3: an `xargs`-wrapped deletion is unresolvable
|
|
478
|
+
* whatever explicit operands it also names; those are reported (never
|
|
479
|
+
* resolved) after the synthetic stdin marker. */
|
|
480
|
+
function xargsVerdict(verb, explicitTargets) {
|
|
481
|
+
return forcedUnresolvableVerdict(verb, [XARGS_SYNTHETIC_TARGET, ...explicitTargets], XARGS_WHY);
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Drop a redirection operand token — `REDIRECT_TOKEN_RE` (module header
|
|
485
|
+
* point 9): an optional leading fd number followed by `<`/`>`, OR a
|
|
486
|
+
* bare `&>`/`&>>` (bash's own combined stdout+stderr redirect, which
|
|
487
|
+
* does NOT start with a digit or `<`/`>` and so needs a separate
|
|
488
|
+
* alternative). Covers `>`, `>>`, `<`, `<<`, `<<<`, `>&`, `<&`, `&>`,
|
|
489
|
+
* `&>>`, and a glued form like `>/dev/null` or `2>&1`. When the matched
|
|
490
|
+
* token IS the bare operator (nothing glued to it —
|
|
491
|
+
* `BARE_REDIRECT_OPERATOR_RE`), the FOLLOWING token (the filename, when
|
|
492
|
+
* whitespace-separated from the operator) is dropped too — `rm -rf
|
|
493
|
+
* /tmp/x >& out` drops `out` the same way `rm -rf /tmp/x > out` does.
|
|
494
|
+
* Applied to the "rest" tokens of every resolver, before flag/target
|
|
495
|
+
* parsing — a redirection operand is never a deletion target.
|
|
496
|
+
*
|
|
497
|
+
* ALSO stops accumulating at a bare `&` token (background-job marker)
|
|
498
|
+
* rather than merely dropping it: `nohup rm -rf /home/x &` segmented
|
|
499
|
+
* under the PRIMARY (`BOUNDARY_RE`) arm keeps its trailing ` &` attached
|
|
500
|
+
* to the same segment text (bare `&` is not one of that arm's boundary
|
|
501
|
+
* characters — see `AMP_BOUNDARY_RE`'s own comment). Stopping (not
|
|
502
|
+
* merely dropping the `&` token and continuing) matters for `rm -rf
|
|
503
|
+
* /home/x & rm -rf /home/y`: the primary arm never splits on the bare
|
|
504
|
+
* `&` at all, so without stopping its ONE segment's rest-tokens would
|
|
505
|
+
* continue past the `&` into the SECOND invocation's own tokens, and
|
|
506
|
+
* the second invocation's verb (`rm`) — not `-`-prefixed, so it falls
|
|
507
|
+
* through every flag check — would be collected as a spurious literal
|
|
508
|
+
* `"rm"` TARGET. Stopping at the first bare `&` instead treats it as the
|
|
509
|
+
* end of THIS invocation, matching what a background job actually is;
|
|
510
|
+
* the amp-aware arm already produces the correct, separate segment for
|
|
511
|
+
* what comes after the `&`, and `combineVerdicts`'s de-duplication (see
|
|
512
|
+
* its own comment) collapses the resulting duplicate target this
|
|
513
|
+
* yields.
|
|
514
|
+
*/
|
|
515
|
+
const REDIRECT_TOKEN_RE = /^(?:\d*[<>]|&>>?)/;
|
|
516
|
+
const BARE_REDIRECT_OPERATOR_RE = /^(?:\d*(?:>>|<<<|<<|>|<)|>&|<&|&>>|&>)$/;
|
|
517
|
+
function stripRedirections(tokens) {
|
|
518
|
+
const out = [];
|
|
519
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
520
|
+
const t = tokens[i];
|
|
521
|
+
if (t === "&")
|
|
522
|
+
break;
|
|
523
|
+
if (REDIRECT_TOKEN_RE.test(t)) {
|
|
524
|
+
if (BARE_REDIRECT_OPERATOR_RE.test(t) && i + 1 < tokens.length) {
|
|
525
|
+
i += 1; // also drop the whitespace-separated filename
|
|
526
|
+
}
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
out.push(t);
|
|
530
|
+
}
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
/** A bare `(`/`)` token inside a segment is subshell syntax (`( rm -rf
|
|
534
|
+
* /home/x )` — the opening `(` is a segment boundary, the closing `)`
|
|
535
|
+
* survives as its own token when whitespace-separated), never an
|
|
536
|
+
* operand. */
|
|
537
|
+
function isBareParen(t) {
|
|
538
|
+
return t === "(" || t === ")";
|
|
539
|
+
}
|
|
540
|
+
const RM_LONG_RECURSIVE = "--recursive";
|
|
541
|
+
const RM_LONG_FORCE = "--force";
|
|
542
|
+
function resolveRm(tokens, ctx, xargsWrapped) {
|
|
543
|
+
let recursive = false;
|
|
544
|
+
let force = false;
|
|
545
|
+
const targets = [];
|
|
546
|
+
let endOfFlags = false;
|
|
547
|
+
for (const t of stripRedirections(tokens.slice(1))) {
|
|
548
|
+
if (!endOfFlags && t === "--") {
|
|
549
|
+
endOfFlags = true;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (!endOfFlags && t.startsWith("--")) {
|
|
553
|
+
if (t === RM_LONG_RECURSIVE)
|
|
554
|
+
recursive = true;
|
|
555
|
+
else if (t === RM_LONG_FORCE)
|
|
556
|
+
force = true;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (!endOfFlags && t.startsWith("-") && t.length > 1) {
|
|
560
|
+
if (t.includes("r") || t.includes("R"))
|
|
561
|
+
recursive = true;
|
|
562
|
+
if (t.includes("f"))
|
|
563
|
+
force = true;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (isBareParen(t))
|
|
567
|
+
continue;
|
|
568
|
+
targets.push(t);
|
|
569
|
+
}
|
|
570
|
+
if (!recursive && !force)
|
|
571
|
+
return null;
|
|
572
|
+
if (xargsWrapped)
|
|
573
|
+
return xargsVerdict("rm", targets);
|
|
574
|
+
if (targets.length === 0)
|
|
575
|
+
return null;
|
|
576
|
+
return buildVerdict("rm", targets, ctx);
|
|
577
|
+
}
|
|
578
|
+
/** Leading, non-flag path operands of a `find` invocation's rest tokens
|
|
579
|
+
* (after its leading options — see `resolveFind`) — the search
|
|
580
|
+
* root(s). Collection stops at the first `-`-prefixed token (a flag or
|
|
581
|
+
* an action/test primitive, `-not` included) or at `!` (`find`'s
|
|
582
|
+
* negation operator), so a test/operator token is never mistaken for a
|
|
583
|
+
* search-root operand. `(`/`)` need no stop of their own: `(` is a
|
|
584
|
+
* segment boundary and never reaches this module, and a lone `)` that
|
|
585
|
+
* did would be collected as a relative — unresolvable — operand. */
|
|
586
|
+
function findPathOperands(rest) {
|
|
587
|
+
const paths = [];
|
|
588
|
+
for (const t of rest) {
|
|
589
|
+
if (t.startsWith("-") || t === "!")
|
|
590
|
+
break;
|
|
591
|
+
if (isOrphanBoundaryEscape(t))
|
|
592
|
+
continue;
|
|
593
|
+
paths.push(t);
|
|
594
|
+
}
|
|
595
|
+
return paths;
|
|
596
|
+
}
|
|
597
|
+
/** The token an escaped or quoted boundary character leaves behind once
|
|
598
|
+
* the segmenter (quote- and escape-unaware) has cut the segment at that
|
|
599
|
+
* character: `\` from `\;`/`\(`, `'` from `';'`/`'('`, `"` likewise.
|
|
600
|
+
* Never an operand. */
|
|
601
|
+
function isOrphanBoundaryEscape(t) {
|
|
602
|
+
return t === "\\" || t === "'" || t === '"';
|
|
603
|
+
}
|
|
604
|
+
const FIND_EXEC_PRIMARIES = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
|
|
605
|
+
/** `find`'s leading options that take no value and do not change what
|
|
606
|
+
* a path operand means (GNU `-P`, `-O<n>`; BSD `-E`, `-X`, `-d`, `-s`,
|
|
607
|
+
* `-x`). */
|
|
608
|
+
const FIND_LEADING_BOOLEAN_RE = /^(?:-P|-E|-X|-d|-s|-x|-O\d*)$/;
|
|
609
|
+
const FIND_SYMLINK_FOLLOW_WHY = "find follows symlinks here (-H/-L/-follow), so a target inside a root may resolve outside it";
|
|
610
|
+
/** The explicit operands of one `-exec`/`-execdir`/`-ok`/`-okdir rm ...`
|
|
611
|
+
* payload starting at `rest[start]` (the token after the `rm` head):
|
|
612
|
+
* every non-flag token other than the exact `{}` placeholder, up to a
|
|
613
|
+
* `+` terminator or the end of the segment. The `\;` terminator never
|
|
614
|
+
* reaches this module intact — `;` is a segment boundary — so the
|
|
615
|
+
* orphaned escape/quote token it leaves behind (`\`, `'`, `"`) is
|
|
616
|
+
* dropped rather than reported as a relative operand. */
|
|
617
|
+
function findExecPayloadOperands(rest, start) {
|
|
618
|
+
const operands = [];
|
|
619
|
+
let endOfFlags = false;
|
|
620
|
+
for (let j = start; j < rest.length; j++) {
|
|
621
|
+
const t = rest[j];
|
|
622
|
+
if (t === "+" || t === ";")
|
|
623
|
+
break;
|
|
624
|
+
if (t === "{}" || isOrphanBoundaryEscape(t))
|
|
625
|
+
continue;
|
|
626
|
+
if (!endOfFlags && t === "--") {
|
|
627
|
+
endOfFlags = true;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (!endOfFlags && t.startsWith("-") && t.length > 1)
|
|
631
|
+
continue;
|
|
632
|
+
operands.push(t);
|
|
633
|
+
}
|
|
634
|
+
return operands;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Resolve a `find` invocation. `tokens[0]` is the `find` head itself,
|
|
638
|
+
* OR — for a CONTINUATION segment (module header point 6: `;` is a
|
|
639
|
+
* segment boundary, so everything after a `\;` terminator lands in a
|
|
640
|
+
* new segment headed by the next primitive, `find /tmp/x -exec rm -rf
|
|
641
|
+
* {} \; -exec rm -rf /home/y \;`) — the first primitive, with
|
|
642
|
+
* `carriedRoots` supplying the search roots the preceding segment
|
|
643
|
+
* named. Always returns the roots as targets so a payload that deletes
|
|
644
|
+
* the match (`{}`) is resolved against where `find` searches.
|
|
645
|
+
*/
|
|
646
|
+
function resolveFind(tokens, ctx, xargsWrapped, carriedRoots = null) {
|
|
647
|
+
const rest = stripRedirections(carriedRoots === null ? tokens.slice(1) : tokens);
|
|
648
|
+
const hasDelete = rest.includes("-delete");
|
|
649
|
+
const execTargets = [];
|
|
650
|
+
let hasExecRm = false;
|
|
651
|
+
rest.forEach((t, i) => {
|
|
652
|
+
const payloadHead = rest[i + 1];
|
|
653
|
+
if (FIND_EXEC_PRIMARIES.has(t) && payloadHead !== undefined && RM_HEAD_RE.test(payloadHead)) {
|
|
654
|
+
hasExecRm = true;
|
|
655
|
+
execTargets.push(...findExecPayloadOperands(rest, i + 2));
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
if (!hasDelete && !hasExecRm)
|
|
659
|
+
return null;
|
|
660
|
+
const { paths, followsSymlinks } = carriedRoots === null
|
|
661
|
+
? findSearchRoots(rest)
|
|
662
|
+
: { paths: [...carriedRoots], followsSymlinks: rest.includes("-follow") };
|
|
663
|
+
const targets = [...paths, ...execTargets];
|
|
664
|
+
if (xargsWrapped)
|
|
665
|
+
return xargsVerdict("find", targets);
|
|
666
|
+
if (followsSymlinks)
|
|
667
|
+
return forcedUnresolvableVerdict("find", targets, FIND_SYMLINK_FOLLOW_WHY);
|
|
668
|
+
return buildVerdict("find", targets, ctx);
|
|
669
|
+
}
|
|
670
|
+
/** The search roots a `find` invocation names — its leading options
|
|
671
|
+
* skipped (module header point 6: `-H`/`-L` flagged as symlink-
|
|
672
|
+
* following, `-P`/`-E`/`-X`/`-d`/`-s`/`-x`/`-O<n>` and `-D <opts>`
|
|
673
|
+
* dropped, BSD `-f <path>` contributing its path), then the leading
|
|
674
|
+
* non-flag operands (`findPathOperands`), `.` when none. `-follow`
|
|
675
|
+
* anywhere in the expression also flags symlink-following. Shared by
|
|
676
|
+
* `resolveFind` and the continuation carry in `resolveSegmentText`. */
|
|
677
|
+
function findSearchRoots(rest) {
|
|
678
|
+
let i = 0;
|
|
679
|
+
let followsSymlinks = rest.includes("-follow");
|
|
680
|
+
const leadingPaths = [];
|
|
681
|
+
while (i < rest.length) {
|
|
682
|
+
const t = rest[i];
|
|
683
|
+
if (t === "-H" || t === "-L") {
|
|
684
|
+
followsSymlinks = true;
|
|
685
|
+
i += 1;
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (FIND_LEADING_BOOLEAN_RE.test(t)) {
|
|
689
|
+
i += 1;
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
if (t === "-D") {
|
|
693
|
+
i += 2;
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
if (t === "-f") {
|
|
697
|
+
if (rest[i + 1] !== undefined)
|
|
698
|
+
leadingPaths.push(rest[i + 1]);
|
|
699
|
+
i += 2;
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
const paths = [...leadingPaths, ...findPathOperands(rest.slice(i))];
|
|
705
|
+
return { paths: paths.length > 0 ? paths : ["."], followsSymlinks };
|
|
706
|
+
}
|
|
707
|
+
/** `git`'s own global options that consume the following token — the
|
|
708
|
+
* shared `GIT_GLOBAL_VALUE_TAKING_FLAGS` plus `-c`/`--config-env`,
|
|
709
|
+
* which `read-only-bash.ts` deliberately leaves out of that set for
|
|
710
|
+
* ITS fail-closed reasons; here the value must be stepped over so the
|
|
711
|
+
* `clean` subcommand behind it is still found (module header point 4). */
|
|
712
|
+
const GIT_CLEAN_SKIP_VALUE_FLAGS = new Set([
|
|
713
|
+
...GIT_GLOBAL_VALUE_TAKING_FLAGS,
|
|
714
|
+
"-c",
|
|
715
|
+
"--config-env",
|
|
716
|
+
]);
|
|
717
|
+
const GIT_CLEAN_EXCLUDE_FLAGS = new Set(["-e", "--exclude"]);
|
|
718
|
+
/** Index of the `clean` subcommand token after a `git` head and its
|
|
719
|
+
* global options, or `-1` when the subcommand is something else. */
|
|
720
|
+
function findGitCleanSubcommand(tokens) {
|
|
721
|
+
let i = 1;
|
|
722
|
+
while (i < tokens.length) {
|
|
723
|
+
const t = tokens[i];
|
|
724
|
+
if (t === "--") {
|
|
725
|
+
i += 1;
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
if (GIT_CLEAN_SKIP_VALUE_FLAGS.has(t)) {
|
|
729
|
+
i += 2;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (t.startsWith("-")) {
|
|
733
|
+
i += 1;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
return tokens[i] === "clean" ? i : -1;
|
|
739
|
+
}
|
|
740
|
+
function resolveGitClean(tokens, cleanIdx, ctx, xargsWrapped) {
|
|
741
|
+
const rest = stripRedirections(tokens.slice(cleanIdx + 1));
|
|
742
|
+
const hasForce = ctx.gitRequireForceOverride ||
|
|
743
|
+
rest.some((t) => t === "-f" || t === "--force" || (t.startsWith("-") && !t.startsWith("--") && t.includes("f")));
|
|
744
|
+
if (!hasForce)
|
|
745
|
+
return null;
|
|
746
|
+
const pathspecs = [];
|
|
747
|
+
let endOfFlags = false;
|
|
748
|
+
for (let i = 0; i < rest.length; i++) {
|
|
749
|
+
const t = rest[i];
|
|
750
|
+
if (!endOfFlags && t === "--") {
|
|
751
|
+
endOfFlags = true;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (!endOfFlags && GIT_CLEAN_EXCLUDE_FLAGS.has(t)) {
|
|
755
|
+
i += 1; // the pattern value is not a pathspec
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (!endOfFlags && t.startsWith("-"))
|
|
759
|
+
continue;
|
|
760
|
+
if (isBareParen(t))
|
|
761
|
+
continue;
|
|
762
|
+
pathspecs.push(t);
|
|
763
|
+
}
|
|
764
|
+
const targets = pathspecs.length > 0 ? pathspecs : ["."];
|
|
765
|
+
if (xargsWrapped)
|
|
766
|
+
return xargsVerdict("git-clean", targets);
|
|
767
|
+
return buildVerdict("git-clean", targets, ctx);
|
|
768
|
+
}
|
|
769
|
+
/** Index of the first token at or after `startIdx` that is itself a
|
|
770
|
+
* recognized deletion-verb head (`RM_HEAD_RE`, `find`, or `git`
|
|
771
|
+
* immediately followed by `clean`), or `tokens.length` when none is —
|
|
772
|
+
* the bounded forward scan module header point 3 describes. Skips
|
|
773
|
+
* every intervening token uniformly, whatever `xargs` option or value
|
|
774
|
+
* it is: no option-table copy to drift. */
|
|
775
|
+
function findXargsVerbHead(tokens, startIdx) {
|
|
776
|
+
for (let i = startIdx; i < tokens.length; i++) {
|
|
777
|
+
const t = tokens[i].text;
|
|
778
|
+
if (RM_HEAD_RE.test(t) || t === "find" || (t === "git" && tokens[i + 1]?.text === "clean"))
|
|
779
|
+
return i;
|
|
780
|
+
}
|
|
781
|
+
return tokens.length;
|
|
782
|
+
}
|
|
783
|
+
/** `exec`'s own flags, peeled locally (module header point 3): `-a
|
|
784
|
+
* <name>` (or glued `-a<name>`, or `-cla <name>`) consumes its value,
|
|
785
|
+
* `-c`/`-l` and `--` are boolean. */
|
|
786
|
+
function peelExecLocal(tokens, startIdx) {
|
|
787
|
+
let idx = startIdx;
|
|
788
|
+
while (idx < tokens.length) {
|
|
789
|
+
const t = tokens[idx].text;
|
|
790
|
+
if (t === "--")
|
|
791
|
+
return idx + 1;
|
|
792
|
+
if (/^-[cl]*a$/.test(t)) {
|
|
793
|
+
idx += 2;
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (/^-[cl]*a./.test(t) || /^-[cl]+$/.test(t)) {
|
|
797
|
+
idx += 1;
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
return idx;
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Peel the wrapper chain in front of the verb (module header point 3).
|
|
806
|
+
* Each iteration first handles the THREE locally-owned wrappers —
|
|
807
|
+
* `exec` (so `-a <name>` is consumed with its value), `busybox`/
|
|
808
|
+
* `toybox` (multi-call binaries: the next token IS the verb), and
|
|
809
|
+
* `xargs` (never recognized by `command-normalize.ts`; its own argv is
|
|
810
|
+
* not simply "the command to run") — then hands the cursor to the
|
|
811
|
+
* shared `peelWrapperPrefixes` loop, and stops once neither step
|
|
812
|
+
* advances it. Interleaving keeps a composed chain (`sudo xargs -0 rm
|
|
813
|
+
* -rf`, `xargs sudo rm -rf`) correct regardless of ordering. `xargs`'s
|
|
814
|
+
* own semantics for "no verb head found at all": `idx` lands past the
|
|
815
|
+
* end, `tokens.length === 0` after slicing, and `resolveSegmentText`
|
|
816
|
+
* returns `null` — e.g. `xargs echo hi` stays unrecognized.
|
|
817
|
+
*/
|
|
818
|
+
function peelWrapperHeadsAndXargs(tokens) {
|
|
819
|
+
let idx = 0;
|
|
820
|
+
let xargsWrapped = false;
|
|
821
|
+
for (let guard = 0; guard <= tokens.length; guard++) {
|
|
822
|
+
const before = idx;
|
|
823
|
+
const head = tokens[idx]?.text;
|
|
824
|
+
if (head === "exec") {
|
|
825
|
+
idx = peelExecLocal(tokens, idx + 1);
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (head === "busybox" || head === "toybox") {
|
|
829
|
+
idx += 1;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
if (head !== undefined && XARGS_HEAD_RE.test(head)) {
|
|
833
|
+
xargsWrapped = true;
|
|
834
|
+
idx = findXargsVerbHead(tokens, idx + 1);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
idx = peelWrapperPrefixes(tokens, idx).idx;
|
|
838
|
+
if (idx === before)
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
return { idx, xargsWrapped };
|
|
842
|
+
}
|
|
843
|
+
/** Shell keywords and group markers that PRECEDE a command inside a
|
|
844
|
+
* segment (module header point 2): `if rm ...`, `then rm ...`, `do rm
|
|
845
|
+
* ...`, `! rm ...`, `{ rm ...`, and the `) {` / `){` a function body
|
|
846
|
+
* starts with after the segmenter cut at its `(`. Compared on the RAW
|
|
847
|
+
* token so a quoted `"if"` (a command name, for bash) is left alone. */
|
|
848
|
+
const LEADING_KEYWORD_RE = /^(?:if|then|else|elif|while|until|do|!|[){]+)$/;
|
|
849
|
+
/** Strip leading keyword/group-marker tokens and a trailing `}`/`;`
|
|
850
|
+
* token (a `{ ...; }` brace group's close). Operates on the RAW
|
|
851
|
+
* (pre-decode) token array so a quoted literal `"{"` argument (not
|
|
852
|
+
* brace-group syntax) is never mistaken for one — `decodeShellWord`'s
|
|
853
|
+
* decoded output for a quoted token never equals the bare, unquoted
|
|
854
|
+
* `{`/`}`/`;` this check compares against. */
|
|
855
|
+
function stripCommandMarkers(rawTokens) {
|
|
856
|
+
let tokens = rawTokens;
|
|
857
|
+
while (tokens.length > 0 && LEADING_KEYWORD_RE.test(tokens[0]))
|
|
858
|
+
tokens = tokens.slice(1);
|
|
859
|
+
while (tokens.length > 0 && (tokens[tokens.length - 1] === "}" || tokens[tokens.length - 1] === ";")) {
|
|
860
|
+
tokens = tokens.slice(0, -1);
|
|
861
|
+
}
|
|
862
|
+
return tokens;
|
|
863
|
+
}
|
|
864
|
+
/** Strip a single trailing `)` from a segment's last RAW token — a
|
|
865
|
+
* subshell close (module header point 2). `(` is a boundary character
|
|
866
|
+
* segmentation itself consumes; `)` is not, per `command-normalize
|
|
867
|
+
* .ts`'s own comment, so it survives glued to whatever token was
|
|
868
|
+
* last (`(rm -rf /home/x)` -> `/home/x)`). Applied only to the LAST
|
|
869
|
+
* token, and only when that token is not the bare `)` alone (which
|
|
870
|
+
* would zero it out into an empty string) — a lone `)` token is left
|
|
871
|
+
* for the resolvers, which drop it as subshell syntax (`isBareParen`)
|
|
872
|
+
* rather than collect it as an operand. */
|
|
873
|
+
function stripTrailingParen(rawTokens) {
|
|
874
|
+
if (rawTokens.length === 0)
|
|
875
|
+
return rawTokens;
|
|
876
|
+
const last = rawTokens[rawTokens.length - 1];
|
|
877
|
+
if (last.length > 1 && last.endsWith(")")) {
|
|
878
|
+
return [...rawTokens.slice(0, -1), last.slice(0, -1)];
|
|
879
|
+
}
|
|
880
|
+
return rawTokens;
|
|
881
|
+
}
|
|
882
|
+
/** Strip a single trailing bare `&` from a segment's last RAW token —
|
|
883
|
+
* the same treatment `stripTrailingParen` gives a subshell's trailing
|
|
884
|
+
* `)`. The PRIMARY segmentation arm does not split on a bare `&` (see
|
|
885
|
+
* `AMP_BOUNDARY_RE`'s own comment), so it survives glued to whatever
|
|
886
|
+
* token was last (`rm -rf /home/x&` -> last raw token `/home/x&`); the
|
|
887
|
+
* AMP-AWARE arm's segment for the same command has no trailing `&` at
|
|
888
|
+
* all. Left unstripped, the primary arm's target (`/home/x&`) and the
|
|
889
|
+
* amp-aware arm's target (`/home/x`) are textually different, so
|
|
890
|
+
* `combineVerdicts` would concatenate both instead of the dedup it
|
|
891
|
+
* performs on identical targets, reporting the same target twice.
|
|
892
|
+
* Applied only to the LAST token, and only when that token is not the
|
|
893
|
+
* bare `&` alone (which would zero it out into an empty string). */
|
|
894
|
+
function stripTrailingAmp(rawTokens) {
|
|
895
|
+
if (rawTokens.length === 0)
|
|
896
|
+
return rawTokens;
|
|
897
|
+
const last = rawTokens[rawTokens.length - 1];
|
|
898
|
+
if (last.length > 1 && last.endsWith("&")) {
|
|
899
|
+
return [...rawTokens.slice(0, -1), last.slice(0, -1)];
|
|
900
|
+
}
|
|
901
|
+
return rawTokens;
|
|
902
|
+
}
|
|
903
|
+
function resolveSegmentText(segmentText, ctx, carry) {
|
|
904
|
+
const prefix = parseBashPrefix(segmentText);
|
|
905
|
+
const remainder = segmentText.slice(prefix.remainderStart);
|
|
906
|
+
const rawTokens = stripTrailingAmp(stripTrailingParen(stripCommandMarkers(tokenizeRaw(remainder))));
|
|
907
|
+
const decodedTokens = rawTokens.map((t) => decodeShellWord(t));
|
|
908
|
+
const peelTokens = decodedTokens.map((text) => ({ text }));
|
|
909
|
+
const { idx, xargsWrapped } = peelWrapperHeadsAndXargs(peelTokens);
|
|
910
|
+
const tokens = decodedTokens.slice(idx);
|
|
911
|
+
if (tokens.length === 0)
|
|
912
|
+
return null;
|
|
913
|
+
const head = tokens[0];
|
|
914
|
+
if (head.startsWith("-") && carry.findRoots !== null) {
|
|
915
|
+
return resolveFind(tokens, ctx, xargsWrapped, carry.findRoots);
|
|
916
|
+
}
|
|
917
|
+
if (head === "find") {
|
|
918
|
+
carry.findRoots = findSearchRoots(stripRedirections(tokens.slice(1))).paths;
|
|
919
|
+
return resolveFind(tokens, ctx, xargsWrapped);
|
|
920
|
+
}
|
|
921
|
+
carry.findRoots = null;
|
|
922
|
+
if (RM_HEAD_RE.test(head))
|
|
923
|
+
return resolveRm(tokens, ctx, xargsWrapped);
|
|
924
|
+
if (GIT_HEAD_RE.test(head)) {
|
|
925
|
+
const cleanIdx = findGitCleanSubcommand(tokens);
|
|
926
|
+
if (cleanIdx >= 0)
|
|
927
|
+
return resolveGitClean(tokens, cleanIdx, ctx, xargsWrapped);
|
|
928
|
+
}
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Combine every recognized deletion segment's verdict from one command
|
|
933
|
+
* (module header point 1) into a single verdict: `unresolvable` is the
|
|
934
|
+
* OR of every segment's, `targets`/`unresolvedTargets` are the
|
|
935
|
+
* concatenation in encounter order, `verb` names only the FIRST
|
|
936
|
+
* recognized segment's verb, and `reason` joins every segment's own
|
|
937
|
+
* reason with `; `.
|
|
938
|
+
*
|
|
939
|
+
* De-duplication in `resolveDeletionTarget` is by exact segment TEXT,
|
|
940
|
+
* across arms, which is not enough on its own — a command whose recognized
|
|
941
|
+
* segment differs between the two arms only in a trailing marker the
|
|
942
|
+
* PRIMARY arm's alphabet does not split on but the AMP-AWARE arm's does
|
|
943
|
+
* (`nohup rm -rf /home/x &`: the trailing ` &` survives inside the
|
|
944
|
+
* primary arm's single segment, but the amp-aware arm splits it off
|
|
945
|
+
* into its own unrecognized segment) produces two textually DIFFERENT
|
|
946
|
+
* recognized segments for what a human reads as ONE invocation, whose
|
|
947
|
+
* `verb`+`targets` are nonetheless IDENTICAL once each is resolved.
|
|
948
|
+
* `dedupeVerdicts` below removes that duplication (and its matching
|
|
949
|
+
* `reason` line) by exact `(verb, JSON(targets))` equality before
|
|
950
|
+
* combining. For `rm -rf /home/x & rm -rf /home/y`, `stripRedirections`
|
|
951
|
+
* stops at the first bare `&` (see its own comment) so the primary
|
|
952
|
+
* arm's verdict for that command is IDENTICAL to the amp-aware arm's
|
|
953
|
+
* first-segment verdict, and dedup collapses the pair, leaving only the
|
|
954
|
+
* real, distinct `/home/x`/`/home/y` targets.
|
|
955
|
+
*
|
|
956
|
+
* Reporting-only ceiling: the amp-aware arm is quote-unaware, so a
|
|
957
|
+
* QUOTED literal `&` inside an operand (`rm -rf '/tmp/a&b'`) is split
|
|
958
|
+
* by that arm into a segment whose cut-off token (`'/tmp/a`) differs
|
|
959
|
+
* from the primary arm's whole operand; the verdict then lists both
|
|
960
|
+
* spellings, and the cut-off one is unresolvable (a fail-closed
|
|
961
|
+
* over-gate on that shape, never a missed deletion).
|
|
962
|
+
*/
|
|
963
|
+
function verdictKey(v) {
|
|
964
|
+
return `${v.verb} ${JSON.stringify(v.targets)}`;
|
|
965
|
+
}
|
|
966
|
+
function dedupeVerdicts(verdicts) {
|
|
967
|
+
const seen = new Set();
|
|
968
|
+
const deduped = [];
|
|
969
|
+
for (const v of verdicts) {
|
|
970
|
+
const key = verdictKey(v);
|
|
971
|
+
if (seen.has(key))
|
|
972
|
+
continue;
|
|
973
|
+
seen.add(key);
|
|
974
|
+
deduped.push(v);
|
|
975
|
+
}
|
|
976
|
+
return deduped;
|
|
977
|
+
}
|
|
978
|
+
function combineVerdicts(verdicts) {
|
|
979
|
+
const deduped = dedupeVerdicts(verdicts);
|
|
980
|
+
if (deduped.length === 1)
|
|
981
|
+
return deduped[0];
|
|
982
|
+
const seenReasons = new Set();
|
|
983
|
+
const reasons = [];
|
|
984
|
+
for (const v of deduped) {
|
|
985
|
+
if (seenReasons.has(v.reason))
|
|
986
|
+
continue;
|
|
987
|
+
seenReasons.add(v.reason);
|
|
988
|
+
reasons.push(v.reason);
|
|
989
|
+
}
|
|
990
|
+
return {
|
|
991
|
+
verb: deduped[0].verb,
|
|
992
|
+
targets: deduped.flatMap((v) => v.targets),
|
|
993
|
+
unresolvedTargets: deduped.flatMap((v) => v.unresolvedTargets),
|
|
994
|
+
unresolvable: deduped.some((v) => v.unresolvable),
|
|
995
|
+
reason: reasons.join("; "),
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Every shell segment `command` names, per `command-normalize.ts`
|
|
1000
|
+
* segmentation arm, each arm in command order (module header point 1).
|
|
1001
|
+
* `null` when EITHER arm declines (an oversized command past
|
|
1002
|
+
* `MAX_NORMALIZE_LENGTH` — the same bound for both, so in practice they
|
|
1003
|
+
* decline together). De-duplication by exact text happens in the
|
|
1004
|
+
* caller, which still walks every segment of every arm so the per-arm
|
|
1005
|
+
* `SegmentCarry` sees each arm's full sequence.
|
|
1006
|
+
*/
|
|
1007
|
+
function collectSegmentArms(command) {
|
|
1008
|
+
const primary = segmentViewOf(command);
|
|
1009
|
+
const ampAware = segmentViewOfAmpAware(command);
|
|
1010
|
+
if (primary === null || ampAware === null)
|
|
1011
|
+
return null;
|
|
1012
|
+
return [primary.map((s) => s.text), ampAware.map((s) => s.text)];
|
|
1013
|
+
}
|
|
1014
|
+
const GIT_REQUIRE_FORCE_RE = /clean\.requireforce/i;
|
|
1015
|
+
/**
|
|
1016
|
+
* Resolve the static deletion-target verdict for a Bash command string,
|
|
1017
|
+
* across EVERY shell segment named by either segmentation arm (module
|
|
1018
|
+
* header point 1), or `null` when no segment names a recognized
|
|
1019
|
+
* deletion verb at all. Never throws.
|
|
1020
|
+
*/
|
|
1021
|
+
export function resolveDeletionTarget(command, safeRoots) {
|
|
1022
|
+
if (typeof command !== "string" || command.length === 0)
|
|
1023
|
+
return null;
|
|
1024
|
+
const ctx = {
|
|
1025
|
+
safeRoots,
|
|
1026
|
+
gitRequireForceOverride: GIT_REQUIRE_FORCE_RE.test(command),
|
|
1027
|
+
extglobOpener: EXTGLOB_OPENER_RE.test(command),
|
|
1028
|
+
};
|
|
1029
|
+
const arms = collectSegmentArms(command);
|
|
1030
|
+
if (arms === null) {
|
|
1031
|
+
// Oversized command (past `MAX_NORMALIZE_LENGTH`) — both arms
|
|
1032
|
+
// decline to run at all. Fall back to the single-first-segment
|
|
1033
|
+
// contract: prefix-strip the WHOLE command first (a recognized
|
|
1034
|
+
// `cd`/`VAR=` prefix can itself span past a `&&`), THEN take the
|
|
1035
|
+
// first segment of what remains.
|
|
1036
|
+
const prefix = parseBashPrefix(command);
|
|
1037
|
+
const remainder = command.slice(prefix.remainderStart);
|
|
1038
|
+
return resolveSegmentText(firstSegment(remainder), ctx, { findRoots: null });
|
|
1039
|
+
}
|
|
1040
|
+
const verdicts = [];
|
|
1041
|
+
const seen = new Set();
|
|
1042
|
+
for (const arm of arms) {
|
|
1043
|
+
const carry = { findRoots: null };
|
|
1044
|
+
for (const text of arm) {
|
|
1045
|
+
const v = resolveSegmentText(text, ctx, carry);
|
|
1046
|
+
if (seen.has(text))
|
|
1047
|
+
continue;
|
|
1048
|
+
seen.add(text);
|
|
1049
|
+
if (v !== null)
|
|
1050
|
+
verdicts.push(v);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return verdicts.length === 0 ? null : combineVerdicts(verdicts);
|
|
1054
|
+
}
|
|
1055
|
+
//# sourceMappingURL=deletion-target-resolve.js.map
|