@seanmozeik/tripwire 0.6.7 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -141
  3. package/dist/index.js +35 -0
  4. package/dist/tripwire-cli.js +2 -10
  5. package/dist/tripwire-hook.js +3 -0
  6. package/dist/tripwire-pi.js +4 -0
  7. package/dist/tripwire.js +135 -90
  8. package/dist/types/dispatch.d.ts +18 -0
  9. package/dist/types/index.d.ts +6 -0
  10. package/dist/types/lib/bash.d.ts +27 -0
  11. package/dist/types/lib/config.d.ts +110 -0
  12. package/dist/types/lib/cursor.d.ts +16 -0
  13. package/dist/types/lib/decision.d.ts +13 -0
  14. package/dist/types/lib/diff.d.ts +3 -0
  15. package/dist/types/lib/event.d.ts +45 -0
  16. package/dist/types/lib/log.d.ts +2 -0
  17. package/dist/types/lib/secrets.d.ts +41 -0
  18. package/dist/types/rules/bash-deny.d.ts +5 -0
  19. package/dist/types/rules/bash-git.d.ts +5 -0
  20. package/dist/types/rules/bash-network-install.d.ts +4 -0
  21. package/dist/types/rules/bash-redirect.d.ts +4 -0
  22. package/dist/types/rules/bash-scoped-rm.d.ts +5 -0
  23. package/dist/types/rules/bash-tar-explosion.d.ts +4 -0
  24. package/dist/types/rules/config-custom.d.ts +6 -0
  25. package/dist/types/rules/lazy-code.d.ts +4 -0
  26. package/dist/types/rules/path-protect.d.ts +12 -0
  27. package/dist/types/rules/post-secret-scrub.d.ts +12 -0
  28. package/dist/types/rules/read-protect.d.ts +4 -0
  29. package/dist/types/rules/tool-policy.d.ts +5 -0
  30. package/package.json +53 -22
  31. package/dist/tripwire-cli.js.jsc +0 -0
  32. package/dist/tripwire.js.jsc +0 -0
  33. package/src/cli.ts +0 -264
  34. package/src/dispatch.ts +0 -354
  35. package/src/index.ts +0 -6
  36. package/src/lib/bash.ts +0 -1284
  37. package/src/lib/config.ts +0 -127
  38. package/src/lib/decision.ts +0 -36
  39. package/src/lib/diff.ts +0 -26
  40. package/src/lib/event.ts +0 -106
  41. package/src/lib/install.ts +0 -238
  42. package/src/lib/log.ts +0 -24
  43. package/src/lib/secrets.ts +0 -121
  44. package/src/rules/bash-deny.ts +0 -394
  45. package/src/rules/bash-git.ts +0 -603
  46. package/src/rules/bash-network-install.ts +0 -72
  47. package/src/rules/bash-redirect.ts +0 -91
  48. package/src/rules/bash-scoped-rm.ts +0 -84
  49. package/src/rules/bash-tar-explosion.ts +0 -76
  50. package/src/rules/bash-tool-policy.ts +0 -146
  51. package/src/rules/config-custom.ts +0 -160
  52. package/src/rules/lazy-code.ts +0 -95
  53. package/src/rules/path-protect.ts +0 -68
  54. package/src/rules/post-secret-scrub.ts +0 -38
  55. package/src/rules/read-protect.ts +0 -67
package/src/lib/bash.ts DELETED
@@ -1,1284 +0,0 @@
1
- // Generic bash command parsing built on `shell-quote`. Used by every bash
2
- // Rule — bash-deny, bash-scoped-rm, bash-redirect, bash-network-install,
3
- // Bash-tar-explosion, bash-tool-policy.
4
- //
5
- // Shell-quote.parse(cmd) returns a flat array of tokens and operator
6
- // Objects. We post-process it into structured `Segment`s split at top-level
7
- // Shell operators (`;`, `&&`, `||`, `|`, `&`, newline). Each segment
8
- // Records its head token, positional args, flags, and redirect targets.
9
- //
10
- // Limitations:
11
- // - No variable expansion. `$HOME` stays literal — rules that care about
12
- // Paths should either reject literal env-var references or accept them.
13
- // - Command substitution `$(...)` is collapsed into a single opaque
14
- // Token (`__tripwire_cmd_sub__`) so safe-path checks fail safely.
15
- // - Glob expansion is not performed.
16
-
17
- import { parse, quote, type ParseEntry } from 'shell-quote';
18
-
19
- interface Segment {
20
- readonly head: string; // First non-flag token, e.g. `rm`, `npm`
21
- readonly tokens: readonly string[]; // All string tokens incl. head, in order
22
- readonly args: readonly string[]; // Tokens[1..], minus pure flag tokens
23
- readonly flags: readonly string[]; // Tokens that start with `-`
24
- readonly redirects: readonly Redirect[];
25
- readonly raw: string; // Best-effort reconstruction
26
- }
27
-
28
- interface Redirect {
29
- readonly op: '>' | '>>' | '<' | '<<' | '<<<' | '<>' | '>&' | '<&' | '&>' | '&>>';
30
- readonly target: string;
31
- }
32
-
33
- const SAFE_RELATIVE: readonly string[] = [
34
- 'dist',
35
- 'build',
36
- '_build',
37
- 'out',
38
- 'target',
39
- '.next',
40
- '.nuxt',
41
- '.svelte-kit',
42
- '.output',
43
- '.astro',
44
- '.angular',
45
- '.vite',
46
- '.parcel-cache',
47
- '.turbo',
48
- '.vercel',
49
- '.netlify',
50
- '.fly',
51
- '.wrangler',
52
- '.serverless',
53
- 'coverage',
54
- '.nyc_output',
55
- '.cache',
56
- '.ruff_cache',
57
- '.mypy_cache',
58
- '.pytest_cache',
59
- '.ty_cache',
60
- '.tox',
61
- '__pycache__',
62
- '.venv',
63
- 'venv',
64
- 'node_modules',
65
- '.gradle',
66
- 'DerivedData',
67
- '.bundle',
68
- '.cargo-target',
69
- 'tmp',
70
- '.tmp',
71
- '.state',
72
- '.terraform',
73
- '.yarn/cache',
74
- '.yarn/install-state.gz',
75
- '.pnpm-store',
76
- '.bun',
77
- ];
78
-
79
- const SAFE_ABSOLUTE: readonly string[] = [
80
- '/tmp',
81
- '/var/tmp',
82
- '/var/folders',
83
- '/private/tmp',
84
- '/private/var/tmp',
85
- '/private/var/folders',
86
- ];
87
-
88
- const REDIRECT_OPS: ReadonlySet<string> = new Set([
89
- '>',
90
- '>>',
91
- '<',
92
- '<<',
93
- '<<<',
94
- '<>',
95
- '>&',
96
- '<&',
97
- '&>',
98
- '&>>',
99
- ]);
100
-
101
- // `|&` is bash shorthand for "pipe stdout AND stderr to the next command"
102
- // — semantically equivalent to `2>&1 |` for our purposes. shell-quote
103
- // Emits it as a single op; without classifying it as a segment break,
104
- // `cmd1 |& cmd2` collapses into one segment with `__op_|&__` as a fake
105
- // Positional arg, hiding `cmd2` from every rule.
106
- const SEGMENT_OPS: ReadonlySet<string> = new Set([';', '&&', '||', '|', '|&', '&']);
107
-
108
- // Type guards over `ParseEntry`.
109
- const isStringToken = (e: ParseEntry): e is string => typeof e === 'string';
110
- const getOp = (e: ParseEntry): string | null => {
111
- if (typeof e === 'object' && 'op' in e && typeof e.op === 'string') {
112
- return e.op;
113
- }
114
- return null;
115
- };
116
- const isCommentToken = (e: ParseEntry): boolean => typeof e === 'object' && 'comment' in e;
117
-
118
- // Glob entries from shell-quote are `{ op: 'glob', pattern: '...' }`. We
119
- // Expand them against the hook's cwd via `Bun.Glob` so safe-path rules
120
- // See concrete files (e.g. `.state/foo*` → `.state/foo-1.json`,
121
- // `.state/foo-2.json`) instead of an opaque `__op_glob__` sentinel that
122
- // Always fails safe-path checks. If a pattern matches nothing, we keep
123
- // The literal pattern so the rule can still reason about its prefix
124
- // (e.g. `.state/foo*` resolves under `.state/` regardless).
125
- const expandGlob = (pattern: string): string[] => {
126
- try {
127
- const matches = [...new Bun.Glob(pattern).scanSync({ onlyFiles: false, dot: true })];
128
- if (matches.length > 0) {
129
- return matches;
130
- }
131
- } catch {
132
- // Fall through to the literal pattern.
133
- }
134
- return [pattern];
135
- };
136
-
137
- // Convert one entry to one or more string tokens. Operators and
138
- // Command-sub markers become opaque sentinel tokens; globs expand.
139
- const entryToTokens = (e: ParseEntry): string[] => {
140
- if (isStringToken(e)) {
141
- return [e];
142
- }
143
- if (typeof e === 'object' && 'op' in e && e.op === 'glob' && 'pattern' in e) {
144
- return expandGlob(String((e as { pattern: unknown }).pattern));
145
- }
146
- const op = getOp(e);
147
- if (op !== null) {
148
- if (REDIRECT_OPS.has(op) || SEGMENT_OPS.has(op)) {
149
- return [];
150
- }
151
- return [`__op_${op}__`];
152
- }
153
- if (isCommentToken(e)) {
154
- return [];
155
- }
156
- return ['__tripwire_cmd_sub__'];
157
- };
158
-
159
- interface FdBudget {
160
- remaining: number;
161
- }
162
-
163
- const parseSegment = (entries: readonly ParseEntry[], fdBudget: FdBudget): Segment | null => {
164
- const tokens: string[] = [];
165
- const args: string[] = [];
166
- const flags: string[] = [];
167
- const redirects: Redirect[] = [];
168
-
169
- let i = 0;
170
- while (i < entries.length) {
171
- const e = entries[i]!;
172
- const op = getOp(e);
173
- if (op !== null && REDIRECT_OPS.has(op)) {
174
- // Shell-quote emits a leading file-descriptor digit (e.g. the `2` in
175
- // `2>&1`) as a separate string token *before* the redirect op. It
176
- // Also drops the whitespace, so `echo 2 >file` and `echo 2>file`
177
- // Produce identical token streams. We pre-scanned the original
178
- // Command for digit-then-redirect-with-no-space patterns and stored
179
- // The count in fdBudget; only consume one when we see a digit
180
- // Adjacent to a redirect op here.
181
- const last = tokens.at(-1);
182
- if (last !== undefined && /^[0-9]+$/.test(last) && fdBudget.remaining > 0) {
183
- tokens.pop();
184
- fdBudget.remaining--;
185
- }
186
- const target = entries[i + 1];
187
- if (target !== undefined && isStringToken(target)) {
188
- redirects.push({ op: op as Redirect['op'], target });
189
- i += 2;
190
- continue;
191
- }
192
- i++;
193
- continue;
194
- }
195
- for (const t of entryToTokens(e)) {
196
- tokens.push(t);
197
- }
198
- i++;
199
- }
200
-
201
- if (tokens.length === 0) {
202
- return null;
203
- }
204
- for (let j = 1; j < tokens.length; j++) {
205
- const t = tokens[j]!;
206
- if (t.startsWith('-') && t !== '-') {
207
- flags.push(t);
208
- } else {
209
- args.push(t);
210
- }
211
- }
212
- // Normalise the head to its basename so command-name rules match regardless
213
- // Of whether the command was invoked via absolute path (/bin/rm), a
214
- // Homebrew-prefixed path (/opt/homebrew/bin/gog), or a relative ./rm form.
215
- // This fixes the containment hole where `/bin/rm <unsafe>` bypassed every
216
- // Rule that compared `seg.head === 'rm'`. The `tokens` array is left
217
- // Unchanged (raw reconstruction stays accurate); only the canonical `head`
218
- // Used for matching is normalised.
219
- const rawHead = tokens[0]!;
220
- const slashIdx = rawHead.lastIndexOf('/');
221
- const head = slashIdx === -1 ? rawHead : rawHead.slice(slashIdx + 1);
222
- return { head, tokens, args, flags, redirects, raw: tokens.join(' ') };
223
- };
224
-
225
- // Pass an env function that preserves variable references as literals,
226
- // Otherwise shell-quote treats `$HOME` as an empty string and we lose
227
- // The ability to reason about home-directory references.
228
- const PRESERVE_ENV = (key: string): string => `$${key}`;
229
-
230
- // Shell-quote splits `&>file` into two ops — `{op:"&"}` then `{op:">"}` —
231
- // Which would (a) make the `&` look like a backgrounding segment break and
232
- // (b) hide the redirect from rule analysis. Merge those pairs back into
233
- // `&>` / `&>>` before segment splitting.
234
- const mergeAmpRedirects = (entries: readonly ParseEntry[]): ParseEntry[] => {
235
- const out: ParseEntry[] = [];
236
- for (let i = 0; i < entries.length; i++) {
237
- const e = entries[i]!;
238
- const next = entries[i + 1];
239
- if (getOp(e) === '&' && next !== undefined && (getOp(next) === '>' || getOp(next) === '>>')) {
240
- const merged = { op: getOp(next) === '>' ? '&>' : '&>>' } as unknown as ParseEntry;
241
- out.push(merged);
242
- i++;
243
- continue;
244
- }
245
- // `>|file` is bash's noclobber-override redirect. shell-quote splits
246
- // It into `{op:">"}, {op:"|"}` — the `|` then trips segment splitting
247
- // And the redirect target is lost. Re-merge to a single `>` op (the
248
- // Noclobber bit doesn't matter to rule analysis; what matters is that
249
- // It's a write redirect to the following target).
250
- if (getOp(e) === '>' && next !== undefined && getOp(next) === '|') {
251
- out.push({ op: '>' } as unknown as ParseEntry);
252
- i++;
253
- continue;
254
- }
255
- out.push(e);
256
- }
257
- return out;
258
- };
259
-
260
- // Count digit-then-redirect adjacencies in the source string (`2>file`,
261
- // `1>&2`, `2>>log`). The `(?<![\w$])` rejects matches inside identifiers
262
- // Like `foo2>bar`. A trailing `>` or `<` with no whitespace is required —
263
- // `echo 2 >file` keeps `2` as a positional arg.
264
- const countFdPrefixRedirects = (cmd: string): number => {
265
- const matches = cmd.match(/(?<![\w$])\d+(?=[<>])/g);
266
- return matches?.length ?? 0;
267
- };
268
-
269
- const heredocDelimiterFromLine = (line: string): string | null => {
270
- const match =
271
- /<<-?\s*(?:"(?<quoted>[^"]+)"|'(?<single>[^']+)'|(?<unquoted>[A-Za-z_][A-Za-z0-9_]*))/u.exec(
272
- line,
273
- );
274
- return (
275
- match?.groups?.['quoted'] ?? match?.groups?.['single'] ?? match?.groups?.['unquoted'] ?? null
276
- );
277
- };
278
-
279
- const SHELL_STDIN_HEAD_RE =
280
- /(?:^|[|;&]\s*)(?:\/(?:usr\/bin|bin|usr\/local\/bin|opt\/homebrew\/bin)\/)?(?:sh|bash|zsh|dash|ksh|ash)(?:\s|$)/u;
281
-
282
- const heredocFeedsShell = (line: string): boolean => SHELL_STDIN_HEAD_RE.test(line);
283
-
284
- const maskLiteralHeredocBodies = (cmd: string): string => {
285
- const lines = cmd.split('\n');
286
- const out: string[] = [];
287
- for (let i = 0; i < lines.length; i++) {
288
- const line = lines[i]!;
289
- out.push(line);
290
- const delimiter = heredocDelimiterFromLine(line);
291
- if (delimiter === null || heredocFeedsShell(line)) {
292
- continue;
293
- }
294
- i++;
295
- while (i < lines.length && lines[i]!.trim() !== delimiter) {
296
- i++;
297
- }
298
- if (i < lines.length) {
299
- out.push('__HEREDOC_BODY__');
300
- out.push(lines[i]!);
301
- }
302
- }
303
- return out.join('\n');
304
- };
305
-
306
- // Side-channel for static-string lookup. Mask-protected rules (hasBypass,
307
- // Bash-deny scanning) must never see heredoc body characters — a body
308
- // Containing `# tripwire-allow` or `rm -rf /` would slip past them.
309
- // `unwrapStaticString` still needs the original body of `$(cat <<EOF ... EOF)`
310
- // To validate the wrapped commit message. Keep masking universal, and pass
311
- // The original-body lookup through a separate channel only the static-string
312
- // Extractor consults.
313
- const collectHeredocBodies = (cmd: string): ReadonlyMap<string, string> => {
314
- const map = new Map<string, string>();
315
- const lines = cmd.split('\n');
316
- for (let i = 0; i < lines.length; i++) {
317
- const line = lines[i]!;
318
- const delimiter = heredocDelimiterFromLine(line);
319
- if (delimiter === null || heredocFeedsShell(line)) {
320
- continue;
321
- }
322
- const body: string[] = [];
323
- i++;
324
- while (i < lines.length && lines[i]!.trim() !== delimiter) {
325
- body.push(lines[i]!);
326
- i++;
327
- }
328
- if (!map.has(delimiter)) {
329
- map.set(delimiter, body.join('\n'));
330
- }
331
- }
332
- return map;
333
- };
334
-
335
- const extractShellHeredocCommands = (cmd: string): string[] => {
336
- const lines = cmd.split('\n');
337
- const out: string[] = [];
338
- for (let i = 0; i < lines.length; i++) {
339
- const line = lines[i]!;
340
- const delimiter = heredocDelimiterFromLine(line);
341
- if (delimiter === null || !heredocFeedsShell(line)) {
342
- continue;
343
- }
344
- const body: string[] = [];
345
- i++;
346
- while (i < lines.length && lines[i]!.trim() !== delimiter) {
347
- body.push(lines[i]!);
348
- i++;
349
- }
350
- if (body.length > 0) {
351
- out.push(body.join('\n'));
352
- }
353
- }
354
- return out;
355
- };
356
-
357
- // Extract inner commands from `$(...)`, `<(...)`, `>(...)`, and `` `...` ``.
358
- // Shell-quote collapses these into opaque sentinel tokens (which is correct
359
- // For safe-path checks — substituted output is unknown), but it also hides
360
- // The inner commands themselves from rule analysis. So `tee >(rm -rf /etc)`
361
- // Would let the `rm` slip through. We pull the inner commands out and
362
- // Analyze them as additional segments.
363
- //
364
- // Backticks don't nest (bash needs `\` escaping for that, which we treat as
365
- // A literal). Process/command substitutions can nest arbitrarily — a depth
366
- // Counter handles the balanced parens.
367
- const findBacktickEnd = (cmd: string, start: number): number | null => {
368
- for (let i = start; i < cmd.length; i++) {
369
- const ch = cmd[i]!;
370
- if (ch === '\\') {
371
- i++;
372
- continue;
373
- }
374
- if (ch === '`') {
375
- return i;
376
- }
377
- }
378
- return null;
379
- };
380
-
381
- const findSubstitutionEnd = (cmd: string, start: number): number | null => {
382
- let depth = 1;
383
- let quote: 'single' | 'double' | null = null;
384
- for (let j = start; j < cmd.length; j++) {
385
- const cj = cmd[j]!;
386
- if (cj === '\\') {
387
- j++;
388
- continue;
389
- }
390
- if (quote === 'single') {
391
- if (cj === "'") {
392
- quote = null;
393
- }
394
- continue;
395
- }
396
- if (cj === "'") {
397
- quote ??= 'single';
398
- continue;
399
- }
400
- if (cj === '"') {
401
- quote = quote === 'double' ? null : 'double';
402
- continue;
403
- }
404
- if (cj === '(') {
405
- depth++;
406
- continue;
407
- }
408
- if (cj === ')') {
409
- depth--;
410
- if (depth === 0) {
411
- return j;
412
- }
413
- }
414
- }
415
- return null;
416
- };
417
-
418
- const extractInnerCommands = (cmd: string): string[] => {
419
- const inner: string[] = [];
420
- let quote: 'single' | 'double' | null = null;
421
- for (let i = 0; i < cmd.length; i++) {
422
- const ch = cmd[i]!;
423
- if (ch === '\\') {
424
- i++;
425
- continue;
426
- }
427
- if (quote === 'single') {
428
- if (ch === "'") {
429
- quote = null;
430
- }
431
- continue;
432
- }
433
- if (ch === "'") {
434
- quote ??= 'single';
435
- continue;
436
- }
437
- if (ch === '"') {
438
- quote = quote === 'double' ? null : 'double';
439
- continue;
440
- }
441
- if (ch === '`') {
442
- const end = findBacktickEnd(cmd, i + 1);
443
- if (end !== null) {
444
- inner.push(cmd.slice(i + 1, end));
445
- i = end;
446
- }
447
- continue;
448
- }
449
- const next = cmd[i + 1];
450
- const isCommandSubStart = ch === '$' && next === '(';
451
- const isProcessSubStart = quote === null && (ch === '<' || ch === '>') && next === '(';
452
- if (!isCommandSubStart && !isProcessSubStart) {
453
- continue;
454
- }
455
- const end = findSubstitutionEnd(cmd, i + 2);
456
- if (end !== null) {
457
- inner.push(cmd.slice(i + 2, end));
458
- i = end;
459
- }
460
- }
461
- return inner;
462
- };
463
-
464
- // ── Exec-flag extraction (fd -x, find -exec, etc.) ───────────────────
465
- // Tools that take a subcommand on the same arg vector hide that
466
- // Subcommand from rule analysis. Pull it out, substitute the user-
467
- // Provided search root into the placeholder(s), and feed the
468
- // Reconstructed command back through parseCommand so every existing
469
- // Bash rule (deny / scoped-rm / redirect / etc.) sees it.
470
-
471
- const HOME_VAR_RE = /^\$\{?HOME\}?(?:\/|$)/;
472
-
473
- const pathLikeToken = (t: string): boolean => {
474
- if (t === '' || t === '-') {
475
- return false;
476
- }
477
- if (t === '/' || t === '~' || t === '.' || t === '..') {
478
- return true;
479
- }
480
- if (
481
- t.startsWith('/') ||
482
- t.startsWith('~') ||
483
- t.startsWith('./') ||
484
- t.startsWith('../') ||
485
- HOME_VAR_RE.test(t)
486
- ) {
487
- return true;
488
- }
489
- return false;
490
- };
491
-
492
- // Rank candidate search roots by how dangerous a `cmd <root>` invocation
493
- // Would be. Higher wins.
494
- const pathDangerScore = (t: string): number => {
495
- if (t === '/') {
496
- return 100;
497
- }
498
- if (t === '~' || HOME_VAR_RE.test(t)) {
499
- return 90;
500
- }
501
- if (/^\/(?<dir>etc|usr|bin|sbin|System|Library|var|boot|root|home)(?<suffix>\/|$)/.test(t)) {
502
- return 80;
503
- }
504
- if (t.startsWith('/Users/')) {
505
- return 70;
506
- }
507
- if (t.startsWith('/') || t.startsWith('~')) {
508
- return 60;
509
- }
510
- if (t.startsWith('../')) {
511
- return 40;
512
- }
513
- if (t === '..' || t === '.' || t.startsWith('./')) {
514
- return 10;
515
- }
516
- return 50;
517
- };
518
-
519
- interface ExecSpec {
520
- // Flag tokens that introduce a nested command, e.g. `-x` / `-exec`.
521
- readonly execFlags: ReadonlySet<string>;
522
- // Placeholder tokens the tool substitutes with each match path.
523
- readonly placeholders: ReadonlySet<string>;
524
- // Walk tokens[1..execFlagIdx) and return the most-suspicious search root
525
- // The tool would feed into placeholders, or `.` if nothing path-shaped
526
- // Is present.
527
- readonly pickRoot: (tokens: readonly string[], execFlagIdx: number) => string;
528
- }
529
-
530
- // Fd's flag layout: flags can appear before or after the pattern/path
531
- // Positionals, and some flags consume a value (-e ts, -t f, -d 3). We
532
- // Need to skip those value tokens, otherwise `ts` is misread as a path.
533
- const FD_VALUE_FLAGS: ReadonlySet<string> = new Set([
534
- '-e',
535
- '--extension',
536
- '-t',
537
- '--type',
538
- '-E',
539
- '--exclude',
540
- '-d',
541
- '--max-depth',
542
- '--min-depth',
543
- '--exact-depth',
544
- '-c',
545
- '--color',
546
- '--changed-within',
547
- '--changed-before',
548
- '-S',
549
- '--size',
550
- '-o',
551
- '--owner',
552
- '-j',
553
- '--threads',
554
- '-g',
555
- '--glob',
556
- '--format',
557
- '--max-results',
558
- '--ignore-file',
559
- '--search-path',
560
- '--base-directory',
561
- '--path-separator',
562
- '--and',
563
- ]);
564
-
565
- const pickFdSearchRoot = (tokens: readonly string[], execFlagIdx: number): string => {
566
- const candidates: string[] = [];
567
- let i = 1;
568
- while (i < execFlagIdx) {
569
- const t = tokens[i]!;
570
- if (FD_VALUE_FLAGS.has(t)) {
571
- i += 2;
572
- continue;
573
- }
574
- if (t.startsWith('-')) {
575
- i++;
576
- continue;
577
- }
578
- if (pathLikeToken(t)) {
579
- candidates.push(t);
580
- }
581
- i++;
582
- }
583
- if (candidates.length === 0) {
584
- return '.';
585
- }
586
- candidates.sort((a, b) => pathDangerScore(b) - pathDangerScore(a));
587
- return candidates[0]!;
588
- };
589
-
590
- // Find's grammar: PATHs come first, before any flag-shaped token. Once we
591
- // Hit a `-`-prefixed token (a test predicate or action), no more paths.
592
- // `find` defaults to cwd if no path is given. We collect everything
593
- // Path-shaped in the prefix region as candidates.
594
- const pickFindSearchRoot = (tokens: readonly string[], execFlagIdx: number): string => {
595
- const candidates: string[] = [];
596
- for (let i = 1; i < execFlagIdx; i++) {
597
- const t = tokens[i]!;
598
- if (t.startsWith('-')) {
599
- break;
600
- }
601
- if (pathLikeToken(t)) {
602
- candidates.push(t);
603
- }
604
- }
605
- if (candidates.length === 0) {
606
- return '.';
607
- }
608
- candidates.sort((a, b) => pathDangerScore(b) - pathDangerScore(a));
609
- return candidates[0]!;
610
- };
611
-
612
- const FD_SPEC: ExecSpec = {
613
- execFlags: new Set(['-x', '-X', '--exec', '--exec-batch']),
614
- placeholders: new Set(['{}', '{/}', '{//}', '{.}', '{/.}']),
615
- pickRoot: pickFdSearchRoot,
616
- };
617
-
618
- const FIND_SPEC: ExecSpec = {
619
- // `-ok` / `-okdir` prompt interactively per-match, but the executed
620
- // Command is still constructed from agent-controlled input, so treat
621
- // It the same as `-exec`.
622
- execFlags: new Set(['-exec', '-execdir', '-ok', '-okdir']),
623
- placeholders: new Set(['{}']),
624
- pickRoot: pickFindSearchRoot,
625
- };
626
-
627
- const EXEC_SPECS: Readonly<Record<string, ExecSpec>> = Object.assign(
628
- Object.create(null) as Record<string, ExecSpec>,
629
- { fd: FD_SPEC, fdfind: FD_SPEC, find: FIND_SPEC, gfind: FIND_SPEC },
630
- );
631
-
632
- const substitutePlaceholders = (
633
- tokens: readonly string[],
634
- spec: ExecSpec,
635
- root: string,
636
- ): string[] => tokens.map((t) => (spec.placeholders.has(t) ? root : t));
637
-
638
- const extractExecCommands = (seg: Segment): string[] => {
639
- const spec = EXEC_SPECS[seg.head];
640
- if (spec === undefined) {
641
- return [];
642
- }
643
- const out: string[] = [];
644
- const tokens = seg.tokens;
645
- for (let i = 1; i < tokens.length; i++) {
646
- if (!spec.execFlags.has(tokens[i]!)) {
647
- continue;
648
- }
649
- // Collect tokens until the exec terminator (`;` or `+`, both shared
650
- // By fd and find) or end of segment. shell-quote turns `\;` into the
651
- // Literal string token `;`.
652
- const inner: string[] = [];
653
- let j = i + 1;
654
- while (j < tokens.length) {
655
- const t = tokens[j]!;
656
- if (t === ';' || t === '+') {
657
- break;
658
- }
659
- inner.push(t);
660
- j++;
661
- }
662
- if (inner.length === 0) {
663
- continue;
664
- }
665
- const head = inner[0]!;
666
- if (spec.placeholders.has(head)) {
667
- continue;
668
- }
669
- const root = spec.pickRoot(tokens, i);
670
- out.push(quote(substitutePlaceholders(inner, spec, root)));
671
- i = j;
672
- }
673
- return out;
674
- };
675
-
676
- // ── Substitution unwrappers ──────────────────────────────────────────
677
- //
678
- // Bash hides agent-controlled content inside command substitutions and
679
- // Shell wrappers two different ways, and rules need two different shapes
680
- // Of unwrap:
681
- //
682
- // 1. `sh -c '<script>'` and `bash -c '<script>'` carry a script the
683
- // Outer parser can't see into. Extracted with
684
- // `extractShellWrappedCommands(seg)` and re-parsed as bash segments
685
- // So every existing rule applies. Used by `parseCommand`.
686
- //
687
- // 2. `$(cat <<'TAG' ... TAG)` and `$(echo '...')` / `$(printf '...')`
688
- // Compute a static string value at runtime. Rules that inspect arg
689
- // Values (commit-message convention, redirect targets, etc.) need
690
- // The string, not a re-parse. `unwrapStaticString(token)` returns
691
- // It; pass-through if the token isn't a recognised substitution.
692
- //
693
- // Both layers cover the same underlying gap — the shell-quote parser
694
- // Treats substitutions as opaque sentinels — and any rule that touches
695
- // Agent-controlled content should route through one of them.
696
-
697
- const HEREDOC_SUBST_RE =
698
- /\$\(\s*cat\s+<<-?\s*['"]?(?<delimiter>\w+)['"]?\s*\n(?<body>[\s\S]*?)\n\s*\k<delimiter>\s*\)/u;
699
- const ECHO_PRINTF_SUBST_RE = /\$\(\s*(?:printf|echo)\s+(?:-[a-zA-Z]+\s+)*'(?<content>[^']*)'/u;
700
-
701
- const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, string>): string => {
702
- const heredoc = HEREDOC_SUBST_RE.exec(value);
703
- if (heredoc !== null) {
704
- const captured = heredoc.groups?.['body'] ?? value;
705
- if (heredocBodies !== undefined && captured.trim() === '__HEREDOC_BODY__') {
706
- const delimiter = heredoc.groups?.['delimiter'];
707
- const real = delimiter === undefined ? undefined : heredocBodies.get(delimiter);
708
- if (real !== undefined) {
709
- return real;
710
- }
711
- }
712
- return captured;
713
- }
714
- const printf = ECHO_PRINTF_SUBST_RE.exec(value);
715
- if (printf !== null) {
716
- return printf.groups?.['content'] ?? value;
717
- }
718
- return value;
719
- };
720
-
721
- // Recover commands hidden inside a `sh -c '...'` / `bash -c '...'` wrapper.
722
- // Without this, every redirect / deny / scoped-rm rule can be trivially
723
- // Bypassed by wrapping the offending command in `sh -c`. The shell parser
724
- // Otherwise sees `sh` as the head and the script as an opaque positional
725
- // Arg. We pull the script out and feed it back through `parseCommand` so
726
- // All existing rules apply.
727
- // Head normalisation in `parseSegment` strips directory prefixes, so this set
728
- // Only needs bare basenames — `/bin/bash` etc. are now unreachable as heads.
729
- const SHELL_WRAPPER_HEADS: ReadonlySet<string> = new Set([
730
- 'sh',
731
- 'bash',
732
- 'zsh',
733
- 'dash',
734
- 'ksh',
735
- 'ash',
736
- ]);
737
-
738
- const extractShellWrappedCommands = (seg: Segment): string[] => {
739
- if (!SHELL_WRAPPER_HEADS.has(seg.head)) {
740
- return [];
741
- }
742
- const tokens = seg.tokens;
743
- for (let i = 1; i < tokens.length; i++) {
744
- const t = tokens[i]!;
745
- if (t === '-c' && i + 1 < tokens.length) {
746
- return [tokens[i + 1]!];
747
- }
748
- // Combined short flags that include `c`: `-ec`, `-xc`, `-eu c` won't —
749
- // Only treat `c` as the last char so the next token is the script.
750
- if (
751
- t.startsWith('-') &&
752
- !t.startsWith('--') &&
753
- t.endsWith('c') &&
754
- t.length > 2 &&
755
- i + 1 < tokens.length
756
- ) {
757
- return [tokens[i + 1]!];
758
- }
759
- }
760
- return [];
761
- };
762
-
763
- // Heads that take `[flags] <command> [args]` on the same arg vector: the
764
- // First non-flag token after the prefix is the real command. `sudo`/`doas`
765
- // (privilege escalation), `xargs` (stdin-driven exec), and `watch` (repeated
766
- // Exec) all hide a sibling command this way, so the same unwrap that handles
767
- // `command`/`env`/`nohup` applies. `sudo` is also matched by bash-deny's
768
- // `ask` rule on the outer segment — both fire, and the more-restrictive
769
- // Interior decision (e.g. `sudo rm -rf /` → deny) wins on merge.
770
- const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
771
- 'command',
772
- 'exec',
773
- 'env',
774
- 'time',
775
- 'nohup',
776
- 'setsid',
777
- 'nice',
778
- 'ionice',
779
- 'chronic',
780
- 'stdbuf',
781
- 'unbuffer',
782
- 'script',
783
- 'taskset',
784
- 'sudo',
785
- 'doas',
786
- 'xargs',
787
- 'watch',
788
- ]);
789
-
790
- const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
791
- command: new Set(),
792
- env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string', '--block-signal']),
793
- time: new Set(['-f', '--format', '-o', '--output']),
794
- nice: new Set(['-n', '--adjustment']),
795
- ionice: new Set(['-c', '--class', '-n', '--classdata', '-p', '--pid']),
796
- stdbuf: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
797
- script: new Set(['-c', '--command']),
798
- taskset: new Set(),
799
- sudo: new Set([
800
- '-u',
801
- '--user',
802
- '-g',
803
- '--group',
804
- '-C',
805
- '--close-from',
806
- '-D',
807
- '--chdir',
808
- '-h',
809
- '--host',
810
- '-p',
811
- '--prompt',
812
- '-r',
813
- '--role',
814
- '-t',
815
- '--type',
816
- '-U',
817
- '--other-user',
818
- '-R',
819
- '--chroot',
820
- '-T',
821
- '--command-timeout',
822
- ]),
823
- doas: new Set(['-a', '-C', '-u']),
824
- xargs: new Set([
825
- '-I',
826
- '-i',
827
- '-J',
828
- '-n',
829
- '--max-args',
830
- '-P',
831
- '--max-procs',
832
- '-s',
833
- '--max-chars',
834
- '-L',
835
- '--max-lines',
836
- '-E',
837
- '--eof',
838
- '-d',
839
- '--delimiter',
840
- '-a',
841
- '--arg-file',
842
- '--replace',
843
- ]),
844
- watch: new Set(['-n', '--interval']),
845
- };
846
-
847
- const tokenLooksLikeEnvAssignment = (token: string): boolean =>
848
- /^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(token);
849
-
850
- const skipHeadRenamingPrefix = (tokens: readonly string[]): number => {
851
- const head = tokens[0]!;
852
- const valueFlags = HEAD_RENAMING_VALUE_FLAGS[head] ?? new Set<string>();
853
- let i = 1;
854
- while (i < tokens.length) {
855
- const token = tokens[i]!;
856
- if (head === 'env' && tokenLooksLikeEnvAssignment(token)) {
857
- i++;
858
- continue;
859
- }
860
- if (valueFlags.has(token)) {
861
- i += 2;
862
- continue;
863
- }
864
- if (token.includes('=') && valueFlags.has(token.slice(0, token.indexOf('=')))) {
865
- i++;
866
- continue;
867
- }
868
- if (token.startsWith('--') && token !== '--') {
869
- i++;
870
- continue;
871
- }
872
- if (token.startsWith('-') && token !== '-') {
873
- i++;
874
- continue;
875
- }
876
- break;
877
- }
878
- return i;
879
- };
880
-
881
- const extractHeadRenamingCommands = (seg: Segment): string[] => {
882
- if (!HEAD_RENAMING_HEADS.has(seg.head)) {
883
- return [];
884
- }
885
- if (seg.head === 'script') {
886
- for (let i = 1; i < seg.tokens.length - 1; i++) {
887
- const token = seg.tokens[i]!;
888
- if (token === '-c' || token === '--command') {
889
- return [seg.tokens[i + 1]!];
890
- }
891
- if (token.startsWith('--command=')) {
892
- return [token.slice('--command='.length)];
893
- }
894
- }
895
- }
896
- const start = skipHeadRenamingPrefix(seg.tokens);
897
- const inner = quote(seg.tokens.slice(start));
898
- return inner === '' ? [] : [inner];
899
- };
900
-
901
- const EVAL_HEADS: ReadonlySet<string> = new Set(['eval']);
902
-
903
- const extractEvalCommands = (seg: Segment): string[] => {
904
- if (!EVAL_HEADS.has(seg.head)) {
905
- return [];
906
- }
907
- if (seg.tokens.length === 2) {
908
- return [seg.tokens[1]!];
909
- }
910
- const sub = quote(seg.tokens.slice(1));
911
- return sub === '' ? [] : [sub];
912
- };
913
-
914
- // ── rtk (token-optimizing CLI proxy) ─────────────────────────────────
915
- // Rtk wraps real commands so their output is filtered before reaching the
916
- // Agent's context, and Codex auto-prepends it. The wrapper hides the real
917
- // Command from every rule: `rtk proxy rm -rf /` parses with head `rtk` and
918
- // The destructive `rm` buried in opaque positional args. Strip the `rtk`
919
- // Prefix and reconstruct the interior command so the existing rules decide
920
- // On what actually runs.
921
- //
922
- // Grammar: `rtk [global-opts] <subcommand> [args]`. Two subcommand classes
923
- // Exec a sibling command:
924
- // • wrapper subs — the keyword is dropped, the remainder is an arbitrary
925
- // Command: `run` (also `-c <string>`), `proxy`, `err`, `test`, `summary`.
926
- // • tool-proxy subs — the keyword *is* the binary: `git`, `find`, `npm`,
927
- // `docker`, … Reconstructing from the subcommand onward yields the real
928
- // Invocation (`git push …`, `find … -delete`). rtk-internal filters that
929
- // Aren't real binaries (`gain`, `config`, `diff`, …) reconstruct to inert
930
- // Heads no rule matches, so no allowlist is needed.
931
- const RTK_WRAPPER_SUBCOMMANDS: ReadonlySet<string> = new Set([
932
- 'run',
933
- 'proxy',
934
- 'err',
935
- 'test',
936
- 'summary',
937
- ]);
938
-
939
- // Head normalisation in `parseSegment` strips directory prefixes, so only the
940
- // Bare basename is needed — the `endsWith` fallbacks are now unreachable.
941
- const isRtkHead = (head: string): boolean => head === 'rtk';
942
-
943
- const skipRtkGlobalFlags = (tokens: readonly string[]): number => {
944
- // Rtk's global options (`-v`/`-vv`/`--verbose`, `--ultra-compact`,
945
- // `--skip-env`) are all boolean, so any leading flag token can be skipped.
946
- let i = 1;
947
- while (i < tokens.length) {
948
- const t = tokens[i]!;
949
- if (t.startsWith('-') && t !== '-' && t !== '--') {
950
- i++;
951
- continue;
952
- }
953
- break;
954
- }
955
- return i;
956
- };
957
-
958
- const dashCommandArg = (tokens: readonly string[], start: number): string | null => {
959
- for (let k = start; k < tokens.length; k++) {
960
- const t = tokens[k]!;
961
- if ((t === '-c' || t === '--command') && k + 1 < tokens.length) {
962
- return tokens[k + 1]!;
963
- }
964
- if (t.startsWith('--command=')) {
965
- return t.slice('--command='.length);
966
- }
967
- }
968
- return null;
969
- };
970
-
971
- const extractRtkCommands = (seg: Segment): string[] => {
972
- if (!isRtkHead(seg.head)) {
973
- return [];
974
- }
975
- const subIdx = skipRtkGlobalFlags(seg.tokens);
976
- const sub = seg.tokens[subIdx];
977
- if (sub === undefined) {
978
- return [];
979
- }
980
- if (RTK_WRAPPER_SUBCOMMANDS.has(sub)) {
981
- // `rtk run -c '<cmd>'` carries the command in a flag value.
982
- const viaFlag = dashCommandArg(seg.tokens, subIdx + 1);
983
- if (viaFlag !== null) {
984
- return viaFlag === '' ? [] : [viaFlag];
985
- }
986
- // Otherwise the command is the positional remainder after the keyword,
987
- // Skipping any wrapper-local boolean flags.
988
- let j = subIdx + 1;
989
- while (j < seg.tokens.length) {
990
- const t = seg.tokens[j]!;
991
- if (t === '--') {
992
- j++;
993
- break;
994
- }
995
- if (t.startsWith('-') && t !== '-') {
996
- j++;
997
- continue;
998
- }
999
- break;
1000
- }
1001
- const inner = quote(seg.tokens.slice(j));
1002
- return inner === '' ? [] : [inner];
1003
- }
1004
- // Tool-proxy subcommand: the subcommand token is the real binary name.
1005
- const inner = quote(seg.tokens.slice(subIdx));
1006
- return inner === '' ? [] : [inner];
1007
- };
1008
-
1009
- // ── Positional-prefix wrappers ───────────────────────────────────────
1010
- // Heads where the real command follows one or more positional arguments
1011
- // The wrapper consumes itself: `timeout <duration> <cmd>`, `chroot <newroot>
1012
- // <cmd>`, `flock <lockfile> <cmd>` (or `flock <lockfile> -c '<cmd>'`), and
1013
- // `su [user] -c '<cmd>'`. Skip the flag region, drop the wrapper's own
1014
- // Positionals, and the remainder is the command.
1015
- interface PrefixWrapperSpec {
1016
- readonly valueFlags: ReadonlySet<string>;
1017
- // Positional args the wrapper consumes before the command (duration, newroot…).
1018
- readonly skipPositionals: number;
1019
- // Whether the command can also arrive via `-c <string>` (su, flock).
1020
- readonly dashCommand: boolean;
1021
- }
1022
-
1023
- const PREFIX_WRAPPER_SPECS: Readonly<Record<string, PrefixWrapperSpec>> = {
1024
- timeout: {
1025
- valueFlags: new Set(['-s', '--signal', '-k', '--kill-after']),
1026
- skipPositionals: 1,
1027
- dashCommand: false,
1028
- },
1029
- gtimeout: {
1030
- valueFlags: new Set(['-s', '--signal', '-k', '--kill-after']),
1031
- skipPositionals: 1,
1032
- dashCommand: false,
1033
- },
1034
- chroot: {
1035
- valueFlags: new Set(['--userspec', '--groups']),
1036
- skipPositionals: 1,
1037
- dashCommand: false,
1038
- },
1039
- flock: {
1040
- valueFlags: new Set(['-w', '--wait', '--timeout', '-E', '--conflict-exit-code']),
1041
- skipPositionals: 1,
1042
- dashCommand: true,
1043
- },
1044
- su: { valueFlags: new Set(), skipPositionals: 0, dashCommand: true },
1045
- };
1046
-
1047
- const extractPrefixWrapperCommands = (seg: Segment): string[] => {
1048
- const spec = PREFIX_WRAPPER_SPECS[seg.head];
1049
- if (spec === undefined) {
1050
- return [];
1051
- }
1052
- if (spec.dashCommand) {
1053
- const viaFlag = dashCommandArg(seg.tokens, 1);
1054
- if (viaFlag !== null) {
1055
- return viaFlag === '' ? [] : [viaFlag];
1056
- }
1057
- }
1058
- let i = 1;
1059
- while (i < seg.tokens.length) {
1060
- const t = seg.tokens[i]!;
1061
- if (spec.valueFlags.has(t)) {
1062
- i += 2;
1063
- continue;
1064
- }
1065
- if (t.includes('=') && spec.valueFlags.has(t.slice(0, t.indexOf('=')))) {
1066
- i++;
1067
- continue;
1068
- }
1069
- if (t === '--') {
1070
- i++;
1071
- break;
1072
- }
1073
- if (t.startsWith('-') && t !== '-') {
1074
- i++;
1075
- continue;
1076
- }
1077
- break;
1078
- }
1079
- i += spec.skipPositionals;
1080
- const inner = quote(seg.tokens.slice(i));
1081
- return inner === '' ? [] : [inner];
1082
- };
1083
-
1084
- // Each extractor pulls the inner command(s) a wrapper hides on its own arg
1085
- // Vector, to be re-parsed as additional segments so every rule sees what
1086
- // Actually runs. Order is irrelevant — all results are unioned into `out`.
1087
- const SEGMENT_EXTRACTORS: readonly ((seg: Segment) => string[])[] = [
1088
- extractExecCommands,
1089
- extractShellWrappedCommands,
1090
- extractHeadRenamingCommands,
1091
- extractEvalCommands,
1092
- extractRtkCommands,
1093
- extractPrefixWrapperCommands,
1094
- ];
1095
-
1096
- const normalizeTopLevelNewlines = (cmd: string): string => {
1097
- let out = '';
1098
- let inSingle = false;
1099
- let inDouble = false;
1100
- let escaped = false;
1101
-
1102
- for (const ch of cmd) {
1103
- if (escaped) {
1104
- out += ch;
1105
- escaped = false;
1106
- continue;
1107
- }
1108
- if (ch === '\\') {
1109
- out += ch;
1110
- escaped = true;
1111
- continue;
1112
- }
1113
- if (ch === "'" && !inDouble) {
1114
- inSingle = !inSingle;
1115
- out += ch;
1116
- continue;
1117
- }
1118
- if (ch === '"' && !inSingle) {
1119
- inDouble = !inDouble;
1120
- out += ch;
1121
- continue;
1122
- }
1123
- out += ch === '\n' && !inSingle && !inDouble ? ' ; ' : ch;
1124
- }
1125
- return out;
1126
- };
1127
-
1128
- const parseCommand = (cmd: string): Segment[] => {
1129
- let entries: ParseEntry[];
1130
- const cmdForParsing = normalizeTopLevelNewlines(maskLiteralHeredocBodies(cmd));
1131
- try {
1132
- entries = parse(cmdForParsing, PRESERVE_ENV);
1133
- } catch {
1134
- return [];
1135
- }
1136
- entries = mergeAmpRedirects(entries);
1137
- const fdBudget: FdBudget = { remaining: countFdPrefixRedirects(cmdForParsing) };
1138
-
1139
- const out: Segment[] = [];
1140
- let buf: ParseEntry[] = [];
1141
- for (const e of entries) {
1142
- const op = getOp(e);
1143
- if (op !== null && SEGMENT_OPS.has(op)) {
1144
- const seg = parseSegment(buf, fdBudget);
1145
- if (seg !== null) {
1146
- out.push(seg);
1147
- }
1148
- buf = [];
1149
- continue;
1150
- }
1151
- buf.push(e);
1152
- }
1153
- const seg = parseSegment(buf, fdBudget);
1154
- if (seg !== null) {
1155
- out.push(seg);
1156
- }
1157
-
1158
- // Recursively analyze any embedded commands as additional segments. The
1159
- // Outer segment's args are already opaque sentinels (safe-path-failing);
1160
- // This catches dangerous inner commands the outer call would otherwise
1161
- // Hide.
1162
- for (const sub of [...extractInnerCommands(cmd), ...extractShellHeredocCommands(cmd)]) {
1163
- for (const innerSeg of parseCommand(sub)) {
1164
- out.push(innerSeg);
1165
- }
1166
- }
1167
-
1168
- // Tools like `fd -x …` and `find -exec …` carry an inner subcommand
1169
- // On the same arg vector. Without extraction the executed command is
1170
- // Hidden and slips past every rule. Pull it out (with the user's
1171
- // Search root substituted into placeholders) and parse it as its own
1172
- // Segments so bash-deny et al. see it.
1173
- // Snapshot length: we push new segments into `out` from within the
1174
- // Loop, but should only scan the segments that existed pre-extraction
1175
- // To avoid re-processing extracted ones.
1176
- const preExtractLen = out.length;
1177
- for (let k = 0; k < preExtractLen; k++) {
1178
- const seg = out[k]!;
1179
- for (const extract of SEGMENT_EXTRACTORS) {
1180
- for (const sub of extract(seg)) {
1181
- for (const innerSeg of parseCommand(sub)) {
1182
- out.push(innerSeg);
1183
- }
1184
- }
1185
- }
1186
- }
1187
-
1188
- return out;
1189
- };
1190
-
1191
- const stripLeadingDotSlash = (p: string): string => (p.startsWith('./') ? p.slice(2) : p);
1192
-
1193
- const isSafePathTarget = (
1194
- raw: string,
1195
- extraRelative: readonly string[] = [],
1196
- extraAbsolute: readonly string[] = [],
1197
- ): boolean => {
1198
- if (raw === '') {
1199
- return false;
1200
- }
1201
- const t = stripLeadingDotSlash(raw);
1202
- if (t === '..' || t.startsWith('../') || t.includes('/../')) {
1203
- return false;
1204
- }
1205
- for (const abs of [...SAFE_ABSOLUTE, ...extraAbsolute]) {
1206
- if (t === abs || t.startsWith(`${abs}/`)) {
1207
- return true;
1208
- }
1209
- }
1210
- for (const rel of [...SAFE_RELATIVE, ...extraRelative]) {
1211
- if (t === rel || t.startsWith(`${rel}/`)) {
1212
- return true;
1213
- }
1214
- }
1215
- return false;
1216
- };
1217
-
1218
- const safeScopesSummary = (
1219
- extraRelative: readonly string[] = [],
1220
- extraAbsolute: readonly string[] = [],
1221
- ): string => {
1222
- const groups: Record<string, readonly string[]> = {
1223
- 'build outputs': ['dist', 'build', '_build', 'out', 'target'],
1224
- 'js framework outputs': [
1225
- '.next',
1226
- '.nuxt',
1227
- '.svelte-kit',
1228
- '.output',
1229
- '.astro',
1230
- '.angular',
1231
- '.vite',
1232
- '.parcel-cache',
1233
- '.turbo',
1234
- '.vercel',
1235
- '.netlify',
1236
- '.fly',
1237
- '.wrangler',
1238
- '.serverless',
1239
- ],
1240
- 'tests / coverage': ['coverage', '.nyc_output'],
1241
- caches: ['.cache', '.ruff_cache', '.mypy_cache', '.pytest_cache', '.ty_cache', '.tox'],
1242
- 'language / package': [
1243
- '__pycache__',
1244
- '.venv',
1245
- 'venv',
1246
- 'node_modules',
1247
- '.gradle',
1248
- 'DerivedData',
1249
- '.bundle',
1250
- '.cargo-target',
1251
- ],
1252
- 'tmp / state': ['tmp', '.tmp', '.state', ...SAFE_ABSOLUTE],
1253
- iac: ['.terraform'],
1254
- 'bundler dev': ['.yarn/cache', '.yarn/install-state.gz', '.pnpm-store', '.bun'],
1255
- };
1256
- if (extraRelative.length > 0) {
1257
- groups['custom relative'] = extraRelative;
1258
- }
1259
- if (extraAbsolute.length > 0) {
1260
- groups['custom absolute'] = extraAbsolute;
1261
- }
1262
- return Object.entries(groups)
1263
- .map(([k, v]) => ` ${k}: ${v.join(', ')}`)
1264
- .join('\n');
1265
- };
1266
-
1267
- // Mask heredoc bodies before scanning, otherwise a `# tripwire-allow`
1268
- // Smuggled inside a heredoc body (e.g. a commit message piped via
1269
- // `$(cat <<EOF ... EOF)`) disarms every rule for the surrounding command.
1270
- // A legitimate bypass marker sits on the actual command line, which the
1271
- // Mask leaves intact.
1272
- const hasBypass = (cmd: string): boolean =>
1273
- /(?<prefix>^|\s)#\s*tripwire-allow\b/.test(maskLiteralHeredocBodies(cmd));
1274
-
1275
- export type { Redirect, Segment };
1276
- export {
1277
- EXEC_SPECS,
1278
- collectHeredocBodies,
1279
- hasBypass,
1280
- isSafePathTarget,
1281
- parseCommand,
1282
- safeScopesSummary,
1283
- unwrapStaticString,
1284
- };