@seanmozeik/tripwire 0.7.0 → 0.7.2

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