@seanmozeik/tripwire 0.6.2 → 0.6.4
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 +63 -63
- package/dist/tripwire.js.jsc +0 -0
- package/package.json +1 -1
- package/src/dispatch.ts +41 -51
- package/src/index.ts +2 -2
- package/src/lib/bash.ts +44 -9
- package/src/lib/config.ts +42 -25
- package/src/lib/decision.ts +1 -14
- package/src/lib/grep-sanitize.ts +0 -254
- package/src/lib/rtk.ts +0 -112
package/src/lib/grep-sanitize.ts
DELETED
|
@@ -1,254 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
// Wrap the `rtk hook claude` subprocess. After tripwire's gate passes on a
|
|
2
|
-
// Bash tool call, we hand the original event to rtk to apply its
|
|
3
|
-
// Command-rewrite logic (token-saver). If rtk returns an updatedInput, we
|
|
4
|
-
// Merge that into our hook response.
|
|
5
|
-
|
|
6
|
-
import { spawnSync } from 'node:child_process';
|
|
7
|
-
|
|
8
|
-
import type { RtkConfig } from './config';
|
|
9
|
-
import { sanitizeGrepFlags } from './grep-sanitize';
|
|
10
|
-
|
|
11
|
-
interface RtkOutput {
|
|
12
|
-
readonly updatedCommand?: string;
|
|
13
|
-
readonly reason?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const findRtkBin = (config: RtkConfig): string | null => {
|
|
17
|
-
// If config specifies a path, try it first
|
|
18
|
-
if (config.path !== undefined) {
|
|
19
|
-
return config.path;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Try common locations
|
|
23
|
-
const home = process.env['HOME'] ?? '';
|
|
24
|
-
const commonPaths = ['/opt/homebrew/bin/rtk', '/usr/local/bin/rtk', `${home}/.local/bin/rtk`];
|
|
25
|
-
|
|
26
|
-
for (const path of commonPaths) {
|
|
27
|
-
try {
|
|
28
|
-
spawnSync('test', ['-x', path], { stdio: 'ignore' });
|
|
29
|
-
return path;
|
|
30
|
-
} catch {
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Try searching PATH
|
|
36
|
-
try {
|
|
37
|
-
const whichResult = spawnSync('which', ['rtk'], { stdio: 'pipe' });
|
|
38
|
-
if (whichResult.status === 0) {
|
|
39
|
-
const stdout = whichResult.stdout as string | Buffer | null;
|
|
40
|
-
if (stdout !== null) {
|
|
41
|
-
const path = String(stdout).trim();
|
|
42
|
-
if (path.length > 0) {
|
|
43
|
-
return path;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
} catch {
|
|
48
|
-
// Ignore errors
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return null;
|
|
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
|
-
|
|
69
|
-
const runRtkRewrite = (event: unknown, config: RtkConfig, timeoutMs = 2000): RtkOutput => {
|
|
70
|
-
// If rtk is disabled, skip it
|
|
71
|
-
if (!config.enabled) {
|
|
72
|
-
return {};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const rtkBin = findRtkBin(config);
|
|
76
|
-
if (rtkBin === null) {
|
|
77
|
-
// Rtk not found, silently continue
|
|
78
|
-
return {};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const payload = JSON.stringify(sanitizeRtkEvent(event));
|
|
82
|
-
const result = spawnSync(rtkBin, ['hook', 'claude'], {
|
|
83
|
-
input: payload,
|
|
84
|
-
encoding: 'utf8',
|
|
85
|
-
timeout: timeoutMs,
|
|
86
|
-
maxBuffer: 1024 * 1024,
|
|
87
|
-
});
|
|
88
|
-
if (result.error !== undefined || typeof result.stdout !== 'string') {
|
|
89
|
-
return {};
|
|
90
|
-
}
|
|
91
|
-
try {
|
|
92
|
-
const parsed = JSON.parse(result.stdout) as {
|
|
93
|
-
hookSpecificOutput?: {
|
|
94
|
-
permissionDecisionReason?: string;
|
|
95
|
-
updatedInput?: { command?: string };
|
|
96
|
-
};
|
|
97
|
-
};
|
|
98
|
-
const cmd = parsed.hookSpecificOutput?.updatedInput?.command;
|
|
99
|
-
const reason = parsed.hookSpecificOutput?.permissionDecisionReason;
|
|
100
|
-
if (typeof cmd !== 'string') {
|
|
101
|
-
return {};
|
|
102
|
-
}
|
|
103
|
-
if (typeof reason === 'string') {
|
|
104
|
-
return { updatedCommand: cmd, reason };
|
|
105
|
-
}
|
|
106
|
-
return { updatedCommand: cmd };
|
|
107
|
-
} catch {
|
|
108
|
-
return {};
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
export { runRtkRewrite };
|