@seanmozeik/tripwire 0.4.0 → 0.4.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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanmozeik/tripwire",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
5
5
  "license": "MIT",
6
6
  "bin": {
package/src/lib/bash.ts CHANGED
@@ -304,6 +304,220 @@ const extractInnerCommands = (cmd: string): string[] => {
304
304
  return inner;
305
305
  };
306
306
 
307
+ // ── Exec-flag extraction (fd -x, find -exec, etc.) ───────────────────
308
+ // Tools that take a subcommand on the same arg vector hide that
309
+ // Subcommand from rule analysis. Pull it out, substitute the user-
310
+ // Provided search root into the placeholder(s), and feed the
311
+ // Reconstructed command back through parseCommand so every existing
312
+ // Bash rule (deny / scoped-rm / redirect / etc.) sees it.
313
+
314
+ const HOME_VAR_RE = /^\$\{?HOME\}?(?:\/|$)/;
315
+
316
+ const pathLikeToken = (t: string): boolean => {
317
+ if (t === '' || t === '-') {
318
+ return false;
319
+ }
320
+ if (t === '/' || t === '~' || t === '.' || t === '..') {
321
+ return true;
322
+ }
323
+ if (
324
+ t.startsWith('/') ||
325
+ t.startsWith('~') ||
326
+ t.startsWith('./') ||
327
+ t.startsWith('../') ||
328
+ HOME_VAR_RE.test(t)
329
+ ) {
330
+ return true;
331
+ }
332
+ return false;
333
+ };
334
+
335
+ // Rank candidate search roots by how dangerous a `cmd <root>` invocation
336
+ // Would be. Higher wins.
337
+ const pathDangerScore = (t: string): number => {
338
+ if (t === '/') {
339
+ return 100;
340
+ }
341
+ if (t === '~' || HOME_VAR_RE.test(t)) {
342
+ return 90;
343
+ }
344
+ if (/^\/(etc|usr|bin|sbin|System|Library|var|boot|root|home)(\/|$)/.test(t)) {
345
+ return 80;
346
+ }
347
+ if (t.startsWith('/Users/')) {
348
+ return 70;
349
+ }
350
+ if (t.startsWith('/') || t.startsWith('~')) {
351
+ return 60;
352
+ }
353
+ if (t.startsWith('../')) {
354
+ return 40;
355
+ }
356
+ if (t === '..' || t === '.' || t.startsWith('./')) {
357
+ return 10;
358
+ }
359
+ return 50;
360
+ };
361
+
362
+ interface ExecSpec {
363
+ // Flag tokens that introduce a nested command, e.g. `-x` / `-exec`.
364
+ readonly execFlags: ReadonlySet<string>;
365
+ // Placeholder tokens the tool substitutes with each match path.
366
+ readonly placeholders: ReadonlySet<string>;
367
+ // Walk tokens[1..execFlagIdx) and return the most-suspicious search root
368
+ // The tool would feed into placeholders, or `.` if nothing path-shaped
369
+ // Is present.
370
+ readonly pickRoot: (tokens: readonly string[], execFlagIdx: number) => string;
371
+ }
372
+
373
+ // Fd's flag layout: flags can appear before or after the pattern/path
374
+ // Positionals, and some flags consume a value (-e ts, -t f, -d 3). We
375
+ // Need to skip those value tokens, otherwise `ts` is misread as a path.
376
+ const FD_VALUE_FLAGS: ReadonlySet<string> = new Set([
377
+ '-e',
378
+ '--extension',
379
+ '-t',
380
+ '--type',
381
+ '-E',
382
+ '--exclude',
383
+ '-d',
384
+ '--max-depth',
385
+ '--min-depth',
386
+ '--exact-depth',
387
+ '-c',
388
+ '--color',
389
+ '--changed-within',
390
+ '--changed-before',
391
+ '-S',
392
+ '--size',
393
+ '-o',
394
+ '--owner',
395
+ '-j',
396
+ '--threads',
397
+ '-g',
398
+ '--glob',
399
+ '--format',
400
+ '--max-results',
401
+ '--ignore-file',
402
+ '--search-path',
403
+ '--base-directory',
404
+ '--path-separator',
405
+ '--and',
406
+ ]);
407
+
408
+ const pickFdSearchRoot = (tokens: readonly string[], execFlagIdx: number): string => {
409
+ const candidates: string[] = [];
410
+ let i = 1;
411
+ while (i < execFlagIdx) {
412
+ const t = tokens[i]!;
413
+ if (FD_VALUE_FLAGS.has(t)) {
414
+ i += 2;
415
+ continue;
416
+ }
417
+ if (t.startsWith('-')) {
418
+ i++;
419
+ continue;
420
+ }
421
+ if (pathLikeToken(t)) {
422
+ candidates.push(t);
423
+ }
424
+ i++;
425
+ }
426
+ if (candidates.length === 0) {
427
+ return '.';
428
+ }
429
+ candidates.sort((a, b) => pathDangerScore(b) - pathDangerScore(a));
430
+ return candidates[0]!;
431
+ };
432
+
433
+ // Find's grammar: PATHs come first, before any flag-shaped token. Once we
434
+ // Hit a `-`-prefixed token (a test predicate or action), no more paths.
435
+ // `find` defaults to cwd if no path is given. We collect everything
436
+ // Path-shaped in the prefix region as candidates.
437
+ const pickFindSearchRoot = (tokens: readonly string[], execFlagIdx: number): string => {
438
+ const candidates: string[] = [];
439
+ for (let i = 1; i < execFlagIdx; i++) {
440
+ const t = tokens[i]!;
441
+ if (t.startsWith('-')) {
442
+ break;
443
+ }
444
+ if (pathLikeToken(t)) {
445
+ candidates.push(t);
446
+ }
447
+ }
448
+ if (candidates.length === 0) {
449
+ return '.';
450
+ }
451
+ candidates.sort((a, b) => pathDangerScore(b) - pathDangerScore(a));
452
+ return candidates[0]!;
453
+ };
454
+
455
+ const FD_SPEC: ExecSpec = {
456
+ execFlags: new Set(['-x', '-X', '--exec', '--exec-batch']),
457
+ placeholders: new Set(['{}', '{/}', '{//}', '{.}', '{/.}']),
458
+ pickRoot: pickFdSearchRoot,
459
+ };
460
+
461
+ const FIND_SPEC: ExecSpec = {
462
+ // `-ok` / `-okdir` prompt interactively per-match, but the executed
463
+ // Command is still constructed from agent-controlled input, so treat
464
+ // It the same as `-exec`.
465
+ execFlags: new Set(['-exec', '-execdir', '-ok', '-okdir']),
466
+ placeholders: new Set(['{}']),
467
+ pickRoot: pickFindSearchRoot,
468
+ };
469
+
470
+ const EXEC_SPECS: Readonly<Record<string, ExecSpec>> = {
471
+ fd: FD_SPEC,
472
+ fdfind: FD_SPEC,
473
+ find: FIND_SPEC,
474
+ gfind: FIND_SPEC,
475
+ };
476
+
477
+ const substitutePlaceholders = (
478
+ tokens: readonly string[],
479
+ spec: ExecSpec,
480
+ root: string,
481
+ ): string[] => tokens.map((t) => (spec.placeholders.has(t) ? root : t));
482
+
483
+ const extractExecCommands = (seg: Segment): string[] => {
484
+ const spec = EXEC_SPECS[seg.head];
485
+ if (spec === undefined) {
486
+ return [];
487
+ }
488
+ const out: string[] = [];
489
+ const tokens = seg.tokens;
490
+ for (let i = 1; i < tokens.length; i++) {
491
+ if (!spec.execFlags.has(tokens[i]!)) {
492
+ continue;
493
+ }
494
+ // Collect tokens until the exec terminator (`;` or `+`, both shared
495
+ // By fd and find) or end of segment. shell-quote turns `\;` into the
496
+ // Literal string token `;`.
497
+ const inner: string[] = [];
498
+ let j = i + 1;
499
+ while (j < tokens.length) {
500
+ const t = tokens[j]!;
501
+ if (t === ';' || t === '+') {
502
+ break;
503
+ }
504
+ inner.push(t);
505
+ j++;
506
+ }
507
+ if (inner.length === 0) {
508
+ continue;
509
+ }
510
+ const head = inner[0]!;
511
+ if (spec.placeholders.has(head)) {
512
+ continue;
513
+ }
514
+ const root = spec.pickRoot(tokens, i);
515
+ out.push(substitutePlaceholders(inner, spec, root).join(' '));
516
+ i = j;
517
+ }
518
+ return out;
519
+ };
520
+
307
521
  const parseCommand = (cmd: string): Segment[] => {
308
522
  let entries: ParseEntry[];
309
523
  try {
@@ -343,6 +557,24 @@ const parseCommand = (cmd: string): Segment[] => {
343
557
  }
344
558
  }
345
559
 
560
+ // Tools like `fd -x …` and `find -exec …` carry an inner subcommand
561
+ // On the same arg vector. Without extraction the executed command is
562
+ // Hidden and slips past every rule. Pull it out (with the user's
563
+ // Search root substituted into placeholders) and parse it as its own
564
+ // Segments so bash-deny et al. see it.
565
+ // Snapshot length: we push new segments into `out` from within the
566
+ // Loop, but should only scan the segments that existed pre-extraction
567
+ // To avoid re-processing extracted ones.
568
+ const preExtractLen = out.length;
569
+ for (let k = 0; k < preExtractLen; k++) {
570
+ const seg = out[k]!;
571
+ for (const sub of extractExecCommands(seg)) {
572
+ for (const innerSeg of parseCommand(sub)) {
573
+ out.push(innerSeg);
574
+ }
575
+ }
576
+ }
577
+
346
578
  return out;
347
579
  };
348
580