@seanmozeik/tripwire 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanmozeik/tripwire",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -31,15 +31,15 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@effect/platform-bun": "^4.0.0-beta.65",
35
- "effect": "^4.0.0-beta.65",
34
+ "@effect/platform-bun": "^4.0.0-beta.66",
35
+ "effect": "^4.0.0-beta.66",
36
36
  "shell-quote": "^1.8.3"
37
37
  },
38
38
  "devDependencies": {
39
- "@types/bun": "^1.3.13",
39
+ "@types/bun": "^1.3.14",
40
40
  "@types/shell-quote": "^1.7.5",
41
- "oxfmt": "^0.48.0",
42
- "oxlint": "^1.61.0",
41
+ "oxfmt": "^0.50.0",
42
+ "oxlint": "^1.65.0",
43
43
  "oxlint-tsgolint": "^0.22.1",
44
44
  "typescript": "^6.0.3"
45
45
  },
package/src/lib/bash.ts CHANGED
@@ -256,6 +256,60 @@ const countFdPrefixRedirects = (cmd: string): number => {
256
256
  return matches?.length ?? 0;
257
257
  };
258
258
 
259
+ const heredocDelimiterFromLine = (line: string): string | null => {
260
+ const match = /<<-?\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z_][A-Za-z0-9_]*))/u.exec(line);
261
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
262
+ };
263
+
264
+ const SHELL_STDIN_HEAD_RE =
265
+ /(?:^|[|;&]\s*)(?:\/(?:usr\/bin|bin|usr\/local\/bin|opt\/homebrew\/bin)\/)?(?:sh|bash|zsh|dash|ksh|ash)(?:\s|$)/u;
266
+
267
+ const heredocFeedsShell = (line: string): boolean => SHELL_STDIN_HEAD_RE.test(line);
268
+
269
+ const maskLiteralHeredocBodies = (cmd: string): string => {
270
+ const lines = cmd.split('\n');
271
+ const out: string[] = [];
272
+ for (let i = 0; i < lines.length; i++) {
273
+ const line = lines[i]!;
274
+ out.push(line);
275
+ const delimiter = heredocDelimiterFromLine(line);
276
+ if (delimiter === null || heredocFeedsShell(line)) {
277
+ continue;
278
+ }
279
+ i++;
280
+ while (i < lines.length && lines[i]!.trim() !== delimiter) {
281
+ i++;
282
+ }
283
+ if (i < lines.length) {
284
+ out.push('__HEREDOC_BODY__');
285
+ out.push(lines[i]!);
286
+ }
287
+ }
288
+ return out.join('\n');
289
+ };
290
+
291
+ const extractShellHeredocCommands = (cmd: string): string[] => {
292
+ const lines = cmd.split('\n');
293
+ const out: string[] = [];
294
+ for (let i = 0; i < lines.length; i++) {
295
+ const line = lines[i]!;
296
+ const delimiter = heredocDelimiterFromLine(line);
297
+ if (delimiter === null || !heredocFeedsShell(line)) {
298
+ continue;
299
+ }
300
+ const body: string[] = [];
301
+ i++;
302
+ while (i < lines.length && lines[i]!.trim() !== delimiter) {
303
+ body.push(lines[i]!);
304
+ i++;
305
+ }
306
+ if (body.length > 0) {
307
+ out.push(body.join('\n'));
308
+ }
309
+ }
310
+ return out;
311
+ };
312
+
259
313
  // Extract inner commands from `$(...)`, `<(...)`, `>(...)`, and `` `...` ``.
260
314
  // Shell-quote collapses these into opaque sentinel tokens (which is correct
261
315
  // For safe-path checks — substituted output is unknown), but it also hides
@@ -266,39 +320,98 @@ const countFdPrefixRedirects = (cmd: string): number => {
266
320
  // Backticks don't nest (bash needs `\` escaping for that, which we treat as
267
321
  // A literal). Process/command substitutions can nest arbitrarily — a depth
268
322
  // Counter handles the balanced parens.
269
- const extractInnerCommands = (cmd: string): string[] => {
270
- const inner: string[] = [];
271
- // Backticks: simple, non-nesting.
272
- const bt = cmd.match(/`([^`]+)`/g);
273
- if (bt !== null) {
274
- for (const m of bt) {
275
- inner.push(m.slice(1, -1));
323
+ const findBacktickEnd = (cmd: string, start: number): number | null => {
324
+ for (let i = start; i < cmd.length; i++) {
325
+ const ch = cmd[i]!;
326
+ if (ch === '\\') {
327
+ i++;
328
+ continue;
329
+ }
330
+ if (ch === '`') {
331
+ return i;
276
332
  }
277
333
  }
278
- // $( ), <( ), >( ) with balanced parens.
279
- for (let i = 0; i < cmd.length - 1; i++) {
334
+ return null;
335
+ };
336
+
337
+ const findSubstitutionEnd = (cmd: string, start: number): number | null => {
338
+ let depth = 1;
339
+ let quote: 'single' | 'double' | null = null;
340
+ for (let j = start; j < cmd.length; j++) {
341
+ const cj = cmd[j]!;
342
+ if (cj === '\\') {
343
+ j++;
344
+ continue;
345
+ }
346
+ if (quote === 'single') {
347
+ if (cj === "'") {
348
+ quote = null;
349
+ }
350
+ continue;
351
+ }
352
+ if (cj === "'") {
353
+ quote ??= 'single';
354
+ continue;
355
+ }
356
+ if (cj === '"') {
357
+ quote = quote === 'double' ? null : 'double';
358
+ continue;
359
+ }
360
+ if (cj === '(') {
361
+ depth++;
362
+ continue;
363
+ }
364
+ if (cj === ')') {
365
+ depth--;
366
+ if (depth === 0) {
367
+ return j;
368
+ }
369
+ }
370
+ }
371
+ return null;
372
+ };
373
+
374
+ const extractInnerCommands = (cmd: string): string[] => {
375
+ const inner: string[] = [];
376
+ let quote: 'single' | 'double' | null = null;
377
+ for (let i = 0; i < cmd.length; i++) {
280
378
  const ch = cmd[i]!;
281
- const next = cmd[i + 1]!;
282
- const isSubStart = (ch === '$' || ch === '<' || ch === '>') && next === '(';
283
- if (!isSubStart) {
379
+ if (ch === '\\') {
380
+ i++;
284
381
  continue;
285
382
  }
286
- let depth = 1;
287
- let j = i + 2;
288
- while (j < cmd.length && depth > 0) {
289
- const cj = cmd[j]!;
290
- if (cj === '(') {
291
- depth++;
292
- } else if (cj === ')') {
293
- depth--;
383
+ if (quote === 'single') {
384
+ if (ch === "'") {
385
+ quote = null;
294
386
  }
295
- if (depth > 0) {
296
- j++;
387
+ continue;
388
+ }
389
+ if (ch === "'") {
390
+ quote ??= 'single';
391
+ continue;
392
+ }
393
+ if (ch === '"') {
394
+ quote = quote === 'double' ? null : 'double';
395
+ continue;
396
+ }
397
+ if (ch === '`') {
398
+ const end = findBacktickEnd(cmd, i + 1);
399
+ if (end !== null) {
400
+ inner.push(cmd.slice(i + 1, end));
401
+ i = end;
297
402
  }
403
+ continue;
404
+ }
405
+ const next = cmd[i + 1];
406
+ const isCommandSubStart = ch === '$' && next === '(';
407
+ const isProcessSubStart = quote === null && (ch === '<' || ch === '>') && next === '(';
408
+ if (!isCommandSubStart && !isProcessSubStart) {
409
+ continue;
298
410
  }
299
- if (depth === 0) {
300
- inner.push(cmd.slice(i + 2, j));
301
- i = j;
411
+ const end = findSubstitutionEnd(cmd, i + 2);
412
+ if (end !== null) {
413
+ inner.push(cmd.slice(i + 2, end));
414
+ i = end;
302
415
  }
303
416
  }
304
417
  return inner;
@@ -467,12 +580,10 @@ const FIND_SPEC: ExecSpec = {
467
580
  pickRoot: pickFindSearchRoot,
468
581
  };
469
582
 
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
- };
583
+ const EXEC_SPECS: Readonly<Record<string, ExecSpec>> = Object.assign(
584
+ Object.create(null) as Record<string, ExecSpec>,
585
+ { fd: FD_SPEC, fdfind: FD_SPEC, find: FIND_SPEC, gfind: FIND_SPEC },
586
+ );
476
587
 
477
588
  const substitutePlaceholders = (
478
589
  tokens: readonly string[],
@@ -518,15 +629,193 @@ const extractExecCommands = (seg: Segment): string[] => {
518
629
  return out;
519
630
  };
520
631
 
632
+ // ── Substitution unwrappers ──────────────────────────────────────────
633
+ //
634
+ // Bash hides agent-controlled content inside command substitutions and
635
+ // Shell wrappers two different ways, and rules need two different shapes
636
+ // Of unwrap:
637
+ //
638
+ // 1. `sh -c '<script>'` and `bash -c '<script>'` carry a script the
639
+ // Outer parser can't see into. Extracted with
640
+ // `extractShellWrappedCommands(seg)` and re-parsed as bash segments
641
+ // So every existing rule applies. Used by `parseCommand`.
642
+ //
643
+ // 2. `$(cat <<'TAG' ... TAG)` and `$(echo '...')` / `$(printf '...')`
644
+ // Compute a static string value at runtime. Rules that inspect arg
645
+ // Values (commit-message convention, redirect targets, etc.) need
646
+ // The string, not a re-parse. `unwrapStaticString(token)` returns
647
+ // It; pass-through if the token isn't a recognised substitution.
648
+ //
649
+ // Both layers cover the same underlying gap — the shell-quote parser
650
+ // Treats substitutions as opaque sentinels — and any rule that touches
651
+ // Agent-controlled content should route through one of them.
652
+
653
+ const HEREDOC_SUBST_RE = /\$\(\s*cat\s+<<-?\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\s*\1\s*\)/u;
654
+ const ECHO_PRINTF_SUBST_RE = /\$\(\s*(?:printf|echo)\s+(?:-[a-zA-Z]+\s+)*'([^']*)'/u;
655
+
656
+ const unwrapStaticString = (value: string): string => {
657
+ const heredoc = HEREDOC_SUBST_RE.exec(value);
658
+ if (heredoc !== null) {
659
+ return heredoc[2] ?? value;
660
+ }
661
+ const printf = ECHO_PRINTF_SUBST_RE.exec(value);
662
+ if (printf !== null) {
663
+ return printf[1] ?? value;
664
+ }
665
+ return value;
666
+ };
667
+
668
+ // Recover commands hidden inside a `sh -c '...'` / `bash -c '...'` wrapper.
669
+ // Without this, every redirect / deny / scoped-rm rule can be trivially
670
+ // Bypassed by wrapping the offending command in `sh -c`. The shell parser
671
+ // Otherwise sees `sh` as the head and the script as an opaque positional
672
+ // Arg. We pull the script out and feed it back through `parseCommand` so
673
+ // All existing rules apply.
674
+ const SHELL_WRAPPER_HEADS: ReadonlySet<string> = new Set([
675
+ 'sh',
676
+ 'bash',
677
+ 'zsh',
678
+ 'dash',
679
+ 'ksh',
680
+ 'ash',
681
+ '/bin/sh',
682
+ '/bin/bash',
683
+ '/bin/zsh',
684
+ '/bin/dash',
685
+ '/bin/ksh',
686
+ '/usr/bin/sh',
687
+ '/usr/bin/bash',
688
+ '/usr/bin/zsh',
689
+ '/usr/local/bin/bash',
690
+ '/opt/homebrew/bin/bash',
691
+ ]);
692
+
693
+ const extractShellWrappedCommands = (seg: Segment): string[] => {
694
+ if (!SHELL_WRAPPER_HEADS.has(seg.head)) {
695
+ return [];
696
+ }
697
+ const tokens = seg.tokens;
698
+ for (let i = 1; i < tokens.length; i++) {
699
+ const t = tokens[i]!;
700
+ if (t === '-c' && i + 1 < tokens.length) {
701
+ return [tokens[i + 1]!];
702
+ }
703
+ // Combined short flags that include `c`: `-ec`, `-xc`, `-eu c` won't —
704
+ // Only treat `c` as the last char so the next token is the script.
705
+ if (
706
+ t.startsWith('-') &&
707
+ !t.startsWith('--') &&
708
+ t.endsWith('c') &&
709
+ t.length > 2 &&
710
+ i + 1 < tokens.length
711
+ ) {
712
+ return [tokens[i + 1]!];
713
+ }
714
+ }
715
+ return [];
716
+ };
717
+
718
+ const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
719
+ 'command',
720
+ 'exec',
721
+ 'env',
722
+ 'time',
723
+ 'nohup',
724
+ 'setsid',
725
+ 'nice',
726
+ 'ionice',
727
+ 'chronic',
728
+ 'stdbuf',
729
+ 'unbuffer',
730
+ 'script',
731
+ 'taskset',
732
+ ]);
733
+
734
+ const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
735
+ command: new Set(),
736
+ env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string', '--block-signal']),
737
+ time: new Set(['-f', '--format', '-o', '--output']),
738
+ nice: new Set(['-n', '--adjustment']),
739
+ ionice: new Set(['-c', '--class', '-n', '--classdata', '-p', '--pid']),
740
+ stdbuf: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
741
+ script: new Set(['-c', '--command']),
742
+ taskset: new Set(),
743
+ };
744
+
745
+ const tokenLooksLikeEnvAssignment = (token: string): boolean =>
746
+ /^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(token);
747
+
748
+ const skipHeadRenamingPrefix = (tokens: readonly string[]): number => {
749
+ const head = tokens[0]!;
750
+ const valueFlags = HEAD_RENAMING_VALUE_FLAGS[head] ?? new Set<string>();
751
+ let i = 1;
752
+ while (i < tokens.length) {
753
+ const token = tokens[i]!;
754
+ if (head === 'env' && tokenLooksLikeEnvAssignment(token)) {
755
+ i++;
756
+ continue;
757
+ }
758
+ if (valueFlags.has(token)) {
759
+ i += 2;
760
+ continue;
761
+ }
762
+ if (token.includes('=') && valueFlags.has(token.slice(0, token.indexOf('=')))) {
763
+ i++;
764
+ continue;
765
+ }
766
+ if (token.startsWith('--') && token !== '--') {
767
+ i++;
768
+ continue;
769
+ }
770
+ if (token.startsWith('-') && token !== '-') {
771
+ i++;
772
+ continue;
773
+ }
774
+ break;
775
+ }
776
+ return i;
777
+ };
778
+
779
+ const extractHeadRenamingCommands = (seg: Segment): string[] => {
780
+ if (!HEAD_RENAMING_HEADS.has(seg.head)) {
781
+ return [];
782
+ }
783
+ if (seg.head === 'script') {
784
+ for (let i = 1; i < seg.tokens.length - 1; i++) {
785
+ const token = seg.tokens[i]!;
786
+ if (token === '-c' || token === '--command') {
787
+ return [seg.tokens[i + 1]!];
788
+ }
789
+ if (token.startsWith('--command=')) {
790
+ return [token.slice('--command='.length)];
791
+ }
792
+ }
793
+ }
794
+ const start = skipHeadRenamingPrefix(seg.tokens);
795
+ const inner = seg.tokens.slice(start).join(' ');
796
+ return inner === '' ? [] : [inner];
797
+ };
798
+
799
+ const EVAL_HEADS: ReadonlySet<string> = new Set(['eval']);
800
+
801
+ const extractEvalCommands = (seg: Segment): string[] => {
802
+ if (!EVAL_HEADS.has(seg.head)) {
803
+ return [];
804
+ }
805
+ const sub = seg.tokens.slice(1).join(' ');
806
+ return sub === '' ? [] : [sub];
807
+ };
808
+
521
809
  const parseCommand = (cmd: string): Segment[] => {
522
810
  let entries: ParseEntry[];
811
+ const cmdForParsing = maskLiteralHeredocBodies(cmd);
523
812
  try {
524
- entries = parse(cmd, PRESERVE_ENV);
813
+ entries = parse(cmdForParsing, PRESERVE_ENV);
525
814
  } catch {
526
815
  return [];
527
816
  }
528
817
  entries = mergeAmpRedirects(entries);
529
- const fdBudget: FdBudget = { remaining: countFdPrefixRedirects(cmd) };
818
+ const fdBudget: FdBudget = { remaining: countFdPrefixRedirects(cmdForParsing) };
530
819
 
531
820
  const out: Segment[] = [];
532
821
  let buf: ParseEntry[] = [];
@@ -551,7 +840,7 @@ const parseCommand = (cmd: string): Segment[] => {
551
840
  // Outer segment's args are already opaque sentinels (safe-path-failing);
552
841
  // This catches dangerous inner commands the outer call would otherwise
553
842
  // Hide.
554
- for (const sub of extractInnerCommands(cmd)) {
843
+ for (const sub of [...extractInnerCommands(cmd), ...extractShellHeredocCommands(cmd)]) {
555
844
  for (const innerSeg of parseCommand(sub)) {
556
845
  out.push(innerSeg);
557
846
  }
@@ -573,6 +862,21 @@ const parseCommand = (cmd: string): Segment[] => {
573
862
  out.push(innerSeg);
574
863
  }
575
864
  }
865
+ for (const sub of extractShellWrappedCommands(seg)) {
866
+ for (const innerSeg of parseCommand(sub)) {
867
+ out.push(innerSeg);
868
+ }
869
+ }
870
+ for (const sub of extractHeadRenamingCommands(seg)) {
871
+ for (const innerSeg of parseCommand(sub)) {
872
+ out.push(innerSeg);
873
+ }
874
+ }
875
+ for (const sub of extractEvalCommands(seg)) {
876
+ for (const innerSeg of parseCommand(sub)) {
877
+ out.push(innerSeg);
878
+ }
879
+ }
576
880
  }
577
881
 
578
882
  return out;
@@ -657,4 +961,11 @@ const safeScopesSummary = (
657
961
  const hasBypass = (cmd: string): boolean => /(^|\s)#\s*tripwire-allow\b/.test(cmd);
658
962
 
659
963
  export type { Redirect, Segment };
660
- export { hasBypass, isSafePathTarget, parseCommand, safeScopesSummary };
964
+ export {
965
+ EXEC_SPECS,
966
+ hasBypass,
967
+ isSafePathTarget,
968
+ parseCommand,
969
+ safeScopesSummary,
970
+ unwrapStaticString,
971
+ };
@@ -40,6 +40,13 @@ const SPECS: readonly Spec[] = [
40
40
  message: 'Fork bomb pattern detected. Refuse.',
41
41
  match: (_seg, raw) => /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/.test(raw),
42
42
  },
43
+ {
44
+ rule: 'source-script',
45
+ action: 'deny',
46
+ message:
47
+ '`source` / `.` executes a file in the current shell. Refuse arbitrary sourced scripts.',
48
+ match: (seg) => (seg.head === 'source' || seg.head === '.') && seg.tokens.length > 1,
49
+ },
43
50
  {
44
51
  rule: 'dd-raw-device',
45
52
  action: 'deny',
@@ -329,18 +336,52 @@ const SPECS: readonly Spec[] = [
329
336
  },
330
337
  ];
331
338
 
339
+ // Rules in this set ignore `# tripwire-allow` on the command line. They
340
+ // Are catastrophic / irreversible operations and hard policy rules that
341
+ // Have no legitimate prompt-line override. If the user genuinely needs
342
+ // One of these to run, they should do it in a terminal themselves.
343
+ const UNBYPASSABLE_RULES: ReadonlySet<string> = new Set([
344
+ // Catastrophic / irreversible
345
+ 'rm-rf-root',
346
+ 'rm-rf-home',
347
+ 'fork-bomb',
348
+ 'dd-raw-device',
349
+ 'mkfs',
350
+ 'kill-all',
351
+ 'diskutil-destructive',
352
+ 'tmutil-destructive',
353
+ // System control
354
+ 'shutdown',
355
+ 'csrutil',
356
+ 'nvram',
357
+ 'kextload',
358
+ 'spctl-disable',
359
+ 'xattr-quarantine-bypass',
360
+ 'topgrade',
361
+ 'softwareupdate-install',
362
+ 'systemsetup',
363
+ 'scutil-set',
364
+ 'security-keychain-destructive',
365
+ // Hard policy rules
366
+ 'no-verify',
367
+ 'no-gpg-sign',
368
+ ]);
369
+
332
370
  const bashDeny = (segments: readonly Segment[], cmd: string): Decision => {
333
- if (hasBypass(cmd)) {
334
- return allow('bash-deny');
335
- }
371
+ const bypass = hasBypass(cmd);
336
372
  for (const seg of segments) {
337
373
  for (const s of SPECS) {
338
- if (s.match(seg, cmd)) {
339
- return s.action === 'deny' ? deny(s.rule, s.message) : ask(s.rule, s.message);
374
+ if (!s.match(seg, cmd)) {
375
+ continue;
376
+ }
377
+ if (bypass && !UNBYPASSABLE_RULES.has(s.rule)) {
378
+ // Caller asserted in-turn approval; honor it for this rule.
379
+ continue;
340
380
  }
381
+ return s.action === 'deny' ? deny(s.rule, s.message) : ask(s.rule, s.message);
341
382
  }
342
383
  }
343
384
  return allow('bash-deny');
344
385
  };
345
386
 
346
- export { bashDeny };
387
+ export { bashDeny, UNBYPASSABLE_RULES };
@@ -1,4 +1,4 @@
1
- import { type Segment, hasBypass } from '../lib/bash';
1
+ import { type Segment, hasBypass, unwrapStaticString } from '../lib/bash';
2
2
  import type { GitConfig } from '../lib/config';
3
3
  import { type Decision, allow, ask, deny, warn } from '../lib/decision';
4
4
 
@@ -111,15 +111,17 @@ const messageOf = (subArgs: readonly string[]): string | null => {
111
111
  for (let i = 0; i < subArgs.length; i++) {
112
112
  const t = subArgs[i]!;
113
113
  if (t === '-m' || t === '--message') {
114
- return subArgs[i + 1] ?? null;
114
+ const raw = subArgs[i + 1];
115
+ return raw === undefined ? null : unwrapStaticString(raw);
115
116
  }
116
117
  if (t.startsWith('--message=')) {
117
- return t.slice('--message='.length);
118
+ return unwrapStaticString(t.slice('--message='.length));
118
119
  }
119
120
  // Combined short flags like `-am`, `-ma`, `-amS` carry the message
120
121
  // In the next positional arg — same as `-m` alone.
121
122
  if (/^-[a-zA-Z]*m[a-zA-Z]*$/.test(t)) {
122
- return subArgs[i + 1] ?? null;
123
+ const raw = subArgs[i + 1];
124
+ return raw === undefined ? null : unwrapStaticString(raw);
123
125
  }
124
126
  }
125
127
  return null;