@seanmozeik/tripwire 0.6.0 → 0.6.2

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.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -31,8 +31,8 @@
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",
34
+ "@effect/platform-bun": "^4.0.0-beta.78",
35
+ "effect": "^4.0.0-beta.78",
36
36
  "shell-quote": "^1.8.4"
37
37
  },
38
38
  "devDependencies": {
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,
@@ -714,6 +724,8 @@ const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, s
714
724
  // Otherwise sees `sh` as the head and the script as an opaque positional
715
725
  // Arg. We pull the script out and feed it back through `parseCommand` so
716
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.
717
729
  const SHELL_WRAPPER_HEADS: ReadonlySet<string> = new Set([
718
730
  'sh',
719
731
  'bash',
@@ -721,16 +733,6 @@ const SHELL_WRAPPER_HEADS: ReadonlySet<string> = new Set([
721
733
  'dash',
722
734
  'ksh',
723
735
  'ash',
724
- '/bin/sh',
725
- '/bin/bash',
726
- '/bin/zsh',
727
- '/bin/dash',
728
- '/bin/ksh',
729
- '/usr/bin/sh',
730
- '/usr/bin/bash',
731
- '/usr/bin/zsh',
732
- '/usr/local/bin/bash',
733
- '/opt/homebrew/bin/bash',
734
736
  ]);
735
737
 
736
738
  const extractShellWrappedCommands = (seg: Segment): string[] => {
@@ -931,7 +933,9 @@ const RTK_WRAPPER_SUBCOMMANDS: ReadonlySet<string> = new Set([
931
933
  'summary',
932
934
  ]);
933
935
 
934
- const isRtkHead = (head: string): boolean => head === 'rtk' || head.endsWith('/rtk');
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';
935
939
 
936
940
  const skipRtkGlobalFlags = (tokens: readonly string[]): number => {
937
941
  // Rtk's global options (`-v`/`-vv`/`--verbose`, `--ultra-compact`,
@@ -0,0 +1,254 @@
1
+ interface Token {
2
+ readonly raw: string;
3
+ readonly value: string;
4
+ readonly start: number;
5
+ readonly end: number;
6
+ }
7
+
8
+ interface SegmentPart {
9
+ readonly kind: 'segment';
10
+ readonly text: string;
11
+ }
12
+
13
+ interface SeparatorPart {
14
+ readonly kind: 'separator';
15
+ readonly text: string;
16
+ }
17
+
18
+ type Part = SegmentPart | SeparatorPart;
19
+
20
+ const GREP_HEADS: ReadonlySet<string> = new Set(['grep', 'rg', 'egrep', 'fgrep']);
21
+ const COLLISION_SHORT: ReadonlySet<string> = new Set(['r', 'R', 'E']);
22
+ const COLLISION_LONG: ReadonlySet<string> = new Set([
23
+ '--recursive',
24
+ '--dereference-recursive',
25
+ '--extended-regexp',
26
+ ]);
27
+ const VALUE_SHORT: ReadonlySet<string> = new Set(['A', 'B', 'C', 'm', 'd', 'D', 'e', 'f']);
28
+
29
+ const basename = (head: string): string => {
30
+ const slashIdx = head.lastIndexOf('/');
31
+ return slashIdx === -1 ? head : head.slice(slashIdx + 1);
32
+ };
33
+
34
+ const splitShellSegments = (command: string): Part[] => {
35
+ const parts: Part[] = [];
36
+ let quote: "'" | '"' | null = null;
37
+ let segmentStart = 0;
38
+ let i = 0;
39
+
40
+ while (i < command.length) {
41
+ const ch = command[i]!;
42
+ if (ch === '\\') {
43
+ i += 2;
44
+ continue;
45
+ }
46
+ if (quote === "'") {
47
+ if (ch === "'") {
48
+ quote = null;
49
+ }
50
+ i++;
51
+ continue;
52
+ }
53
+ if (quote === '"') {
54
+ if (ch === '"') {
55
+ quote = null;
56
+ }
57
+ i++;
58
+ continue;
59
+ }
60
+ if (ch === "'" || ch === '"') {
61
+ quote = ch;
62
+ i++;
63
+ continue;
64
+ }
65
+
66
+ const two = command.slice(i, i + 2);
67
+ const op = two === '&&' || two === '||' || two === '|&' ? two : ch;
68
+ if (op === ';' || op === '&' || op === '|' || op === '&&' || op === '||' || op === '|&') {
69
+ if (i > segmentStart) {
70
+ parts.push({ kind: 'segment', text: command.slice(segmentStart, i) });
71
+ }
72
+ parts.push({ kind: 'separator', text: op });
73
+ i += op.length;
74
+ segmentStart = i;
75
+ continue;
76
+ }
77
+ i++;
78
+ }
79
+
80
+ if (segmentStart < command.length) {
81
+ parts.push({ kind: 'segment', text: command.slice(segmentStart) });
82
+ }
83
+ return parts;
84
+ };
85
+
86
+ const tokenizeSegment = (segment: string): Token[] => {
87
+ const tokens: Token[] = [];
88
+ let quote: "'" | '"' | null = null;
89
+ let raw = '';
90
+ let value = '';
91
+ let start = 0;
92
+
93
+ const flush = (end: number): void => {
94
+ if (raw.length === 0) {
95
+ return;
96
+ }
97
+ tokens.push({ raw, value, start, end });
98
+ raw = '';
99
+ value = '';
100
+ };
101
+
102
+ for (let i = 0; i < segment.length; i++) {
103
+ const ch = segment[i]!;
104
+ if (raw.length === 0 && !/\s/u.test(ch)) {
105
+ start = i;
106
+ }
107
+ if (ch === '\\') {
108
+ raw += ch;
109
+ const next = segment[i + 1];
110
+ if (next !== undefined) {
111
+ raw += next;
112
+ value += next;
113
+ i++;
114
+ }
115
+ continue;
116
+ }
117
+ if (quote === "'") {
118
+ raw += ch;
119
+ if (ch === "'") {
120
+ quote = null;
121
+ } else {
122
+ value += ch;
123
+ }
124
+ continue;
125
+ }
126
+ if (quote === '"') {
127
+ raw += ch;
128
+ if (ch === '"') {
129
+ quote = null;
130
+ } else {
131
+ value += ch;
132
+ }
133
+ continue;
134
+ }
135
+ if (ch === "'" || ch === '"') {
136
+ raw += ch;
137
+ quote = ch;
138
+ continue;
139
+ }
140
+ if (/\s/u.test(ch)) {
141
+ flush(i);
142
+ continue;
143
+ }
144
+ raw += ch;
145
+ value += ch;
146
+ }
147
+ flush(segment.length);
148
+ return tokens;
149
+ };
150
+
151
+ const sanitizeShortFlag = (flag: string): string | null => {
152
+ let out = '-';
153
+ for (let i = 1; i < flag.length; i++) {
154
+ const letter = flag[i]!;
155
+ if (VALUE_SHORT.has(letter)) {
156
+ out += flag.slice(i);
157
+ return out.length === 1 ? null : out;
158
+ }
159
+ if (!COLLISION_SHORT.has(letter)) {
160
+ out += letter;
161
+ }
162
+ }
163
+ return out.length === 1 ? null : out;
164
+ };
165
+
166
+ const sanitizedTokenValues = (tokens: readonly Token[]): ReadonlyMap<Token, string | null> => {
167
+ const changed = new Map<Token, string | null>();
168
+ let optionsEnded = false;
169
+ let skipValueFor: string | null = null;
170
+
171
+ for (const token of tokens) {
172
+ if (skipValueFor !== null) {
173
+ skipValueFor = null;
174
+ continue;
175
+ }
176
+ if (optionsEnded) {
177
+ continue;
178
+ }
179
+ if (token.value === '--') {
180
+ optionsEnded = true;
181
+ continue;
182
+ }
183
+ if (COLLISION_LONG.has(token.value)) {
184
+ changed.set(token, null);
185
+ continue;
186
+ }
187
+ if (token.value.startsWith('--')) {
188
+ continue;
189
+ }
190
+ if (token.value.startsWith('-') && token.value !== '-') {
191
+ const clean = sanitizeShortFlag(token.value);
192
+ if (clean !== token.value) {
193
+ changed.set(token, clean);
194
+ }
195
+ if (clean?.length === 2 && VALUE_SHORT.has(clean.at(-1)!)) {
196
+ skipValueFor = clean.at(-1)!;
197
+ }
198
+ }
199
+ }
200
+ return changed;
201
+ };
202
+
203
+ const applyTokenChanges = (
204
+ body: string,
205
+ tokens: readonly Token[],
206
+ changes: ReadonlyMap<Token, string | null>,
207
+ ): string => {
208
+ let out = '';
209
+ let cursor = 0;
210
+
211
+ for (let i = 0; i < tokens.length; i++) {
212
+ const token = tokens[i]!;
213
+ if (!changes.has(token)) {
214
+ out += body.slice(cursor, token.end);
215
+ cursor = token.end;
216
+ continue;
217
+ }
218
+ const replacement = changes.get(token) ?? null;
219
+ out += body.slice(cursor, token.start);
220
+ if (replacement === null) {
221
+ cursor = token.end;
222
+ const next = tokens[i + 1];
223
+ if (next === undefined) {
224
+ out = out.trimEnd();
225
+ } else if (/\s$/u.test(out)) {
226
+ cursor = next.start;
227
+ }
228
+ continue;
229
+ }
230
+ out += replacement;
231
+ cursor = token.end;
232
+ }
233
+
234
+ return out + body.slice(cursor);
235
+ };
236
+
237
+ const sanitizeSegment = (segment: string): string => {
238
+ const leading = /^\s*/u.exec(segment)?.[0] ?? '';
239
+ const trailing = /\s*$/u.exec(segment)?.[0] ?? '';
240
+ const body = segment.slice(leading.length, segment.length - trailing.length);
241
+ const tokens = tokenizeSegment(body);
242
+ const head = tokens[0];
243
+ if (head === undefined || !GREP_HEADS.has(basename(head.value))) {
244
+ return segment;
245
+ }
246
+ return `${leading}${applyTokenChanges(body, tokens, sanitizedTokenValues(tokens))}${trailing}`;
247
+ };
248
+
249
+ const sanitizeGrepFlags = (command: string): string =>
250
+ splitShellSegments(command)
251
+ .map((part) => (part.kind === 'segment' ? sanitizeSegment(part.text) : part.text))
252
+ .join('');
253
+
254
+ export { sanitizeGrepFlags };
package/src/lib/rtk.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { spawnSync } from 'node:child_process';
7
7
 
8
8
  import type { RtkConfig } from './config';
9
+ import { sanitizeGrepFlags } from './grep-sanitize';
9
10
 
10
11
  interface RtkOutput {
11
12
  readonly updatedCommand?: string;
@@ -50,6 +51,21 @@ const findRtkBin = (config: RtkConfig): string | null => {
50
51
  return null;
51
52
  };
52
53
 
54
+ const sanitizeRtkEvent = (event: unknown): unknown => {
55
+ if (typeof event !== 'object' || event === null) {
56
+ return event;
57
+ }
58
+ const toolInput = (event as { readonly tool_input?: unknown }).tool_input;
59
+ if (typeof toolInput !== 'object' || toolInput === null) {
60
+ return event;
61
+ }
62
+ const command = (toolInput as { readonly command?: unknown }).command;
63
+ if (typeof command !== 'string') {
64
+ return event;
65
+ }
66
+ return { ...event, tool_input: { ...toolInput, command: sanitizeGrepFlags(command) } };
67
+ };
68
+
53
69
  const runRtkRewrite = (event: unknown, config: RtkConfig, timeoutMs = 2000): RtkOutput => {
54
70
  // If rtk is disabled, skip it
55
71
  if (!config.enabled) {
@@ -62,7 +78,7 @@ const runRtkRewrite = (event: unknown, config: RtkConfig, timeoutMs = 2000): Rtk
62
78
  return {};
63
79
  }
64
80
 
65
- const payload = JSON.stringify(event);
81
+ const payload = JSON.stringify(sanitizeRtkEvent(event));
66
82
  const result = spawnSync(rtkBin, ['hook', 'claude'], {
67
83
  input: payload,
68
84
  encoding: 'utf8',