@seanmozeik/tripwire 0.6.6 → 0.7.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.
@@ -0,0 +1,336 @@
1
+ import type { HookEvent } from './event';
2
+
3
+ type JsonRecord = Record<string, unknown>;
4
+
5
+ type HookHost =
6
+ | { readonly kind: 'native' }
7
+ | { readonly kind: 'cursor'; readonly eventName: string; readonly post: boolean };
8
+
9
+ interface NormalizedHookInput {
10
+ readonly event: unknown;
11
+ readonly host: HookHost;
12
+ }
13
+
14
+ const CURSOR_PRE_EVENTS = new Set([
15
+ 'preToolUse',
16
+ 'beforeShellExecution',
17
+ 'beforeMCPExecution',
18
+ 'beforeReadFile',
19
+ 'beforeTabFileRead',
20
+ ]);
21
+
22
+ const CURSOR_POST_EVENTS = new Set([
23
+ 'postToolUse',
24
+ 'postToolUseFailure',
25
+ 'afterShellExecution',
26
+ 'afterMCPExecution',
27
+ 'afterFileEdit',
28
+ 'afterTabFileEdit',
29
+ ]);
30
+
31
+ const cursorHost = (eventName: string): HookHost => ({
32
+ kind: 'cursor',
33
+ eventName,
34
+ post: CURSOR_POST_EVENTS.has(eventName),
35
+ });
36
+
37
+ const isCursorEventName = (eventName: string): boolean =>
38
+ CURSOR_PRE_EVENTS.has(eventName) || CURSOR_POST_EVENTS.has(eventName);
39
+
40
+ const isRecord = (value: unknown): value is JsonRecord =>
41
+ typeof value === 'object' && value !== null && !Array.isArray(value);
42
+
43
+ const stringField = (value: JsonRecord, ...names: readonly string[]): string | undefined => {
44
+ for (const name of names) {
45
+ const candidate = value[name];
46
+ if (typeof candidate === 'string') {
47
+ return candidate;
48
+ }
49
+ }
50
+ return undefined;
51
+ };
52
+
53
+ const valueField = (value: JsonRecord, ...names: readonly string[]): unknown => {
54
+ for (const name of names) {
55
+ if (name in value) {
56
+ return value[name];
57
+ }
58
+ }
59
+ return undefined;
60
+ };
61
+
62
+ const inputStringField = (
63
+ raw: JsonRecord,
64
+ input: unknown,
65
+ ...names: readonly string[]
66
+ ): string | undefined => {
67
+ const fromRaw = stringField(raw, ...names);
68
+ if (fromRaw !== undefined) {
69
+ return fromRaw;
70
+ }
71
+ if (typeof input === 'string' && names.includes('command')) {
72
+ return input;
73
+ }
74
+ return isRecord(input) ? stringField(input, ...names) : undefined;
75
+ };
76
+
77
+ const requiredString = (value: string | undefined, label: string): string => {
78
+ if (value === undefined) {
79
+ throw new Error(`Cursor hook payload missing ${label}`);
80
+ }
81
+ return value;
82
+ };
83
+
84
+ const normalizeCursorToolName = (name: string): string => {
85
+ const normalized = name.toLowerCase();
86
+ if (
87
+ normalized === 'bash' ||
88
+ normalized === 'exec' ||
89
+ normalized === 'shell' ||
90
+ normalized === 'run_command'
91
+ ) {
92
+ return 'Bash';
93
+ }
94
+ if (normalized === 'read' || normalized === 'read_file') {
95
+ return 'Read';
96
+ }
97
+ if (normalized === 'write' || normalized === 'write_file') {
98
+ return 'Write';
99
+ }
100
+ if (
101
+ normalized === 'edit' ||
102
+ normalized === 'edit_file' ||
103
+ normalized === 'multiedit' ||
104
+ normalized === 'apply_patch'
105
+ ) {
106
+ return 'Edit';
107
+ }
108
+ return name;
109
+ };
110
+
111
+ const isFileTool = (tool: string): boolean =>
112
+ tool === 'Read' || tool === 'Write' || tool === 'Edit';
113
+
114
+ const normalizeBashInput = (input: unknown, required: boolean) => {
115
+ let command: string | undefined;
116
+ if (typeof input === 'string') {
117
+ command = input;
118
+ } else if (isRecord(input)) {
119
+ command = stringField(input, 'command', 'cmd');
120
+ }
121
+ if (required && command === undefined) {
122
+ throw new Error('Cursor hook payload missing command');
123
+ }
124
+ if (typeof input === 'string') {
125
+ return { command: input };
126
+ }
127
+ if (!isRecord(input) || command === undefined) {
128
+ return input;
129
+ }
130
+ return { ...input, command };
131
+ };
132
+
133
+ const normalizeFileInput = (tool: string, input: JsonRecord, required: boolean): JsonRecord => {
134
+ const filePath = stringField(input, 'file_path', 'filePath', 'path');
135
+ if (required && filePath === undefined) {
136
+ throw new Error(`Cursor ${tool} hook payload missing file path`);
137
+ }
138
+ if (tool === 'Read') {
139
+ return { ...input, file_path: filePath ?? '' };
140
+ }
141
+ if (tool === 'Write') {
142
+ const content = stringField(input, 'content', 'fileText', 'text');
143
+ if (required && content === undefined) {
144
+ throw new Error('Cursor Write hook payload missing content');
145
+ }
146
+ return { ...input, file_path: filePath ?? '', content: content ?? '' };
147
+ }
148
+ if (tool === 'Edit') {
149
+ const oldString = stringField(input, 'old_string', 'oldString');
150
+ const newString = stringField(input, 'new_string', 'newString');
151
+ if (required && (oldString === undefined || newString === undefined)) {
152
+ throw new Error('Cursor Edit hook payload missing oldString or newString');
153
+ }
154
+ return {
155
+ ...input,
156
+ file_path: filePath ?? '',
157
+ old_string: oldString ?? '',
158
+ new_string: newString ?? '',
159
+ };
160
+ }
161
+ return input;
162
+ };
163
+
164
+ const normalizeToolInput = (tool: string, input: unknown, required = true): unknown => {
165
+ if (tool === 'Bash') {
166
+ return normalizeBashInput(input, required);
167
+ }
168
+ if (!isFileTool(tool)) {
169
+ return input;
170
+ }
171
+ if (!isRecord(input)) {
172
+ if (required) {
173
+ throw new Error(`Cursor ${tool} hook payload is missing tool input`);
174
+ }
175
+ return input;
176
+ }
177
+ return normalizeFileInput(tool, input, required);
178
+ };
179
+
180
+ const normalizeToolResponse = (tool: string, response: unknown) => {
181
+ if (typeof response !== 'string') {
182
+ return response;
183
+ }
184
+ if (tool === 'Bash') {
185
+ return { stdout: response, stderr: '' };
186
+ }
187
+ if (tool === 'Read') {
188
+ return { content: response };
189
+ }
190
+ return { content: response };
191
+ };
192
+
193
+ const cursorToolName = (eventName: string, raw: JsonRecord): string => {
194
+ if (eventName === 'beforeShellExecution' || eventName === 'afterShellExecution') {
195
+ return 'Bash';
196
+ }
197
+ if (
198
+ eventName === 'beforeReadFile' ||
199
+ eventName === 'beforeTabFileRead' ||
200
+ eventName === 'afterFileEdit' ||
201
+ eventName === 'afterTabFileEdit'
202
+ ) {
203
+ return eventName.startsWith('before') ? 'Read' : 'Edit';
204
+ }
205
+ const rawName = stringField(raw, 'tool_name', 'toolName');
206
+ if (rawName !== undefined) {
207
+ return normalizeCursorToolName(rawName);
208
+ }
209
+ const input = valueField(raw, 'tool_input', 'toolInput');
210
+ if (
211
+ stringField(raw, 'command', 'cmd') !== undefined ||
212
+ typeof input === 'string' ||
213
+ (isRecord(input) && stringField(input, 'command', 'cmd') !== undefined)
214
+ ) {
215
+ return 'Bash';
216
+ }
217
+ if (
218
+ stringField(raw, 'file_path', 'filePath', 'path') !== undefined ||
219
+ (isRecord(input) && stringField(input, 'file_path', 'filePath', 'path') !== undefined)
220
+ ) {
221
+ return 'Read';
222
+ }
223
+ return '';
224
+ };
225
+
226
+ const cursorTopLevelToolInput = (tool: string, raw: JsonRecord): JsonRecord => {
227
+ if (tool === 'Bash') {
228
+ return { command: inputStringField(raw, undefined, 'command', 'cmd') };
229
+ }
230
+ const input: JsonRecord = { file_path: stringField(raw, 'file_path', 'filePath', 'path') };
231
+ if (tool === 'Write') {
232
+ input['content'] = stringField(raw, 'content', 'fileText', 'text');
233
+ }
234
+ if (tool === 'Edit') {
235
+ input['old_string'] = stringField(raw, 'old_string', 'oldString');
236
+ input['new_string'] = stringField(raw, 'new_string', 'newString');
237
+ }
238
+ return input;
239
+ };
240
+
241
+ const cursorToolInput = (eventName: string, tool: string, raw: JsonRecord, post: boolean) => {
242
+ const input = valueField(raw, 'tool_input', 'toolInput');
243
+ const required = !post;
244
+ if (eventName === 'beforeShellExecution' || eventName === 'afterShellExecution') {
245
+ const command = inputStringField(raw, input, 'command', 'cmd');
246
+ return { command: required ? requiredString(command, 'command') : (command ?? '') };
247
+ }
248
+ if (
249
+ eventName === 'beforeReadFile' ||
250
+ eventName === 'beforeTabFileRead' ||
251
+ eventName === 'afterFileEdit' ||
252
+ eventName === 'afterTabFileEdit'
253
+ ) {
254
+ const filePath = inputStringField(raw, input, 'file_path', 'filePath', 'path');
255
+ return normalizeToolInput(
256
+ tool,
257
+ {
258
+ file_path: required ? requiredString(filePath, 'file path') : (filePath ?? ''),
259
+ ...(Array.isArray(raw['edits']) && { edits: raw['edits'] }),
260
+ },
261
+ required,
262
+ );
263
+ }
264
+ if (input === undefined && ['Bash', 'Read', 'Write', 'Edit'].includes(tool)) {
265
+ return normalizeToolInput(tool, cursorTopLevelToolInput(tool, raw), required);
266
+ }
267
+ return normalizeToolInput(tool, input, required);
268
+ };
269
+
270
+ const cursorToolResponse = (eventName: string, tool: string, raw: JsonRecord): unknown => {
271
+ if (eventName === 'afterShellExecution') {
272
+ return normalizeToolResponse(tool, stringField(raw, 'output') ?? '');
273
+ }
274
+ const response = valueField(
275
+ raw,
276
+ 'tool_response',
277
+ 'toolResponse',
278
+ 'tool_output',
279
+ 'toolOutput',
280
+ 'result',
281
+ 'result_json',
282
+ );
283
+ return normalizeToolResponse(tool, response);
284
+ };
285
+
286
+ const normalizeCursorEvent = (
287
+ raw: JsonRecord,
288
+ eventName: string,
289
+ post: boolean,
290
+ ): { readonly event: HookEvent; readonly host: HookHost } => {
291
+ const tool = cursorToolName(eventName, raw);
292
+ const input = cursorToolInput(eventName, tool, raw, post);
293
+ const response = post ? cursorToolResponse(eventName, tool, raw) : undefined;
294
+ const cwd = stringField(raw, 'cwd');
295
+ const sessionId = stringField(raw, 'conversation_id', 'session_id');
296
+ const toolUseId = stringField(raw, 'tool_use_id');
297
+ const event: HookEvent = {
298
+ hook_event_name: post ? 'PostToolUse' : 'PreToolUse',
299
+ ...(tool.length > 0 && { tool_name: tool }),
300
+ ...(input !== undefined && { tool_input: input }),
301
+ ...(response !== undefined && { tool_response: response }),
302
+ ...(cwd !== undefined && { cwd }),
303
+ ...(sessionId !== undefined && { session_id: sessionId }),
304
+ ...(toolUseId !== undefined && { tool_use_id: toolUseId }),
305
+ };
306
+ return { event, host: cursorHost(eventName) };
307
+ };
308
+
309
+ const normalizeHookInput = (raw: unknown, hintedEventName?: string): NormalizedHookInput => {
310
+ if (hintedEventName !== undefined && !isCursorEventName(hintedEventName)) {
311
+ throw new Error(`Unknown Cursor hook event "${hintedEventName}"`);
312
+ }
313
+ if (!isRecord(raw)) {
314
+ if (hintedEventName !== undefined) {
315
+ throw new Error(`Cursor ${hintedEventName} hook payload must be a JSON object`);
316
+ }
317
+ return { event: raw, host: { kind: 'native' } };
318
+ }
319
+ const eventName = hintedEventName ?? stringField(raw, 'hook_event_name');
320
+ if (eventName !== undefined && CURSOR_PRE_EVENTS.has(eventName)) {
321
+ return normalizeCursorEvent(raw, eventName, false);
322
+ }
323
+ if (eventName !== undefined && CURSOR_POST_EVENTS.has(eventName)) {
324
+ return normalizeCursorEvent(raw, eventName, true);
325
+ }
326
+ return { event: raw, host: { kind: 'native' } };
327
+ };
328
+
329
+ export {
330
+ CURSOR_POST_EVENTS,
331
+ CURSOR_PRE_EVENTS,
332
+ cursorHost,
333
+ normalizeHookInput,
334
+ type HookHost,
335
+ type NormalizedHookInput,
336
+ };
package/src/lib/diff.ts CHANGED
@@ -18,8 +18,11 @@ const addedLines = (prev: string, next: string): string[] => {
18
18
  const readFileOrEmpty = (path: string): string => {
19
19
  try {
20
20
  return readFileSync(path, 'utf8');
21
- } catch {
22
- return '';
21
+ } catch (error) {
22
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
23
+ return '';
24
+ }
25
+ throw error;
23
26
  }
24
27
  };
25
28
 
package/src/lib/event.ts CHANGED
@@ -46,45 +46,44 @@ interface ReadResponse {
46
46
  readonly file?: { readonly content?: string };
47
47
  }
48
48
 
49
- const isBashInput = (x: unknown): x is BashInput =>
50
- typeof x === 'object' && x !== null && typeof (x as BashInput).command === 'string';
49
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
50
+ typeof value === 'object' && value !== null && !Array.isArray(value);
51
+
52
+ const isBashInput = (x: unknown): x is BashInput => isRecord(x) && typeof x['command'] === 'string';
51
53
 
52
54
  const isEditInput = (x: unknown): x is EditInput =>
53
- typeof x === 'object' &&
54
- x !== null &&
55
- typeof (x as EditInput).file_path === 'string' &&
56
- typeof (x as EditInput).old_string === 'string' &&
57
- typeof (x as EditInput).new_string === 'string';
55
+ isRecord(x) &&
56
+ typeof x['file_path'] === 'string' &&
57
+ typeof x['old_string'] === 'string' &&
58
+ typeof x['new_string'] === 'string';
58
59
 
59
60
  const isWriteInput = (x: unknown): x is WriteInput =>
60
- typeof x === 'object' &&
61
- x !== null &&
62
- typeof (x as WriteInput).file_path === 'string' &&
63
- typeof (x as WriteInput).content === 'string';
61
+ isRecord(x) && typeof x['file_path'] === 'string' && typeof x['content'] === 'string';
64
62
 
65
63
  const isReadInput = (x: unknown): x is ReadInput =>
66
- typeof x === 'object' && x !== null && typeof (x as ReadInput).file_path === 'string';
64
+ isRecord(x) && typeof x['file_path'] === 'string';
67
65
 
68
66
  // Extract any string payload from a tool_response we can scan for secrets.
69
67
  // Returns concatenated stdout/stderr for Bash, content for Read, or '' if
70
68
  // Nothing is recognizable.
71
69
  const extractResponseText = (toolName: string, response: unknown): string => {
72
- if (typeof response !== 'object' || response === null) {
70
+ if (!isRecord(response)) {
73
71
  return '';
74
72
  }
75
73
  if (toolName === 'Bash') {
76
- const r = response as BashResponse;
77
- return [r.stdout ?? '', r.stderr ?? ''].filter((s) => s.length > 0).join('\n');
74
+ const stdout = typeof response['stdout'] === 'string' ? response['stdout'] : '';
75
+ const stderr = typeof response['stderr'] === 'string' ? response['stderr'] : '';
76
+ return [stdout, stderr].filter((text) => text.length > 0).join('\n');
78
77
  }
79
78
  if (toolName === 'Read') {
80
- const r = response as ReadResponse;
81
- return r.content ?? r.file?.content ?? '';
79
+ if (typeof response['content'] === 'string') {
80
+ return response['content'];
81
+ }
82
+ const { file } = response;
83
+ return isRecord(file) && typeof file['content'] === 'string' ? file['content'] : '';
82
84
  }
83
85
  // Best-effort fallback: stringify and let the scanner do its thing.
84
- if (typeof (response as { content?: string }).content === 'string') {
85
- return (response as { content?: string }).content ?? '';
86
- }
87
- return '';
86
+ return typeof response['content'] === 'string' ? response['content'] : '';
88
87
  };
89
88
 
90
89
  export type {