@seanmozeik/tripwire 0.5.3 → 0.6.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.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -33,14 +33,14 @@
33
33
  "dependencies": {
34
34
  "@effect/platform-bun": "^4.0.0-beta.66",
35
35
  "effect": "^4.0.0-beta.66",
36
- "shell-quote": "^1.8.3"
36
+ "shell-quote": "^1.8.4"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/bun": "^1.3.14",
40
40
  "@types/shell-quote": "^1.7.5",
41
- "oxfmt": "^0.50.0",
42
- "oxlint": "^1.65.0",
43
- "oxlint-tsgolint": "^0.22.1",
41
+ "oxfmt": "^0.53.0",
42
+ "oxlint": "^1.68.0",
43
+ "oxlint-tsgolint": "^0.23.0",
44
44
  "typescript": "^6.0.3"
45
45
  },
46
46
  "engines": {
package/src/cli.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  // Bun src/cli.ts install pi
15
15
  // Bun src/cli.ts install all
16
16
 
17
+ // oxlint-disable-next-line unicorn/import-style
17
18
  import { dirname } from 'node:path';
18
19
 
19
20
  import { BunServices } from '@effect/platform-bun';
@@ -27,7 +28,7 @@ import { installAll, installClaude, installCodex, installPi } from './lib/instal
27
28
  // Resolve tripwire-hook path at runtime using process.argv
28
29
  // This works in both script mode (bun run) and compiled/bundled mode
29
30
  const runtimeSelf = (): string => {
30
- const isBunCli = /\/bun(\.exe)?$/.test(process.argv[0] ?? '');
31
+ const isBunCli = /\/bun(?<ext>\.exe)?$/.test(process.argv[0] ?? '');
31
32
  return isBunCli ? process.argv[1]! : process.argv[0]!;
32
33
  };
33
34
 
package/src/lib/bash.ts CHANGED
@@ -257,8 +257,13 @@ const countFdPrefixRedirects = (cmd: string): number => {
257
257
  };
258
258
 
259
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;
260
+ const match =
261
+ /<<-?\s*(?:"(?<quoted>[^"]+)"|'(?<single>[^']+)'|(?<unquoted>[A-Za-z_][A-Za-z0-9_]*))/u.exec(
262
+ line,
263
+ );
264
+ return (
265
+ match?.groups?.['quoted'] ?? match?.groups?.['single'] ?? match?.groups?.['unquoted'] ?? null
266
+ );
262
267
  };
263
268
 
264
269
  const SHELL_STDIN_HEAD_RE =
@@ -483,7 +488,7 @@ const pathDangerScore = (t: string): number => {
483
488
  if (t === '~' || HOME_VAR_RE.test(t)) {
484
489
  return 90;
485
490
  }
486
- if (/^\/(etc|usr|bin|sbin|System|Library|var|boot|root|home)(\/|$)/.test(t)) {
491
+ if (/^\/(?<dir>etc|usr|bin|sbin|System|Library|var|boot|root|home)(?<suffix>\/|$)/.test(t)) {
487
492
  return 80;
488
493
  }
489
494
  if (t.startsWith('/Users/')) {
@@ -679,15 +684,16 @@ const extractExecCommands = (seg: Segment): string[] => {
679
684
  // Treats substitutions as opaque sentinels — and any rule that touches
680
685
  // Agent-controlled content should route through one of them.
681
686
 
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;
687
+ const HEREDOC_SUBST_RE =
688
+ /\$\(\s*cat\s+<<-?\s*['"]?(?<delimiter>\w+)['"]?\s*\n(?<body>[\s\S]*?)\n\s*\k<delimiter>\s*\)/u;
689
+ const ECHO_PRINTF_SUBST_RE = /\$\(\s*(?:printf|echo)\s+(?:-[a-zA-Z]+\s+)*'(?<content>[^']*)'/u;
684
690
 
685
691
  const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, string>): string => {
686
692
  const heredoc = HEREDOC_SUBST_RE.exec(value);
687
693
  if (heredoc !== null) {
688
- const captured = heredoc[2] ?? value;
694
+ const captured = heredoc.groups?.['body'] ?? value;
689
695
  if (heredocBodies !== undefined && captured.trim() === '__HEREDOC_BODY__') {
690
- const delimiter = heredoc[1];
696
+ const delimiter = heredoc.groups?.['delimiter'];
691
697
  const real = delimiter === undefined ? undefined : heredocBodies.get(delimiter);
692
698
  if (real !== undefined) {
693
699
  return real;
@@ -697,7 +703,7 @@ const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, s
697
703
  }
698
704
  const printf = ECHO_PRINTF_SUBST_RE.exec(value);
699
705
  if (printf !== null) {
700
- return printf[1] ?? value;
706
+ return printf.groups?.['content'] ?? value;
701
707
  }
702
708
  return value;
703
709
  };
@@ -752,6 +758,13 @@ const extractShellWrappedCommands = (seg: Segment): string[] => {
752
758
  return [];
753
759
  };
754
760
 
761
+ // Heads that take `[flags] <command> [args]` on the same arg vector: the
762
+ // First non-flag token after the prefix is the real command. `sudo`/`doas`
763
+ // (privilege escalation), `xargs` (stdin-driven exec), and `watch` (repeated
764
+ // Exec) all hide a sibling command this way, so the same unwrap that handles
765
+ // `command`/`env`/`nohup` applies. `sudo` is also matched by bash-deny's
766
+ // `ask` rule on the outer segment — both fire, and the more-restrictive
767
+ // Interior decision (e.g. `sudo rm -rf /` → deny) wins on merge.
755
768
  const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
756
769
  'command',
757
770
  'exec',
@@ -766,6 +779,10 @@ const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
766
779
  'unbuffer',
767
780
  'script',
768
781
  'taskset',
782
+ 'sudo',
783
+ 'doas',
784
+ 'xargs',
785
+ 'watch',
769
786
  ]);
770
787
 
771
788
  const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
@@ -777,6 +794,52 @@ const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> =
777
794
  stdbuf: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
778
795
  script: new Set(['-c', '--command']),
779
796
  taskset: new Set(),
797
+ sudo: new Set([
798
+ '-u',
799
+ '--user',
800
+ '-g',
801
+ '--group',
802
+ '-C',
803
+ '--close-from',
804
+ '-D',
805
+ '--chdir',
806
+ '-h',
807
+ '--host',
808
+ '-p',
809
+ '--prompt',
810
+ '-r',
811
+ '--role',
812
+ '-t',
813
+ '--type',
814
+ '-U',
815
+ '--other-user',
816
+ '-R',
817
+ '--chroot',
818
+ '-T',
819
+ '--command-timeout',
820
+ ]),
821
+ doas: new Set(['-a', '-C', '-u']),
822
+ xargs: new Set([
823
+ '-I',
824
+ '-i',
825
+ '-J',
826
+ '-n',
827
+ '--max-args',
828
+ '-P',
829
+ '--max-procs',
830
+ '-s',
831
+ '--max-chars',
832
+ '-L',
833
+ '--max-lines',
834
+ '-E',
835
+ '--eof',
836
+ '-d',
837
+ '--delimiter',
838
+ '-a',
839
+ '--arg-file',
840
+ '--replace',
841
+ ]),
842
+ watch: new Set(['-n', '--interval']),
780
843
  };
781
844
 
782
845
  const tokenLooksLikeEnvAssignment = (token: string): boolean =>
@@ -843,6 +906,186 @@ const extractEvalCommands = (seg: Segment): string[] => {
843
906
  return sub === '' ? [] : [sub];
844
907
  };
845
908
 
909
+ // ── rtk (token-optimizing CLI proxy) ─────────────────────────────────
910
+ // Rtk wraps real commands so their output is filtered before reaching the
911
+ // Agent's context, and Codex auto-prepends it. The wrapper hides the real
912
+ // Command from every rule: `rtk proxy rm -rf /` parses with head `rtk` and
913
+ // The destructive `rm` buried in opaque positional args. Strip the `rtk`
914
+ // Prefix and reconstruct the interior command so the existing rules decide
915
+ // On what actually runs.
916
+ //
917
+ // Grammar: `rtk [global-opts] <subcommand> [args]`. Two subcommand classes
918
+ // Exec a sibling command:
919
+ // • wrapper subs — the keyword is dropped, the remainder is an arbitrary
920
+ // Command: `run` (also `-c <string>`), `proxy`, `err`, `test`, `summary`.
921
+ // • tool-proxy subs — the keyword *is* the binary: `git`, `find`, `npm`,
922
+ // `docker`, … Reconstructing from the subcommand onward yields the real
923
+ // Invocation (`git push …`, `find … -delete`). rtk-internal filters that
924
+ // Aren't real binaries (`gain`, `config`, `diff`, …) reconstruct to inert
925
+ // Heads no rule matches, so no allowlist is needed.
926
+ const RTK_WRAPPER_SUBCOMMANDS: ReadonlySet<string> = new Set([
927
+ 'run',
928
+ 'proxy',
929
+ 'err',
930
+ 'test',
931
+ 'summary',
932
+ ]);
933
+
934
+ const isRtkHead = (head: string): boolean => head === 'rtk' || head.endsWith('/rtk');
935
+
936
+ const skipRtkGlobalFlags = (tokens: readonly string[]): number => {
937
+ // Rtk's global options (`-v`/`-vv`/`--verbose`, `--ultra-compact`,
938
+ // `--skip-env`) are all boolean, so any leading flag token can be skipped.
939
+ let i = 1;
940
+ while (i < tokens.length) {
941
+ const t = tokens[i]!;
942
+ if (t.startsWith('-') && t !== '-' && t !== '--') {
943
+ i++;
944
+ continue;
945
+ }
946
+ break;
947
+ }
948
+ return i;
949
+ };
950
+
951
+ const dashCommandArg = (tokens: readonly string[], start: number): string | null => {
952
+ for (let k = start; k < tokens.length; k++) {
953
+ const t = tokens[k]!;
954
+ if ((t === '-c' || t === '--command') && k + 1 < tokens.length) {
955
+ return tokens[k + 1]!;
956
+ }
957
+ if (t.startsWith('--command=')) {
958
+ return t.slice('--command='.length);
959
+ }
960
+ }
961
+ return null;
962
+ };
963
+
964
+ const extractRtkCommands = (seg: Segment): string[] => {
965
+ if (!isRtkHead(seg.head)) {
966
+ return [];
967
+ }
968
+ const subIdx = skipRtkGlobalFlags(seg.tokens);
969
+ const sub = seg.tokens[subIdx];
970
+ if (sub === undefined) {
971
+ return [];
972
+ }
973
+ if (RTK_WRAPPER_SUBCOMMANDS.has(sub)) {
974
+ // `rtk run -c '<cmd>'` carries the command in a flag value.
975
+ const viaFlag = dashCommandArg(seg.tokens, subIdx + 1);
976
+ if (viaFlag !== null) {
977
+ return viaFlag === '' ? [] : [viaFlag];
978
+ }
979
+ // Otherwise the command is the positional remainder after the keyword,
980
+ // Skipping any wrapper-local boolean flags.
981
+ let j = subIdx + 1;
982
+ while (j < seg.tokens.length) {
983
+ const t = seg.tokens[j]!;
984
+ if (t === '--') {
985
+ j++;
986
+ break;
987
+ }
988
+ if (t.startsWith('-') && t !== '-') {
989
+ j++;
990
+ continue;
991
+ }
992
+ break;
993
+ }
994
+ const inner = seg.tokens.slice(j).join(' ');
995
+ return inner === '' ? [] : [inner];
996
+ }
997
+ // Tool-proxy subcommand: the subcommand token is the real binary name.
998
+ const inner = seg.tokens.slice(subIdx).join(' ');
999
+ return inner === '' ? [] : [inner];
1000
+ };
1001
+
1002
+ // ── Positional-prefix wrappers ───────────────────────────────────────
1003
+ // Heads where the real command follows one or more positional arguments
1004
+ // The wrapper consumes itself: `timeout <duration> <cmd>`, `chroot <newroot>
1005
+ // <cmd>`, `flock <lockfile> <cmd>` (or `flock <lockfile> -c '<cmd>'`), and
1006
+ // `su [user] -c '<cmd>'`. Skip the flag region, drop the wrapper's own
1007
+ // Positionals, and the remainder is the command.
1008
+ interface PrefixWrapperSpec {
1009
+ readonly valueFlags: ReadonlySet<string>;
1010
+ // Positional args the wrapper consumes before the command (duration, newroot…).
1011
+ readonly skipPositionals: number;
1012
+ // Whether the command can also arrive via `-c <string>` (su, flock).
1013
+ readonly dashCommand: boolean;
1014
+ }
1015
+
1016
+ const PREFIX_WRAPPER_SPECS: Readonly<Record<string, PrefixWrapperSpec>> = {
1017
+ timeout: {
1018
+ valueFlags: new Set(['-s', '--signal', '-k', '--kill-after']),
1019
+ skipPositionals: 1,
1020
+ dashCommand: false,
1021
+ },
1022
+ gtimeout: {
1023
+ valueFlags: new Set(['-s', '--signal', '-k', '--kill-after']),
1024
+ skipPositionals: 1,
1025
+ dashCommand: false,
1026
+ },
1027
+ chroot: {
1028
+ valueFlags: new Set(['--userspec', '--groups']),
1029
+ skipPositionals: 1,
1030
+ dashCommand: false,
1031
+ },
1032
+ flock: {
1033
+ valueFlags: new Set(['-w', '--wait', '--timeout', '-E', '--conflict-exit-code']),
1034
+ skipPositionals: 1,
1035
+ dashCommand: true,
1036
+ },
1037
+ su: { valueFlags: new Set(), skipPositionals: 0, dashCommand: true },
1038
+ };
1039
+
1040
+ const extractPrefixWrapperCommands = (seg: Segment): string[] => {
1041
+ const spec = PREFIX_WRAPPER_SPECS[seg.head];
1042
+ if (spec === undefined) {
1043
+ return [];
1044
+ }
1045
+ if (spec.dashCommand) {
1046
+ const viaFlag = dashCommandArg(seg.tokens, 1);
1047
+ if (viaFlag !== null) {
1048
+ return viaFlag === '' ? [] : [viaFlag];
1049
+ }
1050
+ }
1051
+ let i = 1;
1052
+ while (i < seg.tokens.length) {
1053
+ const t = seg.tokens[i]!;
1054
+ if (spec.valueFlags.has(t)) {
1055
+ i += 2;
1056
+ continue;
1057
+ }
1058
+ if (t.includes('=') && spec.valueFlags.has(t.slice(0, t.indexOf('=')))) {
1059
+ i++;
1060
+ continue;
1061
+ }
1062
+ if (t === '--') {
1063
+ i++;
1064
+ break;
1065
+ }
1066
+ if (t.startsWith('-') && t !== '-') {
1067
+ i++;
1068
+ continue;
1069
+ }
1070
+ break;
1071
+ }
1072
+ i += spec.skipPositionals;
1073
+ const inner = seg.tokens.slice(i).join(' ');
1074
+ return inner === '' ? [] : [inner];
1075
+ };
1076
+
1077
+ // Each extractor pulls the inner command(s) a wrapper hides on its own arg
1078
+ // Vector, to be re-parsed as additional segments so every rule sees what
1079
+ // Actually runs. Order is irrelevant — all results are unioned into `out`.
1080
+ const SEGMENT_EXTRACTORS: readonly ((seg: Segment) => string[])[] = [
1081
+ extractExecCommands,
1082
+ extractShellWrappedCommands,
1083
+ extractHeadRenamingCommands,
1084
+ extractEvalCommands,
1085
+ extractRtkCommands,
1086
+ extractPrefixWrapperCommands,
1087
+ ];
1088
+
846
1089
  const parseCommand = (cmd: string): Segment[] => {
847
1090
  let entries: ParseEntry[];
848
1091
  const cmdForParsing = maskLiteralHeredocBodies(cmd);
@@ -894,24 +1137,11 @@ const parseCommand = (cmd: string): Segment[] => {
894
1137
  const preExtractLen = out.length;
895
1138
  for (let k = 0; k < preExtractLen; k++) {
896
1139
  const seg = out[k]!;
897
- for (const sub of extractExecCommands(seg)) {
898
- for (const innerSeg of parseCommand(sub)) {
899
- out.push(innerSeg);
900
- }
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);
1140
+ for (const extract of SEGMENT_EXTRACTORS) {
1141
+ for (const sub of extract(seg)) {
1142
+ for (const innerSeg of parseCommand(sub)) {
1143
+ out.push(innerSeg);
1144
+ }
915
1145
  }
916
1146
  }
917
1147
  }
@@ -1001,7 +1231,7 @@ const safeScopesSummary = (
1001
1231
  // A legitimate bypass marker sits on the actual command line, which the
1002
1232
  // Mask leaves intact.
1003
1233
  const hasBypass = (cmd: string): boolean =>
1004
- /(^|\s)#\s*tripwire-allow\b/.test(maskLiteralHeredocBodies(cmd));
1234
+ /(?<prefix>^|\s)#\s*tripwire-allow\b/.test(maskLiteralHeredocBodies(cmd));
1005
1235
 
1006
1236
  export type { Redirect, Segment };
1007
1237
  export {
package/src/lib/log.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, mkdirSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
+ // oxlint-disable-next-line unicorn/import-style
3
4
  import { dirname } from 'node:path';
4
5
 
5
6
  const LOG_PATH = `${homedir()}/.claude/tripwire.log`;
@@ -16,6 +16,7 @@
16
16
  import { spawnSync } from 'node:child_process';
17
17
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
+ // oxlint-disable-next-line unicorn/import-style
19
20
  import { join } from 'node:path';
20
21
 
21
22
  interface BetterleaksFinding {
@@ -1,5 +1,5 @@
1
1
  import { type Segment, hasBypass } from '../lib/bash';
2
- import { type Decision, allow, ask, deny } from '../lib/decision';
2
+ import { type Decision, allow, ask, deny, merge } from '../lib/decision';
3
3
 
4
4
  interface Spec {
5
5
  readonly rule: string;
@@ -32,7 +32,7 @@ const SPECS: readonly Spec[] = [
32
32
  match: (seg) =>
33
33
  seg.head === 'rm' &&
34
34
  flagPresent(seg, '-rf', '-fr', '-Rf', '-fR') &&
35
- seg.tokens.some((t) => /^(~|\$HOME|\$\{HOME\})$/.test(t)),
35
+ seg.tokens.some((t) => /^(?<home>~|\$HOME|\$\{HOME\})$/.test(t)),
36
36
  },
37
37
  {
38
38
  rule: 'fork-bomb',
@@ -51,13 +51,14 @@ const SPECS: readonly Spec[] = [
51
51
  rule: 'dd-raw-device',
52
52
  action: 'deny',
53
53
  message: 'dd writing to a raw block device wipes the disk. Refuse.',
54
- match: (seg) => seg.head === 'dd' && /\bof=\/dev\/(disk|sd|nvme|rdisk)/i.test(argsJoined(seg)),
54
+ match: (seg) =>
55
+ seg.head === 'dd' && /\bof=\/dev\/(?<type>disk|sd|nvme|rdisk)/i.test(argsJoined(seg)),
55
56
  },
56
57
  {
57
58
  rule: 'mkfs',
58
59
  action: 'deny',
59
60
  message: 'mkfs formats a filesystem. Refuse.',
60
- match: (seg) => /^mkfs(\.[a-z0-9]+)?$/i.test(seg.head),
61
+ match: (seg) => /^mkfs(?<ext>\.[a-z0-9]+)?$/i.test(seg.head),
61
62
  },
62
63
  {
63
64
  rule: 'kill-all',
@@ -162,7 +163,7 @@ const SPECS: readonly Spec[] = [
162
163
  rule: 'osascript',
163
164
  action: 'ask',
164
165
  message:
165
- 'osascript runs arbitrary AppleScript and can do almost anything (move files, send emails, drive apps). Confirm with Sean what you want done before running it.',
166
+ 'osascript runs arbitrary AppleScript and can do almost anything (move files, send emails, drive apps). Confirm with the user what you want done before running it.',
166
167
  match: (seg) => seg.head === 'osascript',
167
168
  },
168
169
  {
@@ -194,7 +195,7 @@ const SPECS: readonly Spec[] = [
194
195
  rule: 'pmset-write',
195
196
  action: 'deny',
196
197
  message:
197
- '`pmset` (with arguments) writes power-management settings. Read-only `pmset -g` is fine; mutations need Sean.',
198
+ '`pmset` (with arguments) writes power-management settings. Read-only `pmset -g` is fine; mutations need the user.',
198
199
  match: (seg) =>
199
200
  seg.head === 'pmset' &&
200
201
  seg.tokens.length > 1 &&
@@ -237,7 +238,7 @@ const SPECS: readonly Spec[] = [
237
238
  rule: 'kextload',
238
239
  action: 'deny',
239
240
  message:
240
- 'Loading a kernel extension (`kextload`, `kmutil load`) is a system-level mutation. Refuse — Sean handles this manually.',
241
+ 'Loading a kernel extension (`kextload`, `kmutil load`) is a system-level mutation. Refuse — the user handles this manually.',
241
242
  match: (seg) =>
242
243
  seg.head === 'kextload' ||
243
244
  seg.head === 'kextunload' ||
@@ -369,6 +370,11 @@ const UNBYPASSABLE_RULES: ReadonlySet<string> = new Set([
369
370
 
370
371
  const bashDeny = (segments: readonly Segment[], cmd: string): Decision => {
371
372
  const bypass = hasBypass(cmd);
373
+ // Collect the first matching spec per segment, then return the most
374
+ // Restrictive across all of them. Returning the first match outright lets
375
+ // A weaker `ask` on an early segment (e.g. the outer `sudo`) shadow a
376
+ // `deny` on a later unwrapped segment (e.g. the interior `shutdown`).
377
+ const hits: Decision[] = [];
372
378
  for (const seg of segments) {
373
379
  for (const s of SPECS) {
374
380
  if (!s.match(seg, cmd)) {
@@ -378,10 +384,11 @@ const bashDeny = (segments: readonly Segment[], cmd: string): Decision => {
378
384
  // Caller asserted in-turn approval; honor it for this rule.
379
385
  continue;
380
386
  }
381
- return s.action === 'deny' ? deny(s.rule, s.message) : ask(s.rule, s.message);
387
+ hits.push(s.action === 'deny' ? deny(s.rule, s.message) : ask(s.rule, s.message));
388
+ break;
382
389
  }
383
390
  }
384
- return allow('bash-deny');
391
+ return hits.length === 0 ? allow('bash-deny') : merge(hits);
385
392
  };
386
393
 
387
394
  export { bashDeny, UNBYPASSABLE_RULES };
@@ -19,7 +19,7 @@ import { type Decision, allow, ask, deny, warn } from '../lib/decision';
19
19
  // And no -F) — deny (would hang the agent).
20
20
  // - Rebase / cherry-pick / merge — ask (creates conflicts).
21
21
  // - Config — allow read; deny write to --global / --system; deny local
22
- // Write (Sean's identity / workflow).
22
+ // Write (the user's identity / workflow).
23
23
  //
24
24
  // `git -C <dir>`, `git --git-dir=<path>`, `git --work-tree=<path>`,
25
25
  // `git -c key=value` are stripped before subcommand dispatch — `git -C ../foo
@@ -38,7 +38,7 @@ const getProtectedBranches = (config: GitConfig): readonly string[] =>
38
38
 
39
39
  // Conventional Commits 1.0.0 — type(scope)?(!)?: description
40
40
  const CONVENTIONAL_RE =
41
- /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([\w./\- ]+\))?!?:\s+\S/;
41
+ /^(?<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?<scope>\([\w./\- ]+\))?!?:\s+\S/;
42
42
 
43
43
  const PRE_SUB_FLAG_TAKES_VALUE: ReadonlySet<string> = new Set([
44
44
  '-C',
@@ -502,7 +502,7 @@ const HANDLERS: ReadonlyMap<string, Handler> = new Map<string, Handler>([
502
502
  ({ subcommand }) =>
503
503
  warn(
504
504
  `git-${subcommand}`,
505
- `\`git ${subcommand}\` is allowed but unusual mid-session. Make sure this is what Sean asked for.`,
505
+ `\`git ${subcommand}\` is allowed but unusual mid-session. Make sure this is what the user asked for.`,
506
506
  ),
507
507
  ],
508
508
  [
@@ -510,7 +510,7 @@ const HANDLERS: ReadonlyMap<string, Handler> = new Map<string, Handler>([
510
510
  ({ subcommand }) =>
511
511
  warn(
512
512
  `git-${subcommand}`,
513
- `\`git ${subcommand}\` is allowed but unusual mid-session. Make sure this is what Sean asked for.`,
513
+ `\`git ${subcommand}\` is allowed but unusual mid-session. Make sure this is what the user asked for.`,
514
514
  ),
515
515
  ],
516
516
  ]);
@@ -56,7 +56,7 @@ const bashNetworkInstall = (segments: readonly Segment[], cmd: string): Decision
56
56
  if (isFetchPipedToShell(segments)) {
57
57
  return deny(
58
58
  'curl-pipe-shell',
59
- "Piping `curl` / `wget` directly into a shell runs whatever the remote URL serves. Refuse — download to a file, inspect, then run if appropriate. If you genuinely need this, append ` # tripwire-allow: <reason>` (and explain to Sean what you're running).",
59
+ "Piping `curl` / `wget` directly into a shell runs whatever the remote URL serves. Refuse — download to a file, inspect, then run if appropriate. If you genuinely need this, append ` # tripwire-allow: <reason>` (and explain to the user what you're running).",
60
60
  );
61
61
  }
62
62
  for (const seg of segments) {
@@ -8,38 +8,38 @@ import { type Decision, allow, deny } from '../lib/decision';
8
8
  const PROTECTED_TARGET_RE: readonly { rule: string; pattern: RegExp; message: string }[] = [
9
9
  {
10
10
  rule: 'redirect-env',
11
- pattern: /(^|\/)\.env(\.[^/]+)?$/,
11
+ pattern: /(?<prefix>^|\/)\.env(?<ext>\.[^/]+)?$/,
12
12
  message:
13
13
  'Refusing to write into a .env file via shell redirect / tee / cp / mv. .env files hold secrets — never overwrite from a tool call.',
14
14
  },
15
15
  {
16
16
  rule: 'redirect-dev-vars',
17
- pattern: /(^|\/)\.dev\.vars(\.[^/]+)?$/,
17
+ pattern: /(?<prefix>^|\/)\.dev\.vars(?<ext>\.[^/]+)?$/,
18
18
  message: 'Refusing to write into .dev.vars (Cloudflare/Wrangler secrets).',
19
19
  },
20
20
  {
21
21
  rule: 'redirect-ssh',
22
- pattern: /(^|\/)\.ssh\//,
22
+ pattern: /(?<prefix>^|\/)\.ssh\//,
23
23
  message: 'Refusing to write into ~/.ssh/ via shell.',
24
24
  },
25
25
  {
26
26
  rule: 'redirect-key',
27
- pattern: /\.(pem|key|p12|pfx)$/i,
27
+ pattern: /\.(?<ext>pem|key|p12|pfx)$/i,
28
28
  message: 'Refusing to overwrite a private-key-shaped file via shell.',
29
29
  },
30
30
  {
31
31
  rule: 'redirect-aws-credentials',
32
- pattern: /(^|\/)\.aws\/credentials$/,
32
+ pattern: /(?<prefix>^|\/)\.aws\/credentials$/,
33
33
  message: 'Refusing to write into ~/.aws/credentials via shell.',
34
34
  },
35
35
  {
36
36
  rule: 'redirect-netrc',
37
- pattern: /(^|\/)\.netrc$/,
37
+ pattern: /(?<prefix>^|\/)\.netrc$/,
38
38
  message: 'Refusing to write into ~/.netrc via shell.',
39
39
  },
40
40
  {
41
41
  rule: 'redirect-block-device',
42
- pattern: /^\/dev\/(sd|disk|nvme|rdisk)/i,
42
+ pattern: /^\/dev\/(?<type>sd|disk|nvme|rdisk)/i,
43
43
  message: 'Redirecting into a raw block device wipes the disk. Refuse.',
44
44
  },
45
45
  ];
@@ -77,7 +77,7 @@ const bashScopedRm = (
77
77
  .join('\n');
78
78
  return deny(
79
79
  'destructive-outside-safe-paths',
80
- `Destructive deletion outside known-safe scopes is blocked. Use \`trash\` (macOS Trash, recoverable) or \`rip\` (graveyard at ~/.local/share/graveyard, recoverable) instead. Real \`rm\` and \`find -delete\` are allowed only inside ephemeral build / cache / state directories:\n${safeScopesSummary(extraRelative, extraAbsolute)}\n\nFlagged targets:\n${detail}\n\nIf raw \`rm\` is genuinely needed, append \` # tripwire-allow: <reason>\` to the command.`,
80
+ `Destructive deletion outside known-safe scopes is blocked. Use \`trash\` (macOS Trash, recoverable) or \`rip\` (graveyard at /tmp/graveyard-$USER, recoverable until reboot) instead. Real \`rm\` and \`find -delete\` are allowed only inside ephemeral build / cache / state directories:\n${safeScopesSummary(extraRelative, extraAbsolute)}\n\nFlagged targets:\n${detail}\n\nIf raw \`rm\` is genuinely needed, append \` # tripwire-allow: <reason>\` to the command.`,
81
81
  );
82
82
  };
83
83
 
@@ -30,7 +30,7 @@ const findChangeDir = (seg: Segment): string | null => {
30
30
  };
31
31
 
32
32
  const isUnsafeExtractDest = (dest: string): boolean => {
33
- return dest === '/' || /^(~|\$HOME|\$\{HOME\})$/.test(dest);
33
+ return dest === '/' || /^(?<home>~|\$HOME|\$\{HOME\})$/.test(dest);
34
34
  };
35
35
 
36
36
  const bashTarExplosion = (segments: readonly Segment[], cmd: string): Decision => {