@seanmozeik/tripwire 0.4.1 → 0.5.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.1",
3
+ "version": "0.5.1",
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,89 @@ 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
+ // Side-channel for static-string lookup. Mask-protected rules (hasBypass,
292
+ // Bash-deny scanning) must never see heredoc body characters — a body
293
+ // Containing `# tripwire-allow` or `rm -rf /` would slip past them.
294
+ // `unwrapStaticString` still needs the original body of `$(cat <<EOF ... EOF)`
295
+ // To validate the wrapped commit message. Keep masking universal, and pass
296
+ // The original-body lookup through a separate channel only the static-string
297
+ // Extractor consults.
298
+ const collectHeredocBodies = (cmd: string): ReadonlyMap<string, string> => {
299
+ const map = new Map<string, string>();
300
+ const lines = cmd.split('\n');
301
+ for (let i = 0; i < lines.length; i++) {
302
+ const line = lines[i]!;
303
+ const delimiter = heredocDelimiterFromLine(line);
304
+ if (delimiter === null || heredocFeedsShell(line)) {
305
+ continue;
306
+ }
307
+ const body: string[] = [];
308
+ i++;
309
+ while (i < lines.length && lines[i]!.trim() !== delimiter) {
310
+ body.push(lines[i]!);
311
+ i++;
312
+ }
313
+ if (!map.has(delimiter)) {
314
+ map.set(delimiter, body.join('\n'));
315
+ }
316
+ }
317
+ return map;
318
+ };
319
+
320
+ const extractShellHeredocCommands = (cmd: string): string[] => {
321
+ const lines = cmd.split('\n');
322
+ const out: string[] = [];
323
+ for (let i = 0; i < lines.length; i++) {
324
+ const line = lines[i]!;
325
+ const delimiter = heredocDelimiterFromLine(line);
326
+ if (delimiter === null || !heredocFeedsShell(line)) {
327
+ continue;
328
+ }
329
+ const body: string[] = [];
330
+ i++;
331
+ while (i < lines.length && lines[i]!.trim() !== delimiter) {
332
+ body.push(lines[i]!);
333
+ i++;
334
+ }
335
+ if (body.length > 0) {
336
+ out.push(body.join('\n'));
337
+ }
338
+ }
339
+ return out;
340
+ };
341
+
259
342
  // Extract inner commands from `$(...)`, `<(...)`, `>(...)`, and `` `...` ``.
260
343
  // Shell-quote collapses these into opaque sentinel tokens (which is correct
261
344
  // For safe-path checks — substituted output is unknown), but it also hides
@@ -266,39 +349,98 @@ const countFdPrefixRedirects = (cmd: string): number => {
266
349
  // Backticks don't nest (bash needs `\` escaping for that, which we treat as
267
350
  // A literal). Process/command substitutions can nest arbitrarily — a depth
268
351
  // 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));
352
+ const findBacktickEnd = (cmd: string, start: number): number | null => {
353
+ for (let i = start; i < cmd.length; i++) {
354
+ const ch = cmd[i]!;
355
+ if (ch === '\\') {
356
+ i++;
357
+ continue;
358
+ }
359
+ if (ch === '`') {
360
+ return i;
361
+ }
362
+ }
363
+ return null;
364
+ };
365
+
366
+ const findSubstitutionEnd = (cmd: string, start: number): number | null => {
367
+ let depth = 1;
368
+ let quote: 'single' | 'double' | null = null;
369
+ for (let j = start; j < cmd.length; j++) {
370
+ const cj = cmd[j]!;
371
+ if (cj === '\\') {
372
+ j++;
373
+ continue;
374
+ }
375
+ if (quote === 'single') {
376
+ if (cj === "'") {
377
+ quote = null;
378
+ }
379
+ continue;
380
+ }
381
+ if (cj === "'") {
382
+ quote ??= 'single';
383
+ continue;
384
+ }
385
+ if (cj === '"') {
386
+ quote = quote === 'double' ? null : 'double';
387
+ continue;
388
+ }
389
+ if (cj === '(') {
390
+ depth++;
391
+ continue;
392
+ }
393
+ if (cj === ')') {
394
+ depth--;
395
+ if (depth === 0) {
396
+ return j;
397
+ }
276
398
  }
277
399
  }
278
- // $( ), <( ), >( ) with balanced parens.
279
- for (let i = 0; i < cmd.length - 1; i++) {
400
+ return null;
401
+ };
402
+
403
+ const extractInnerCommands = (cmd: string): string[] => {
404
+ const inner: string[] = [];
405
+ let quote: 'single' | 'double' | null = null;
406
+ for (let i = 0; i < cmd.length; i++) {
280
407
  const ch = cmd[i]!;
281
- const next = cmd[i + 1]!;
282
- const isSubStart = (ch === '$' || ch === '<' || ch === '>') && next === '(';
283
- if (!isSubStart) {
408
+ if (ch === '\\') {
409
+ i++;
284
410
  continue;
285
411
  }
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--;
412
+ if (quote === 'single') {
413
+ if (ch === "'") {
414
+ quote = null;
294
415
  }
295
- if (depth > 0) {
296
- j++;
416
+ continue;
417
+ }
418
+ if (ch === "'") {
419
+ quote ??= 'single';
420
+ continue;
421
+ }
422
+ if (ch === '"') {
423
+ quote = quote === 'double' ? null : 'double';
424
+ continue;
425
+ }
426
+ if (ch === '`') {
427
+ const end = findBacktickEnd(cmd, i + 1);
428
+ if (end !== null) {
429
+ inner.push(cmd.slice(i + 1, end));
430
+ i = end;
297
431
  }
432
+ continue;
433
+ }
434
+ const next = cmd[i + 1];
435
+ const isCommandSubStart = ch === '$' && next === '(';
436
+ const isProcessSubStart = quote === null && (ch === '<' || ch === '>') && next === '(';
437
+ if (!isCommandSubStart && !isProcessSubStart) {
438
+ continue;
298
439
  }
299
- if (depth === 0) {
300
- inner.push(cmd.slice(i + 2, j));
301
- i = j;
440
+ const end = findSubstitutionEnd(cmd, i + 2);
441
+ if (end !== null) {
442
+ inner.push(cmd.slice(i + 2, end));
443
+ i = end;
302
444
  }
303
445
  }
304
446
  return inner;
@@ -467,12 +609,10 @@ const FIND_SPEC: ExecSpec = {
467
609
  pickRoot: pickFindSearchRoot,
468
610
  };
469
611
 
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
- };
612
+ const EXEC_SPECS: Readonly<Record<string, ExecSpec>> = Object.assign(
613
+ Object.create(null) as Record<string, ExecSpec>,
614
+ { fd: FD_SPEC, fdfind: FD_SPEC, find: FIND_SPEC, gfind: FIND_SPEC },
615
+ );
476
616
 
477
617
  const substitutePlaceholders = (
478
618
  tokens: readonly string[],
@@ -518,15 +658,201 @@ const extractExecCommands = (seg: Segment): string[] => {
518
658
  return out;
519
659
  };
520
660
 
661
+ // ── Substitution unwrappers ──────────────────────────────────────────
662
+ //
663
+ // Bash hides agent-controlled content inside command substitutions and
664
+ // Shell wrappers two different ways, and rules need two different shapes
665
+ // Of unwrap:
666
+ //
667
+ // 1. `sh -c '<script>'` and `bash -c '<script>'` carry a script the
668
+ // Outer parser can't see into. Extracted with
669
+ // `extractShellWrappedCommands(seg)` and re-parsed as bash segments
670
+ // So every existing rule applies. Used by `parseCommand`.
671
+ //
672
+ // 2. `$(cat <<'TAG' ... TAG)` and `$(echo '...')` / `$(printf '...')`
673
+ // Compute a static string value at runtime. Rules that inspect arg
674
+ // Values (commit-message convention, redirect targets, etc.) need
675
+ // The string, not a re-parse. `unwrapStaticString(token)` returns
676
+ // It; pass-through if the token isn't a recognised substitution.
677
+ //
678
+ // Both layers cover the same underlying gap — the shell-quote parser
679
+ // Treats substitutions as opaque sentinels — and any rule that touches
680
+ // Agent-controlled content should route through one of them.
681
+
682
+ const HEREDOC_SUBST_RE = /\$\(\s*cat\s+<<-?\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\s*\1\s*\)/u;
683
+ const ECHO_PRINTF_SUBST_RE = /\$\(\s*(?:printf|echo)\s+(?:-[a-zA-Z]+\s+)*'([^']*)'/u;
684
+
685
+ const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, string>): string => {
686
+ const heredoc = HEREDOC_SUBST_RE.exec(value);
687
+ if (heredoc !== null) {
688
+ const captured = heredoc[2] ?? value;
689
+ if (heredocBodies !== undefined && captured.trim() === '__HEREDOC_BODY__') {
690
+ const delimiter = heredoc[1];
691
+ const real = delimiter === undefined ? undefined : heredocBodies.get(delimiter);
692
+ if (real !== undefined) {
693
+ return real;
694
+ }
695
+ }
696
+ return captured;
697
+ }
698
+ const printf = ECHO_PRINTF_SUBST_RE.exec(value);
699
+ if (printf !== null) {
700
+ return printf[1] ?? value;
701
+ }
702
+ return value;
703
+ };
704
+
705
+ // Recover commands hidden inside a `sh -c '...'` / `bash -c '...'` wrapper.
706
+ // Without this, every redirect / deny / scoped-rm rule can be trivially
707
+ // Bypassed by wrapping the offending command in `sh -c`. The shell parser
708
+ // Otherwise sees `sh` as the head and the script as an opaque positional
709
+ // Arg. We pull the script out and feed it back through `parseCommand` so
710
+ // All existing rules apply.
711
+ const SHELL_WRAPPER_HEADS: ReadonlySet<string> = new Set([
712
+ 'sh',
713
+ 'bash',
714
+ 'zsh',
715
+ 'dash',
716
+ 'ksh',
717
+ 'ash',
718
+ '/bin/sh',
719
+ '/bin/bash',
720
+ '/bin/zsh',
721
+ '/bin/dash',
722
+ '/bin/ksh',
723
+ '/usr/bin/sh',
724
+ '/usr/bin/bash',
725
+ '/usr/bin/zsh',
726
+ '/usr/local/bin/bash',
727
+ '/opt/homebrew/bin/bash',
728
+ ]);
729
+
730
+ const extractShellWrappedCommands = (seg: Segment): string[] => {
731
+ if (!SHELL_WRAPPER_HEADS.has(seg.head)) {
732
+ return [];
733
+ }
734
+ const tokens = seg.tokens;
735
+ for (let i = 1; i < tokens.length; i++) {
736
+ const t = tokens[i]!;
737
+ if (t === '-c' && i + 1 < tokens.length) {
738
+ return [tokens[i + 1]!];
739
+ }
740
+ // Combined short flags that include `c`: `-ec`, `-xc`, `-eu c` won't —
741
+ // Only treat `c` as the last char so the next token is the script.
742
+ if (
743
+ t.startsWith('-') &&
744
+ !t.startsWith('--') &&
745
+ t.endsWith('c') &&
746
+ t.length > 2 &&
747
+ i + 1 < tokens.length
748
+ ) {
749
+ return [tokens[i + 1]!];
750
+ }
751
+ }
752
+ return [];
753
+ };
754
+
755
+ const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
756
+ 'command',
757
+ 'exec',
758
+ 'env',
759
+ 'time',
760
+ 'nohup',
761
+ 'setsid',
762
+ 'nice',
763
+ 'ionice',
764
+ 'chronic',
765
+ 'stdbuf',
766
+ 'unbuffer',
767
+ 'script',
768
+ 'taskset',
769
+ ]);
770
+
771
+ const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
772
+ command: new Set(),
773
+ env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string', '--block-signal']),
774
+ time: new Set(['-f', '--format', '-o', '--output']),
775
+ nice: new Set(['-n', '--adjustment']),
776
+ ionice: new Set(['-c', '--class', '-n', '--classdata', '-p', '--pid']),
777
+ stdbuf: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
778
+ script: new Set(['-c', '--command']),
779
+ taskset: new Set(),
780
+ };
781
+
782
+ const tokenLooksLikeEnvAssignment = (token: string): boolean =>
783
+ /^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(token);
784
+
785
+ const skipHeadRenamingPrefix = (tokens: readonly string[]): number => {
786
+ const head = tokens[0]!;
787
+ const valueFlags = HEAD_RENAMING_VALUE_FLAGS[head] ?? new Set<string>();
788
+ let i = 1;
789
+ while (i < tokens.length) {
790
+ const token = tokens[i]!;
791
+ if (head === 'env' && tokenLooksLikeEnvAssignment(token)) {
792
+ i++;
793
+ continue;
794
+ }
795
+ if (valueFlags.has(token)) {
796
+ i += 2;
797
+ continue;
798
+ }
799
+ if (token.includes('=') && valueFlags.has(token.slice(0, token.indexOf('=')))) {
800
+ i++;
801
+ continue;
802
+ }
803
+ if (token.startsWith('--') && token !== '--') {
804
+ i++;
805
+ continue;
806
+ }
807
+ if (token.startsWith('-') && token !== '-') {
808
+ i++;
809
+ continue;
810
+ }
811
+ break;
812
+ }
813
+ return i;
814
+ };
815
+
816
+ const extractHeadRenamingCommands = (seg: Segment): string[] => {
817
+ if (!HEAD_RENAMING_HEADS.has(seg.head)) {
818
+ return [];
819
+ }
820
+ if (seg.head === 'script') {
821
+ for (let i = 1; i < seg.tokens.length - 1; i++) {
822
+ const token = seg.tokens[i]!;
823
+ if (token === '-c' || token === '--command') {
824
+ return [seg.tokens[i + 1]!];
825
+ }
826
+ if (token.startsWith('--command=')) {
827
+ return [token.slice('--command='.length)];
828
+ }
829
+ }
830
+ }
831
+ const start = skipHeadRenamingPrefix(seg.tokens);
832
+ const inner = seg.tokens.slice(start).join(' ');
833
+ return inner === '' ? [] : [inner];
834
+ };
835
+
836
+ const EVAL_HEADS: ReadonlySet<string> = new Set(['eval']);
837
+
838
+ const extractEvalCommands = (seg: Segment): string[] => {
839
+ if (!EVAL_HEADS.has(seg.head)) {
840
+ return [];
841
+ }
842
+ const sub = seg.tokens.slice(1).join(' ');
843
+ return sub === '' ? [] : [sub];
844
+ };
845
+
521
846
  const parseCommand = (cmd: string): Segment[] => {
522
847
  let entries: ParseEntry[];
848
+ const cmdForParsing = maskLiteralHeredocBodies(cmd);
523
849
  try {
524
- entries = parse(cmd, PRESERVE_ENV);
850
+ entries = parse(cmdForParsing, PRESERVE_ENV);
525
851
  } catch {
526
852
  return [];
527
853
  }
528
854
  entries = mergeAmpRedirects(entries);
529
- const fdBudget: FdBudget = { remaining: countFdPrefixRedirects(cmd) };
855
+ const fdBudget: FdBudget = { remaining: countFdPrefixRedirects(cmdForParsing) };
530
856
 
531
857
  const out: Segment[] = [];
532
858
  let buf: ParseEntry[] = [];
@@ -551,7 +877,7 @@ const parseCommand = (cmd: string): Segment[] => {
551
877
  // Outer segment's args are already opaque sentinels (safe-path-failing);
552
878
  // This catches dangerous inner commands the outer call would otherwise
553
879
  // Hide.
554
- for (const sub of extractInnerCommands(cmd)) {
880
+ for (const sub of [...extractInnerCommands(cmd), ...extractShellHeredocCommands(cmd)]) {
555
881
  for (const innerSeg of parseCommand(sub)) {
556
882
  out.push(innerSeg);
557
883
  }
@@ -573,6 +899,21 @@ const parseCommand = (cmd: string): Segment[] => {
573
899
  out.push(innerSeg);
574
900
  }
575
901
  }
902
+ for (const sub of extractShellWrappedCommands(seg)) {
903
+ for (const innerSeg of parseCommand(sub)) {
904
+ out.push(innerSeg);
905
+ }
906
+ }
907
+ for (const sub of extractHeadRenamingCommands(seg)) {
908
+ for (const innerSeg of parseCommand(sub)) {
909
+ out.push(innerSeg);
910
+ }
911
+ }
912
+ for (const sub of extractEvalCommands(seg)) {
913
+ for (const innerSeg of parseCommand(sub)) {
914
+ out.push(innerSeg);
915
+ }
916
+ }
576
917
  }
577
918
 
578
919
  return out;
@@ -654,7 +995,21 @@ const safeScopesSummary = (
654
995
  .join('\n');
655
996
  };
656
997
 
657
- const hasBypass = (cmd: string): boolean => /(^|\s)#\s*tripwire-allow\b/.test(cmd);
998
+ // Mask heredoc bodies before scanning, otherwise a `# tripwire-allow`
999
+ // Smuggled inside a heredoc body (e.g. a commit message piped via
1000
+ // `$(cat <<EOF ... EOF)`) disarms every rule for the surrounding command.
1001
+ // A legitimate bypass marker sits on the actual command line, which the
1002
+ // Mask leaves intact.
1003
+ const hasBypass = (cmd: string): boolean =>
1004
+ /(^|\s)#\s*tripwire-allow\b/.test(maskLiteralHeredocBodies(cmd));
658
1005
 
659
1006
  export type { Redirect, Segment };
660
- export { hasBypass, isSafePathTarget, parseCommand, safeScopesSummary };
1007
+ export {
1008
+ EXEC_SPECS,
1009
+ collectHeredocBodies,
1010
+ hasBypass,
1011
+ isSafePathTarget,
1012
+ parseCommand,
1013
+ safeScopesSummary,
1014
+ unwrapStaticString,
1015
+ };
@@ -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, collectHeredocBodies, 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
 
@@ -107,19 +107,24 @@ const parseGit = (seg: Segment): GitInvocation | null => {
107
107
  return null;
108
108
  };
109
109
 
110
- const messageOf = (subArgs: readonly string[]): string | null => {
110
+ const messageOf = (
111
+ subArgs: readonly string[],
112
+ heredocBodies?: ReadonlyMap<string, string>,
113
+ ): string | null => {
111
114
  for (let i = 0; i < subArgs.length; i++) {
112
115
  const t = subArgs[i]!;
113
116
  if (t === '-m' || t === '--message') {
114
- return subArgs[i + 1] ?? null;
117
+ const raw = subArgs[i + 1];
118
+ return raw === undefined ? null : unwrapStaticString(raw, heredocBodies);
115
119
  }
116
120
  if (t.startsWith('--message=')) {
117
- return t.slice('--message='.length);
121
+ return unwrapStaticString(t.slice('--message='.length), heredocBodies);
118
122
  }
119
123
  // Combined short flags like `-am`, `-ma`, `-amS` carry the message
120
124
  // In the next positional arg — same as `-m` alone.
121
125
  if (/^-[a-zA-Z]*m[a-zA-Z]*$/.test(t)) {
122
- return subArgs[i + 1] ?? null;
126
+ const raw = subArgs[i + 1];
127
+ return raw === undefined ? null : unwrapStaticString(raw, heredocBodies);
123
128
  }
124
129
  }
125
130
  return null;
@@ -151,6 +156,7 @@ interface HandlerCtx {
151
156
  readonly flags: readonly string[];
152
157
  readonly positional: readonly string[];
153
158
  readonly config: GitConfig;
159
+ readonly heredocBodies: ReadonlyMap<string, string>;
154
160
  }
155
161
 
156
162
  type Handler = (ctx: HandlerCtx) => Decision;
@@ -304,14 +310,14 @@ const handleMerge: Handler = ({ subArgs }) => {
304
310
  return ask('git-merge', '`git merge <branch>` may create merge conflicts. Confirm intent.');
305
311
  };
306
312
 
307
- const handleCommit: Handler = ({ subArgs, config }) => {
313
+ const handleCommit: Handler = ({ subArgs, config, heredocBodies }) => {
308
314
  if (has(subArgs, '--amend')) {
309
315
  return deny(
310
316
  'git-commit-amend',
311
317
  '`git commit --amend` rewrites the last commit. If it has been pushed, this causes upstream divergence. Refuse — surface the intent.',
312
318
  );
313
319
  }
314
- const msg = messageOf(subArgs);
320
+ const msg = messageOf(subArgs, heredocBodies);
315
321
  const hasFile = has(subArgs, '-F', '--file', '-c', '-C', '--reuse-message', '--reedit-message');
316
322
  const hasNoEdit = has(subArgs, '--no-edit');
317
323
  if (msg === null && !hasFile && !hasNoEdit) {
@@ -509,7 +515,11 @@ const HANDLERS: ReadonlyMap<string, Handler> = new Map<string, Handler>([
509
515
  ],
510
516
  ]);
511
517
 
512
- const evalGit = (inv: GitInvocation, config: GitConfig): Decision | null => {
518
+ const evalGit = (
519
+ inv: GitInvocation,
520
+ config: GitConfig,
521
+ heredocBodies: ReadonlyMap<string, string>,
522
+ ): Decision | null => {
513
523
  const { subcommand, subArgs } = inv;
514
524
  const flags = flagsOf(subArgs);
515
525
  const positional = positionalOf(subArgs);
@@ -564,7 +574,7 @@ const evalGit = (inv: GitInvocation, config: GitConfig): Decision | null => {
564
574
 
565
575
  const handler = HANDLERS.get(subcommand);
566
576
  if (handler !== undefined) {
567
- return handler({ subcommand, subArgs, flags, positional, config });
577
+ return handler({ subcommand, subArgs, flags, positional, config, heredocBodies });
568
578
  }
569
579
  return warn(
570
580
  'git-unknown-subcommand',
@@ -576,12 +586,13 @@ const bashGit = (segments: readonly Segment[], cmd: string, config: GitConfig):
576
586
  if (hasBypass(cmd)) {
577
587
  return allow('bash-git');
578
588
  }
589
+ const heredocBodies = collectHeredocBodies(cmd);
579
590
  for (const seg of segments) {
580
591
  const inv = parseGit(seg);
581
592
  if (inv === null) {
582
593
  continue;
583
594
  }
584
- const d = evalGit(inv, config);
595
+ const d = evalGit(inv, config, heredocBodies);
585
596
  if (d !== null && d.kind !== 'allow') {
586
597
  return d;
587
598
  }