@lannguyensi/harness 0.49.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +683 -0
  2. package/dist/cli/add/index.js +15 -3
  3. package/dist/cli/add/index.js.map +1 -1
  4. package/dist/cli/apply/apply.js +19 -3
  5. package/dist/cli/apply/apply.js.map +1 -1
  6. package/dist/cli/approve/risk.d.ts +28 -2
  7. package/dist/cli/approve/risk.js +14 -4
  8. package/dist/cli/approve/risk.js.map +1 -1
  9. package/dist/cli/diff/index.js +22 -1
  10. package/dist/cli/diff/index.js.map +1 -1
  11. package/dist/cli/doctor/format.js +12 -2
  12. package/dist/cli/doctor/format.js.map +1 -1
  13. package/dist/cli/doctor/index.js +26 -1
  14. package/dist/cli/doctor/index.js.map +1 -1
  15. package/dist/cli/doctor/types.d.ts +26 -0
  16. package/dist/cli/explain-policy.d.ts +4 -1
  17. package/dist/cli/explain-policy.js +12 -2
  18. package/dist/cli/explain-policy.js.map +1 -1
  19. package/dist/cli/export.js +12 -1
  20. package/dist/cli/export.js.map +1 -1
  21. package/dist/cli/index.js +29 -7
  22. package/dist/cli/index.js.map +1 -1
  23. package/dist/cli/init/templates.d.ts +1 -1
  24. package/dist/cli/init/templates.js +65 -1
  25. package/dist/cli/init/templates.js.map +1 -1
  26. package/dist/cli/list.js +6 -0
  27. package/dist/cli/list.js.map +1 -1
  28. package/dist/cli/loader.js +15 -0
  29. package/dist/cli/loader.js.map +1 -1
  30. package/dist/cli/pack/hook-codex-pre-tool-use.js +11 -6
  31. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  32. package/dist/cli/pack/hook-post-tool-use.d.ts +6 -4
  33. package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
  34. package/dist/cli/pack/hook-pre-tool-use.js +23 -22
  35. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  36. package/dist/cli/remove/index.d.ts +12 -0
  37. package/dist/cli/remove/index.js +89 -0
  38. package/dist/cli/remove/index.js.map +1 -1
  39. package/dist/cli/validate/checks.d.ts +48 -0
  40. package/dist/cli/validate/checks.js +231 -0
  41. package/dist/cli/validate/checks.js.map +1 -1
  42. package/dist/cli/validate/index.js +11 -0
  43. package/dist/cli/validate/index.js.map +1 -1
  44. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +2 -2
  45. package/dist/policy-packs/builtin/understanding-before-execution/index.js +33 -27
  46. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
  47. package/dist/policy-packs/builtin/understanding-before-execution/markers.js +2 -2
  48. package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -1
  49. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +46 -8
  50. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +71 -12
  51. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
  52. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +6 -3
  53. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -1
  54. package/dist/policy-packs/builtin/understanding-before-execution.js +9 -8
  55. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  56. package/dist/runtime/command-normalize.d.ts +112 -0
  57. package/dist/runtime/command-normalize.js +169 -55
  58. package/dist/runtime/command-normalize.js.map +1 -1
  59. package/dist/runtime/deletion-target-resolve.d.ts +28 -0
  60. package/dist/runtime/deletion-target-resolve.js +1055 -0
  61. package/dist/runtime/deletion-target-resolve.js.map +1 -0
  62. package/dist/runtime/index.d.ts +1 -0
  63. package/dist/runtime/index.js +1 -0
  64. package/dist/runtime/index.js.map +1 -1
  65. package/dist/runtime/intercept.js +17 -1
  66. package/dist/runtime/intercept.js.map +1 -1
  67. package/dist/runtime/kubectl-target-parse.d.ts +16 -0
  68. package/dist/runtime/kubectl-target-parse.js +9 -1
  69. package/dist/runtime/kubectl-target-parse.js.map +1 -1
  70. package/dist/runtime/read-only-bash.d.ts +11 -0
  71. package/dist/runtime/read-only-bash.js +288 -7
  72. package/dist/runtime/read-only-bash.js.map +1 -1
  73. package/dist/runtime/risk-classifier.js +14 -1
  74. package/dist/runtime/risk-classifier.js.map +1 -1
  75. package/dist/runtime/when-eval.d.ts +11 -2
  76. package/dist/runtime/when-eval.js +25 -0
  77. package/dist/runtime/when-eval.js.map +1 -1
  78. package/dist/runtime/workflow-policies.d.ts +164 -0
  79. package/dist/runtime/workflow-policies.js +439 -0
  80. package/dist/runtime/workflow-policies.js.map +1 -0
  81. package/dist/schema/index.d.ts +24 -0
  82. package/dist/schema/policies.d.ts +25 -0
  83. package/dist/schema/policies.js +29 -0
  84. package/dist/schema/policies.js.map +1 -1
  85. package/dist/schema/risk.d.ts +6 -0
  86. package/dist/schema/risk.js +80 -0
  87. package/dist/schema/risk.js.map +1 -1
  88. 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