@seanmozeik/tripwire 0.5.3 → 0.6.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.5.3",
3
+ "version": "0.6.1",
4
4
  "description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -31,16 +31,16 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@effect/platform-bun": "^4.0.0-beta.66",
35
- "effect": "^4.0.0-beta.66",
36
- "shell-quote": "^1.8.3"
34
+ "@effect/platform-bun": "^4.0.0-beta.78",
35
+ "effect": "^4.0.0-beta.78",
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
@@ -209,7 +209,17 @@ const parseSegment = (entries: readonly ParseEntry[], fdBudget: FdBudget): Segme
209
209
  args.push(t);
210
210
  }
211
211
  }
212
- return { head: tokens[0]!, tokens, args, flags, redirects, raw: tokens.join(' ') };
212
+ // Normalise the head to its basename so command-name rules match regardless
213
+ // Of whether the command was invoked via absolute path (/bin/rm), a
214
+ // Homebrew-prefixed path (/opt/homebrew/bin/gog), or a relative ./rm form.
215
+ // This fixes the containment hole where `/bin/rm <unsafe>` bypassed every
216
+ // Rule that compared `seg.head === 'rm'`. The `tokens` array is left
217
+ // Unchanged (raw reconstruction stays accurate); only the canonical `head`
218
+ // Used for matching is normalised.
219
+ const rawHead = tokens[0]!;
220
+ const slashIdx = rawHead.lastIndexOf('/');
221
+ const head = slashIdx === -1 ? rawHead : rawHead.slice(slashIdx + 1);
222
+ return { head, tokens, args, flags, redirects, raw: tokens.join(' ') };
213
223
  };
214
224
 
215
225
  // Pass an env function that preserves variable references as literals,
@@ -257,8 +267,13 @@ const countFdPrefixRedirects = (cmd: string): number => {
257
267
  };
258
268
 
259
269
  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;
270
+ const match =
271
+ /<<-?\s*(?:"(?<quoted>[^"]+)"|'(?<single>[^']+)'|(?<unquoted>[A-Za-z_][A-Za-z0-9_]*))/u.exec(
272
+ line,
273
+ );
274
+ return (
275
+ match?.groups?.['quoted'] ?? match?.groups?.['single'] ?? match?.groups?.['unquoted'] ?? null
276
+ );
262
277
  };
263
278
 
264
279
  const SHELL_STDIN_HEAD_RE =
@@ -483,7 +498,7 @@ const pathDangerScore = (t: string): number => {
483
498
  if (t === '~' || HOME_VAR_RE.test(t)) {
484
499
  return 90;
485
500
  }
486
- if (/^\/(etc|usr|bin|sbin|System|Library|var|boot|root|home)(\/|$)/.test(t)) {
501
+ if (/^\/(?<dir>etc|usr|bin|sbin|System|Library|var|boot|root|home)(?<suffix>\/|$)/.test(t)) {
487
502
  return 80;
488
503
  }
489
504
  if (t.startsWith('/Users/')) {
@@ -679,15 +694,16 @@ const extractExecCommands = (seg: Segment): string[] => {
679
694
  // Treats substitutions as opaque sentinels — and any rule that touches
680
695
  // Agent-controlled content should route through one of them.
681
696
 
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;
697
+ const HEREDOC_SUBST_RE =
698
+ /\$\(\s*cat\s+<<-?\s*['"]?(?<delimiter>\w+)['"]?\s*\n(?<body>[\s\S]*?)\n\s*\k<delimiter>\s*\)/u;
699
+ const ECHO_PRINTF_SUBST_RE = /\$\(\s*(?:printf|echo)\s+(?:-[a-zA-Z]+\s+)*'(?<content>[^']*)'/u;
684
700
 
685
701
  const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, string>): string => {
686
702
  const heredoc = HEREDOC_SUBST_RE.exec(value);
687
703
  if (heredoc !== null) {
688
- const captured = heredoc[2] ?? value;
704
+ const captured = heredoc.groups?.['body'] ?? value;
689
705
  if (heredocBodies !== undefined && captured.trim() === '__HEREDOC_BODY__') {
690
- const delimiter = heredoc[1];
706
+ const delimiter = heredoc.groups?.['delimiter'];
691
707
  const real = delimiter === undefined ? undefined : heredocBodies.get(delimiter);
692
708
  if (real !== undefined) {
693
709
  return real;
@@ -697,7 +713,7 @@ const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, s
697
713
  }
698
714
  const printf = ECHO_PRINTF_SUBST_RE.exec(value);
699
715
  if (printf !== null) {
700
- return printf[1] ?? value;
716
+ return printf.groups?.['content'] ?? value;
701
717
  }
702
718
  return value;
703
719
  };
@@ -708,6 +724,8 @@ const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, s
708
724
  // Otherwise sees `sh` as the head and the script as an opaque positional
709
725
  // Arg. We pull the script out and feed it back through `parseCommand` so
710
726
  // All existing rules apply.
727
+ // Head normalisation in `parseSegment` strips directory prefixes, so this set
728
+ // Only needs bare basenames — `/bin/bash` etc. are now unreachable as heads.
711
729
  const SHELL_WRAPPER_HEADS: ReadonlySet<string> = new Set([
712
730
  'sh',
713
731
  'bash',
@@ -715,16 +733,6 @@ const SHELL_WRAPPER_HEADS: ReadonlySet<string> = new Set([
715
733
  'dash',
716
734
  'ksh',
717
735
  '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
736
  ]);
729
737
 
730
738
  const extractShellWrappedCommands = (seg: Segment): string[] => {
@@ -752,6 +760,13 @@ const extractShellWrappedCommands = (seg: Segment): string[] => {
752
760
  return [];
753
761
  };
754
762
 
763
+ // Heads that take `[flags] <command> [args]` on the same arg vector: the
764
+ // First non-flag token after the prefix is the real command. `sudo`/`doas`
765
+ // (privilege escalation), `xargs` (stdin-driven exec), and `watch` (repeated
766
+ // Exec) all hide a sibling command this way, so the same unwrap that handles
767
+ // `command`/`env`/`nohup` applies. `sudo` is also matched by bash-deny's
768
+ // `ask` rule on the outer segment — both fire, and the more-restrictive
769
+ // Interior decision (e.g. `sudo rm -rf /` → deny) wins on merge.
755
770
  const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
756
771
  'command',
757
772
  'exec',
@@ -766,6 +781,10 @@ const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
766
781
  'unbuffer',
767
782
  'script',
768
783
  'taskset',
784
+ 'sudo',
785
+ 'doas',
786
+ 'xargs',
787
+ 'watch',
769
788
  ]);
770
789
 
771
790
  const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
@@ -777,6 +796,52 @@ const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> =
777
796
  stdbuf: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
778
797
  script: new Set(['-c', '--command']),
779
798
  taskset: new Set(),
799
+ sudo: new Set([
800
+ '-u',
801
+ '--user',
802
+ '-g',
803
+ '--group',
804
+ '-C',
805
+ '--close-from',
806
+ '-D',
807
+ '--chdir',
808
+ '-h',
809
+ '--host',
810
+ '-p',
811
+ '--prompt',
812
+ '-r',
813
+ '--role',
814
+ '-t',
815
+ '--type',
816
+ '-U',
817
+ '--other-user',
818
+ '-R',
819
+ '--chroot',
820
+ '-T',
821
+ '--command-timeout',
822
+ ]),
823
+ doas: new Set(['-a', '-C', '-u']),
824
+ xargs: new Set([
825
+ '-I',
826
+ '-i',
827
+ '-J',
828
+ '-n',
829
+ '--max-args',
830
+ '-P',
831
+ '--max-procs',
832
+ '-s',
833
+ '--max-chars',
834
+ '-L',
835
+ '--max-lines',
836
+ '-E',
837
+ '--eof',
838
+ '-d',
839
+ '--delimiter',
840
+ '-a',
841
+ '--arg-file',
842
+ '--replace',
843
+ ]),
844
+ watch: new Set(['-n', '--interval']),
780
845
  };
781
846
 
782
847
  const tokenLooksLikeEnvAssignment = (token: string): boolean =>
@@ -843,6 +908,188 @@ const extractEvalCommands = (seg: Segment): string[] => {
843
908
  return sub === '' ? [] : [sub];
844
909
  };
845
910
 
911
+ // ── rtk (token-optimizing CLI proxy) ─────────────────────────────────
912
+ // Rtk wraps real commands so their output is filtered before reaching the
913
+ // Agent's context, and Codex auto-prepends it. The wrapper hides the real
914
+ // Command from every rule: `rtk proxy rm -rf /` parses with head `rtk` and
915
+ // The destructive `rm` buried in opaque positional args. Strip the `rtk`
916
+ // Prefix and reconstruct the interior command so the existing rules decide
917
+ // On what actually runs.
918
+ //
919
+ // Grammar: `rtk [global-opts] <subcommand> [args]`. Two subcommand classes
920
+ // Exec a sibling command:
921
+ // • wrapper subs — the keyword is dropped, the remainder is an arbitrary
922
+ // Command: `run` (also `-c <string>`), `proxy`, `err`, `test`, `summary`.
923
+ // • tool-proxy subs — the keyword *is* the binary: `git`, `find`, `npm`,
924
+ // `docker`, … Reconstructing from the subcommand onward yields the real
925
+ // Invocation (`git push …`, `find … -delete`). rtk-internal filters that
926
+ // Aren't real binaries (`gain`, `config`, `diff`, …) reconstruct to inert
927
+ // Heads no rule matches, so no allowlist is needed.
928
+ const RTK_WRAPPER_SUBCOMMANDS: ReadonlySet<string> = new Set([
929
+ 'run',
930
+ 'proxy',
931
+ 'err',
932
+ 'test',
933
+ 'summary',
934
+ ]);
935
+
936
+ // Head normalisation in `parseSegment` strips directory prefixes, so only the
937
+ // Bare basename is needed — the `endsWith` fallbacks are now unreachable.
938
+ const isRtkHead = (head: string): boolean => head === 'rtk';
939
+
940
+ const skipRtkGlobalFlags = (tokens: readonly string[]): number => {
941
+ // Rtk's global options (`-v`/`-vv`/`--verbose`, `--ultra-compact`,
942
+ // `--skip-env`) are all boolean, so any leading flag token can be skipped.
943
+ let i = 1;
944
+ while (i < tokens.length) {
945
+ const t = tokens[i]!;
946
+ if (t.startsWith('-') && t !== '-' && t !== '--') {
947
+ i++;
948
+ continue;
949
+ }
950
+ break;
951
+ }
952
+ return i;
953
+ };
954
+
955
+ const dashCommandArg = (tokens: readonly string[], start: number): string | null => {
956
+ for (let k = start; k < tokens.length; k++) {
957
+ const t = tokens[k]!;
958
+ if ((t === '-c' || t === '--command') && k + 1 < tokens.length) {
959
+ return tokens[k + 1]!;
960
+ }
961
+ if (t.startsWith('--command=')) {
962
+ return t.slice('--command='.length);
963
+ }
964
+ }
965
+ return null;
966
+ };
967
+
968
+ const extractRtkCommands = (seg: Segment): string[] => {
969
+ if (!isRtkHead(seg.head)) {
970
+ return [];
971
+ }
972
+ const subIdx = skipRtkGlobalFlags(seg.tokens);
973
+ const sub = seg.tokens[subIdx];
974
+ if (sub === undefined) {
975
+ return [];
976
+ }
977
+ if (RTK_WRAPPER_SUBCOMMANDS.has(sub)) {
978
+ // `rtk run -c '<cmd>'` carries the command in a flag value.
979
+ const viaFlag = dashCommandArg(seg.tokens, subIdx + 1);
980
+ if (viaFlag !== null) {
981
+ return viaFlag === '' ? [] : [viaFlag];
982
+ }
983
+ // Otherwise the command is the positional remainder after the keyword,
984
+ // Skipping any wrapper-local boolean flags.
985
+ let j = subIdx + 1;
986
+ while (j < seg.tokens.length) {
987
+ const t = seg.tokens[j]!;
988
+ if (t === '--') {
989
+ j++;
990
+ break;
991
+ }
992
+ if (t.startsWith('-') && t !== '-') {
993
+ j++;
994
+ continue;
995
+ }
996
+ break;
997
+ }
998
+ const inner = seg.tokens.slice(j).join(' ');
999
+ return inner === '' ? [] : [inner];
1000
+ }
1001
+ // Tool-proxy subcommand: the subcommand token is the real binary name.
1002
+ const inner = seg.tokens.slice(subIdx).join(' ');
1003
+ return inner === '' ? [] : [inner];
1004
+ };
1005
+
1006
+ // ── Positional-prefix wrappers ───────────────────────────────────────
1007
+ // Heads where the real command follows one or more positional arguments
1008
+ // The wrapper consumes itself: `timeout <duration> <cmd>`, `chroot <newroot>
1009
+ // <cmd>`, `flock <lockfile> <cmd>` (or `flock <lockfile> -c '<cmd>'`), and
1010
+ // `su [user] -c '<cmd>'`. Skip the flag region, drop the wrapper's own
1011
+ // Positionals, and the remainder is the command.
1012
+ interface PrefixWrapperSpec {
1013
+ readonly valueFlags: ReadonlySet<string>;
1014
+ // Positional args the wrapper consumes before the command (duration, newroot…).
1015
+ readonly skipPositionals: number;
1016
+ // Whether the command can also arrive via `-c <string>` (su, flock).
1017
+ readonly dashCommand: boolean;
1018
+ }
1019
+
1020
+ const PREFIX_WRAPPER_SPECS: Readonly<Record<string, PrefixWrapperSpec>> = {
1021
+ timeout: {
1022
+ valueFlags: new Set(['-s', '--signal', '-k', '--kill-after']),
1023
+ skipPositionals: 1,
1024
+ dashCommand: false,
1025
+ },
1026
+ gtimeout: {
1027
+ valueFlags: new Set(['-s', '--signal', '-k', '--kill-after']),
1028
+ skipPositionals: 1,
1029
+ dashCommand: false,
1030
+ },
1031
+ chroot: {
1032
+ valueFlags: new Set(['--userspec', '--groups']),
1033
+ skipPositionals: 1,
1034
+ dashCommand: false,
1035
+ },
1036
+ flock: {
1037
+ valueFlags: new Set(['-w', '--wait', '--timeout', '-E', '--conflict-exit-code']),
1038
+ skipPositionals: 1,
1039
+ dashCommand: true,
1040
+ },
1041
+ su: { valueFlags: new Set(), skipPositionals: 0, dashCommand: true },
1042
+ };
1043
+
1044
+ const extractPrefixWrapperCommands = (seg: Segment): string[] => {
1045
+ const spec = PREFIX_WRAPPER_SPECS[seg.head];
1046
+ if (spec === undefined) {
1047
+ return [];
1048
+ }
1049
+ if (spec.dashCommand) {
1050
+ const viaFlag = dashCommandArg(seg.tokens, 1);
1051
+ if (viaFlag !== null) {
1052
+ return viaFlag === '' ? [] : [viaFlag];
1053
+ }
1054
+ }
1055
+ let i = 1;
1056
+ while (i < seg.tokens.length) {
1057
+ const t = seg.tokens[i]!;
1058
+ if (spec.valueFlags.has(t)) {
1059
+ i += 2;
1060
+ continue;
1061
+ }
1062
+ if (t.includes('=') && spec.valueFlags.has(t.slice(0, t.indexOf('=')))) {
1063
+ i++;
1064
+ continue;
1065
+ }
1066
+ if (t === '--') {
1067
+ i++;
1068
+ break;
1069
+ }
1070
+ if (t.startsWith('-') && t !== '-') {
1071
+ i++;
1072
+ continue;
1073
+ }
1074
+ break;
1075
+ }
1076
+ i += spec.skipPositionals;
1077
+ const inner = seg.tokens.slice(i).join(' ');
1078
+ return inner === '' ? [] : [inner];
1079
+ };
1080
+
1081
+ // Each extractor pulls the inner command(s) a wrapper hides on its own arg
1082
+ // Vector, to be re-parsed as additional segments so every rule sees what
1083
+ // Actually runs. Order is irrelevant — all results are unioned into `out`.
1084
+ const SEGMENT_EXTRACTORS: readonly ((seg: Segment) => string[])[] = [
1085
+ extractExecCommands,
1086
+ extractShellWrappedCommands,
1087
+ extractHeadRenamingCommands,
1088
+ extractEvalCommands,
1089
+ extractRtkCommands,
1090
+ extractPrefixWrapperCommands,
1091
+ ];
1092
+
846
1093
  const parseCommand = (cmd: string): Segment[] => {
847
1094
  let entries: ParseEntry[];
848
1095
  const cmdForParsing = maskLiteralHeredocBodies(cmd);
@@ -894,24 +1141,11 @@ const parseCommand = (cmd: string): Segment[] => {
894
1141
  const preExtractLen = out.length;
895
1142
  for (let k = 0; k < preExtractLen; k++) {
896
1143
  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);
1144
+ for (const extract of SEGMENT_EXTRACTORS) {
1145
+ for (const sub of extract(seg)) {
1146
+ for (const innerSeg of parseCommand(sub)) {
1147
+ out.push(innerSeg);
1148
+ }
915
1149
  }
916
1150
  }
917
1151
  }
@@ -1001,7 +1235,7 @@ const safeScopesSummary = (
1001
1235
  // A legitimate bypass marker sits on the actual command line, which the
1002
1236
  // Mask leaves intact.
1003
1237
  const hasBypass = (cmd: string): boolean =>
1004
- /(^|\s)#\s*tripwire-allow\b/.test(maskLiteralHeredocBodies(cmd));
1238
+ /(?<prefix>^|\s)#\s*tripwire-allow\b/.test(maskLiteralHeredocBodies(cmd));
1005
1239
 
1006
1240
  export type { Redirect, Segment };
1007
1241
  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 => {