@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.
- package/dist/tripwire-cli.js +1 -1
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +53 -53
- package/dist/tripwire.js.jsc +0 -0
- package/package.json +3 -3
- package/src/lib/bash.ts +16 -12
- package/src/lib/grep-sanitize.ts +254 -0
- package/src/lib/rtk.ts +17 -1
package/dist/tripwire.js.jsc
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seanmozeik/tripwire",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
35
|
-
"effect": "^4.0.0-beta.
|
|
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
|
-
|
|
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
|
-
|
|
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',
|