@seanmozeik/tripwire 0.7.0 → 0.7.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/README.md +16 -14
- package/dist/index.js +35 -0
- package/dist/tripwire-cli.js +3 -0
- package/dist/tripwire-hook.js +3 -0
- package/dist/tripwire-pi.js +6 -4
- package/dist/tripwire.js +141 -0
- package/dist/types/dispatch.d.ts +18 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/lib/bash.d.ts +27 -0
- package/dist/types/lib/config.d.ts +110 -0
- package/dist/types/lib/cursor.d.ts +16 -0
- package/dist/types/lib/decision.d.ts +13 -0
- package/dist/types/lib/diff.d.ts +3 -0
- package/dist/types/lib/event.d.ts +45 -0
- package/dist/types/lib/log.d.ts +2 -0
- package/dist/types/lib/secrets.d.ts +41 -0
- package/dist/types/rules/bash-deny.d.ts +5 -0
- package/dist/types/rules/bash-git.d.ts +5 -0
- package/dist/types/rules/bash-network-install.d.ts +4 -0
- package/dist/types/rules/bash-redirect.d.ts +4 -0
- package/dist/types/rules/bash-scoped-rm.d.ts +5 -0
- package/dist/types/rules/bash-tar-explosion.d.ts +4 -0
- package/dist/types/rules/config-custom.d.ts +6 -0
- package/dist/types/rules/lazy-code.d.ts +4 -0
- package/dist/types/rules/path-protect.d.ts +12 -0
- package/dist/types/rules/post-secret-scrub.d.ts +12 -0
- package/dist/types/rules/read-protect.d.ts +4 -0
- package/dist/types/rules/tool-policy.d.ts +5 -0
- package/package.json +16 -13
- package/dist/tripwire +0 -0
- package/scripts/tripwire-cli +0 -12
- package/src/cli.ts +0 -271
- package/src/dispatch.ts +0 -562
- package/src/index.ts +0 -6
- package/src/lib/bash.ts +0 -1328
- package/src/lib/config.ts +0 -174
- package/src/lib/cursor.ts +0 -336
- package/src/lib/decision.ts +0 -36
- package/src/lib/diff.ts +0 -29
- package/src/lib/event.ts +0 -105
- package/src/lib/install.ts +0 -610
- package/src/lib/log.ts +0 -23
- package/src/lib/secrets.ts +0 -184
- package/src/main.ts +0 -31
- package/src/pi-extension.ts +0 -337
- package/src/rules/bash-deny.ts +0 -404
- package/src/rules/bash-git.ts +0 -590
- package/src/rules/bash-network-install.ts +0 -75
- package/src/rules/bash-redirect.ts +0 -91
- package/src/rules/bash-scoped-rm.ts +0 -84
- package/src/rules/bash-tar-explosion.ts +0 -77
- package/src/rules/config-custom.ts +0 -166
- package/src/rules/lazy-code.ts +0 -95
- package/src/rules/path-protect.ts +0 -131
- package/src/rules/post-secret-scrub.ts +0 -49
- package/src/rules/read-protect.ts +0 -57
- package/src/rules/tool-policy.ts +0 -54
package/src/lib/install.ts
DELETED
|
@@ -1,610 +0,0 @@
|
|
|
1
|
-
// Agent installation module for Tripwire hooks and the native Pi extension.
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
lstat,
|
|
5
|
-
mkdir,
|
|
6
|
-
open,
|
|
7
|
-
readFile,
|
|
8
|
-
readlink,
|
|
9
|
-
realpath,
|
|
10
|
-
rename,
|
|
11
|
-
rm,
|
|
12
|
-
stat,
|
|
13
|
-
symlink,
|
|
14
|
-
type FileHandle,
|
|
15
|
-
} from 'node:fs/promises';
|
|
16
|
-
import { homedir } from 'node:os';
|
|
17
|
-
import pathModule from 'node:path';
|
|
18
|
-
import { fileURLToPath } from 'node:url';
|
|
19
|
-
|
|
20
|
-
interface CommandHook extends Record<string, unknown> {
|
|
21
|
-
type: string;
|
|
22
|
-
command: string;
|
|
23
|
-
timeout?: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface HookGroup extends Record<string, unknown> {
|
|
27
|
-
hooks: CommandHook[];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface AgentHooks extends Record<string, unknown> {
|
|
31
|
-
PreToolUse?: HookGroup[];
|
|
32
|
-
PostToolUse?: HookGroup[];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface AgentHooksConfig extends Record<string, unknown> {
|
|
36
|
-
hooks?: AgentHooks;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const piExtensionSourceCandidates = [
|
|
40
|
-
pathModule.join(pathModule.dirname(process.execPath), 'tripwire-pi.js'),
|
|
41
|
-
fileURLToPath(new URL('../../dist/tripwire-pi.js', import.meta.url)),
|
|
42
|
-
fileURLToPath(new URL('tripwire-pi.js', import.meta.url)),
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
export interface InstallOptions {
|
|
46
|
-
readonly extensionSource?: string;
|
|
47
|
-
readonly homeDirectory?: string;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
interface AtomicTextReplaceOptions {
|
|
51
|
-
readonly beforeRename?: (pendingPath: string, targetPath: string) => Promise<void> | void;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
interface ExtensionLinkResult {
|
|
55
|
-
readonly action: 'already' | 'installed' | 'updated';
|
|
56
|
-
readonly success: boolean;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface CursorHook extends Record<string, unknown> {
|
|
60
|
-
command: string;
|
|
61
|
-
type?: string;
|
|
62
|
-
timeout?: number;
|
|
63
|
-
failClosed?: boolean;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface CursorConfig extends Record<string, unknown> {
|
|
67
|
-
version?: number;
|
|
68
|
-
hooks?: Record<string, CursorHook[]>;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const isJsonRecord = (value: unknown): value is Record<string, unknown> =>
|
|
72
|
-
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
73
|
-
|
|
74
|
-
const isErrno = (error: unknown, code: string): boolean =>
|
|
75
|
-
error instanceof Error && 'code' in error && error.code === code;
|
|
76
|
-
|
|
77
|
-
export const replaceTextAtomically = async (
|
|
78
|
-
targetPath: string,
|
|
79
|
-
text: string,
|
|
80
|
-
options: AtomicTextReplaceOptions = {},
|
|
81
|
-
): Promise<void> => {
|
|
82
|
-
let publicationPath = targetPath;
|
|
83
|
-
let targetIsSymlink = false;
|
|
84
|
-
try {
|
|
85
|
-
const targetStatus = await lstat(targetPath);
|
|
86
|
-
targetIsSymlink = targetStatus.isSymbolicLink();
|
|
87
|
-
} catch (error) {
|
|
88
|
-
if (!isErrno(error, 'ENOENT')) {
|
|
89
|
-
throw error;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
if (targetIsSymlink) {
|
|
93
|
-
// A dangling link fails here instead of being replaced.
|
|
94
|
-
publicationPath = await realpath(targetPath);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const pendingPath = `${publicationPath}.${process.pid}.next`;
|
|
98
|
-
let handle: FileHandle | undefined;
|
|
99
|
-
let mode = 0o600;
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
const targetStatus = await stat(publicationPath);
|
|
103
|
-
mode = targetStatus.mode & 0o7777;
|
|
104
|
-
} catch (error) {
|
|
105
|
-
if (!isErrno(error, 'ENOENT')) {
|
|
106
|
-
throw error;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
await rm(pendingPath, { force: true });
|
|
111
|
-
try {
|
|
112
|
-
handle = await open(pendingPath, 'wx', mode);
|
|
113
|
-
await handle.writeFile(text, 'utf8');
|
|
114
|
-
// Chmod is explicit after writing because open applies the umask and writes can clear mode bits.
|
|
115
|
-
await handle.chmod(mode);
|
|
116
|
-
await handle.sync();
|
|
117
|
-
await handle.close();
|
|
118
|
-
handle = undefined;
|
|
119
|
-
await options.beforeRename?.(pendingPath, publicationPath);
|
|
120
|
-
await rename(pendingPath, publicationPath);
|
|
121
|
-
} finally {
|
|
122
|
-
try {
|
|
123
|
-
if (handle !== undefined) {
|
|
124
|
-
await handle.close();
|
|
125
|
-
}
|
|
126
|
-
} finally {
|
|
127
|
-
await rm(pendingPath, { force: true });
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const isCommandHook = (value: unknown): value is CommandHook =>
|
|
133
|
-
isJsonRecord(value) &&
|
|
134
|
-
typeof value['type'] === 'string' &&
|
|
135
|
-
typeof value['command'] === 'string' &&
|
|
136
|
-
(value['timeout'] === undefined || typeof value['timeout'] === 'number');
|
|
137
|
-
|
|
138
|
-
const isHookGroups = (value: unknown): value is HookGroup[] =>
|
|
139
|
-
Array.isArray(value) &&
|
|
140
|
-
value.every(
|
|
141
|
-
(group) =>
|
|
142
|
-
isJsonRecord(group) && Array.isArray(group['hooks']) && group['hooks'].every(isCommandHook),
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
const isAgentHooksConfig = (value: unknown): value is AgentHooksConfig => {
|
|
146
|
-
if (!isJsonRecord(value)) {
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
|
-
const { hooks } = value;
|
|
150
|
-
if (hooks === undefined) {
|
|
151
|
-
return true;
|
|
152
|
-
}
|
|
153
|
-
return (
|
|
154
|
-
isJsonRecord(hooks) &&
|
|
155
|
-
(hooks['PreToolUse'] === undefined || isHookGroups(hooks['PreToolUse'])) &&
|
|
156
|
-
(hooks['PostToolUse'] === undefined || isHookGroups(hooks['PostToolUse']))
|
|
157
|
-
);
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
const parseAgentHooksConfig = (raw: string, label: string): AgentHooksConfig => {
|
|
161
|
-
const value: unknown = JSON.parse(raw);
|
|
162
|
-
if (!isAgentHooksConfig(value)) {
|
|
163
|
-
throw new Error(`${label} hook config has an unsupported shape`);
|
|
164
|
-
}
|
|
165
|
-
return value;
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
const parseCursorConfig = (raw: string): CursorConfig => {
|
|
169
|
-
const value: unknown = JSON.parse(raw);
|
|
170
|
-
if (!isJsonRecord(value)) {
|
|
171
|
-
throw new Error('Cursor hooks.json must contain a JSON object');
|
|
172
|
-
}
|
|
173
|
-
const { hooks } = value;
|
|
174
|
-
if (hooks === undefined) {
|
|
175
|
-
return value;
|
|
176
|
-
}
|
|
177
|
-
if (!isJsonRecord(hooks)) {
|
|
178
|
-
throw new Error('Cursor hooks.json `hooks` must be an object');
|
|
179
|
-
}
|
|
180
|
-
for (const [eventName, eventHooks] of Object.entries(hooks)) {
|
|
181
|
-
if (
|
|
182
|
-
!Array.isArray(eventHooks) ||
|
|
183
|
-
eventHooks.some((hook) => !isJsonRecord(hook) || typeof hook['command'] !== 'string')
|
|
184
|
-
) {
|
|
185
|
-
throw new Error(`Cursor hooks.json event ${eventName} must contain command hooks`);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return value;
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
const TRIPWIRE_HOOK = 'tripwire-hook';
|
|
192
|
-
|
|
193
|
-
const installExtensionLink = async (
|
|
194
|
-
source: string,
|
|
195
|
-
extensionPath: string,
|
|
196
|
-
): Promise<ExtensionLinkResult> => {
|
|
197
|
-
await mkdir(pathModule.dirname(extensionPath), { recursive: true });
|
|
198
|
-
try {
|
|
199
|
-
const status = await lstat(extensionPath);
|
|
200
|
-
if (!status.isSymbolicLink()) {
|
|
201
|
-
return { action: 'already', success: false };
|
|
202
|
-
}
|
|
203
|
-
const currentTarget = await readlink(extensionPath);
|
|
204
|
-
if (currentTarget === source) {
|
|
205
|
-
return { action: 'already', success: true };
|
|
206
|
-
}
|
|
207
|
-
const resolvedTarget = pathModule.resolve(pathModule.dirname(extensionPath), currentTarget);
|
|
208
|
-
if (pathModule.basename(resolvedTarget) !== 'tripwire-pi.js') {
|
|
209
|
-
return { action: 'already', success: false };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const pendingPath = `${extensionPath}.${process.pid}.next`;
|
|
213
|
-
await rm(pendingPath, { force: true });
|
|
214
|
-
try {
|
|
215
|
-
await symlink(source, pendingPath);
|
|
216
|
-
await rename(pendingPath, extensionPath);
|
|
217
|
-
} finally {
|
|
218
|
-
await rm(pendingPath, { force: true });
|
|
219
|
-
}
|
|
220
|
-
return { action: 'updated', success: true };
|
|
221
|
-
} catch (error) {
|
|
222
|
-
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
|
|
223
|
-
throw error;
|
|
224
|
-
}
|
|
225
|
-
await symlink(source, extensionPath);
|
|
226
|
-
return { action: 'installed', success: true };
|
|
227
|
-
}
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
const CURSOR_HOOK_EVENTS = [
|
|
231
|
-
'preToolUse',
|
|
232
|
-
'postToolUse',
|
|
233
|
-
'beforeShellExecution',
|
|
234
|
-
'afterShellExecution',
|
|
235
|
-
'beforeReadFile',
|
|
236
|
-
'afterFileEdit',
|
|
237
|
-
] as const;
|
|
238
|
-
|
|
239
|
-
const CURSOR_FAIL_CLOSED_EVENTS = new Set(['preToolUse', 'beforeShellExecution', 'beforeReadFile']);
|
|
240
|
-
|
|
241
|
-
const isTripwireCommand = (command: string): boolean =>
|
|
242
|
-
command === TRIPWIRE_HOOK ||
|
|
243
|
-
command.startsWith(`${TRIPWIRE_HOOK} `) ||
|
|
244
|
-
command.includes('/tripwire-hook') ||
|
|
245
|
-
command.includes('/tripwire.js') ||
|
|
246
|
-
command.includes('/dist/tripwire');
|
|
247
|
-
|
|
248
|
-
const addCursorHook = (
|
|
249
|
-
hooks: CursorHook[] | undefined,
|
|
250
|
-
eventName: string,
|
|
251
|
-
): [CursorHook[], boolean] => {
|
|
252
|
-
const failClosed = CURSOR_FAIL_CLOSED_EVENTS.has(eventName);
|
|
253
|
-
const newHook = {
|
|
254
|
-
command: `${TRIPWIRE_HOOK} --cursor-event ${eventName}`,
|
|
255
|
-
...(failClosed && { failClosed: true }),
|
|
256
|
-
};
|
|
257
|
-
if (hooks === undefined) {
|
|
258
|
-
return [[newHook], true];
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
let changed = false;
|
|
262
|
-
const hasTripwire = hooks.some((hook) => isTripwireCommand(hook.command));
|
|
263
|
-
const desiredCommand = `${TRIPWIRE_HOOK} --cursor-event ${eventName}`;
|
|
264
|
-
const normalized = hooks.map((hook) => {
|
|
265
|
-
if (!isTripwireCommand(hook.command)) {
|
|
266
|
-
return hook;
|
|
267
|
-
}
|
|
268
|
-
if (hook.command === desiredCommand && (!failClosed || hook.failClosed === true)) {
|
|
269
|
-
return hook;
|
|
270
|
-
}
|
|
271
|
-
changed = true;
|
|
272
|
-
return { ...hook, command: desiredCommand, ...(failClosed && { failClosed: true }) };
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
if (hasTripwire) {
|
|
276
|
-
return [normalized, changed];
|
|
277
|
-
}
|
|
278
|
-
return [[...normalized, newHook], true];
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
const addHookIfMissing = (hooks: HookGroup[] | undefined): [HookGroup[], boolean] => {
|
|
282
|
-
if (!hooks) {
|
|
283
|
-
const newHooks: HookGroup[] = [{ hooks: [{ type: 'command', command: TRIPWIRE_HOOK }] }];
|
|
284
|
-
return [newHooks, false];
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
let needsNormalization = false;
|
|
288
|
-
|
|
289
|
-
const normalizedHooks = hooks.map((h) => ({
|
|
290
|
-
...h,
|
|
291
|
-
hooks: h.hooks.map((hook) => {
|
|
292
|
-
if (hook.command === TRIPWIRE_HOOK || hook.command.endsWith('/tripwire-hook')) {
|
|
293
|
-
if (hook.command !== TRIPWIRE_HOOK) {
|
|
294
|
-
needsNormalization = true;
|
|
295
|
-
return { ...hook, command: TRIPWIRE_HOOK };
|
|
296
|
-
}
|
|
297
|
-
return hook;
|
|
298
|
-
}
|
|
299
|
-
return hook;
|
|
300
|
-
}),
|
|
301
|
-
}));
|
|
302
|
-
|
|
303
|
-
const hasTripwire = normalizedHooks.some((h) =>
|
|
304
|
-
h.hooks.some((hook) => hook.command === TRIPWIRE_HOOK),
|
|
305
|
-
);
|
|
306
|
-
|
|
307
|
-
if (hasTripwire) {
|
|
308
|
-
return [normalizedHooks, !needsNormalization];
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const newHooks: HookGroup[] = [
|
|
312
|
-
...normalizedHooks,
|
|
313
|
-
{ hooks: [{ type: 'command', command: TRIPWIRE_HOOK }] },
|
|
314
|
-
];
|
|
315
|
-
return [newHooks, false];
|
|
316
|
-
};
|
|
317
|
-
|
|
318
|
-
export const installClaude = async (
|
|
319
|
-
options: InstallOptions = {},
|
|
320
|
-
): Promise<{ success: boolean; message: string }> => {
|
|
321
|
-
const homeDirectory = options.homeDirectory ?? homedir();
|
|
322
|
-
const configPath = `${homeDirectory}/.claude/settings.json`;
|
|
323
|
-
try {
|
|
324
|
-
const raw = await readFile(configPath, 'utf8');
|
|
325
|
-
const config = parseAgentHooksConfig(raw, 'Claude');
|
|
326
|
-
|
|
327
|
-
config.hooks ??= {};
|
|
328
|
-
const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
|
|
329
|
-
const [postToolUse, postSkipped] = addHookIfMissing(config.hooks.PostToolUse);
|
|
330
|
-
|
|
331
|
-
config.hooks.PreToolUse = preToolUse;
|
|
332
|
-
config.hooks.PostToolUse = postToolUse;
|
|
333
|
-
|
|
334
|
-
if (preSkipped && postSkipped) {
|
|
335
|
-
return { success: true, message: `Already configured: ${configPath}` };
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
await replaceTextAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
339
|
-
|
|
340
|
-
return { success: true, message: `Updated ${configPath}` };
|
|
341
|
-
} catch (error) {
|
|
342
|
-
if (isErrno(error, 'ENOENT')) {
|
|
343
|
-
return { success: false, message: `Config file not found: ${configPath}` };
|
|
344
|
-
}
|
|
345
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
346
|
-
return { success: false, message: `Failed to update Claude config: ${message}` };
|
|
347
|
-
}
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
export const installPi = async (
|
|
351
|
-
options: InstallOptions = {},
|
|
352
|
-
): Promise<{ success: boolean; message: string }> => {
|
|
353
|
-
const homeDirectory = options.homeDirectory ?? homedir();
|
|
354
|
-
const configPath = `${homeDirectory}/.pi/agent/settings.json`;
|
|
355
|
-
const extensionPath = `${homeDirectory}/.pi/agent/extensions/tripwire.js`;
|
|
356
|
-
|
|
357
|
-
try {
|
|
358
|
-
const source =
|
|
359
|
-
options.extensionSource ??
|
|
360
|
-
piExtensionSourceCandidates.find((candidate) => Bun.file(candidate).size > 0);
|
|
361
|
-
if (source === undefined) {
|
|
362
|
-
return { success: false, message: 'Built Pi extension not found; run `bun run build` first' };
|
|
363
|
-
}
|
|
364
|
-
const raw = await readFile(configPath, 'utf8');
|
|
365
|
-
let config = parseAgentHooksConfig(raw, 'Pi');
|
|
366
|
-
if (config.hooks !== undefined) {
|
|
367
|
-
const { PreToolUse, PostToolUse, ...otherHooks } = config.hooks;
|
|
368
|
-
const cleanGroups = (groups: HookGroup[] | undefined) =>
|
|
369
|
-
groups
|
|
370
|
-
?.map((group) => ({
|
|
371
|
-
...group,
|
|
372
|
-
hooks: group.hooks.filter((hook) => !isTripwireCommand(hook.command)),
|
|
373
|
-
}))
|
|
374
|
-
.filter((group) => group.hooks.length > 0) ?? [];
|
|
375
|
-
const cleanPreToolUse = cleanGroups(PreToolUse);
|
|
376
|
-
const cleanPostToolUse = cleanGroups(PostToolUse);
|
|
377
|
-
const nextHooks = {
|
|
378
|
-
...otherHooks,
|
|
379
|
-
...(cleanPreToolUse.length > 0 && { PreToolUse: cleanPreToolUse }),
|
|
380
|
-
...(cleanPostToolUse.length > 0 && { PostToolUse: cleanPostToolUse }),
|
|
381
|
-
};
|
|
382
|
-
if (Object.keys(nextHooks).length === 0) {
|
|
383
|
-
const { hooks: _removedHooks, ...configWithoutHooks } = config;
|
|
384
|
-
config = configWithoutHooks;
|
|
385
|
-
} else {
|
|
386
|
-
config.hooks = nextHooks;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
const nextRaw = `${JSON.stringify(config, null, 2)}\n`;
|
|
390
|
-
const settingsChanged = nextRaw !== raw;
|
|
391
|
-
|
|
392
|
-
// The extension must be available before old hook settings are removed.
|
|
393
|
-
const link = await installExtensionLink(source, extensionPath);
|
|
394
|
-
if (!link.success) {
|
|
395
|
-
return {
|
|
396
|
-
success: false,
|
|
397
|
-
message: `Refusing to replace existing Pi extension: ${extensionPath}`,
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
if (settingsChanged) {
|
|
401
|
-
await replaceTextAtomically(configPath, nextRaw);
|
|
402
|
-
}
|
|
403
|
-
if (link.action === 'already' && !settingsChanged) {
|
|
404
|
-
return { success: true, message: `Already configured: ${extensionPath}` };
|
|
405
|
-
}
|
|
406
|
-
const verb = link.action === 'updated' ? 'Updated' : 'Installed';
|
|
407
|
-
return { success: true, message: `${verb} ${extensionPath}` };
|
|
408
|
-
} catch (error) {
|
|
409
|
-
if (isErrno(error, 'ENOENT')) {
|
|
410
|
-
return { success: false, message: `Config file not found: ${configPath}` };
|
|
411
|
-
}
|
|
412
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
413
|
-
return { success: false, message: `Failed to update pi config: ${message}` };
|
|
414
|
-
}
|
|
415
|
-
};
|
|
416
|
-
|
|
417
|
-
export const installOhMyPi = async (
|
|
418
|
-
options: InstallOptions = {},
|
|
419
|
-
): Promise<{ success: boolean; message: string }> => {
|
|
420
|
-
const homeDirectory = options.homeDirectory ?? homedir();
|
|
421
|
-
const extensionPath = `${homeDirectory}/.omp/agent/extensions/tripwire.js`;
|
|
422
|
-
try {
|
|
423
|
-
const source =
|
|
424
|
-
options.extensionSource ??
|
|
425
|
-
piExtensionSourceCandidates.find((candidate) => Bun.file(candidate).size > 0);
|
|
426
|
-
if (source === undefined) {
|
|
427
|
-
return { success: false, message: 'Built Pi extension not found; run `bun run build` first' };
|
|
428
|
-
}
|
|
429
|
-
const link = await installExtensionLink(source, extensionPath);
|
|
430
|
-
if (!link.success) {
|
|
431
|
-
return {
|
|
432
|
-
success: false,
|
|
433
|
-
message: `Refusing to replace existing oh-my-pi extension: ${extensionPath}`,
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
if (link.action === 'already') {
|
|
437
|
-
return { success: true, message: `Already configured: ${extensionPath}` };
|
|
438
|
-
}
|
|
439
|
-
const verb = link.action === 'updated' ? 'Updated' : 'Installed';
|
|
440
|
-
return { success: true, message: `${verb} ${extensionPath}` };
|
|
441
|
-
} catch (error) {
|
|
442
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
443
|
-
return { success: false, message: `Failed to install oh-my-pi extension: ${message}` };
|
|
444
|
-
}
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
const parseCodexFeatures = (raw: string): Record<string, unknown> | undefined => {
|
|
448
|
-
const parsed: unknown = Bun.TOML.parse(raw);
|
|
449
|
-
const features = isJsonRecord(parsed) ? parsed['features'] : undefined;
|
|
450
|
-
if (features !== undefined && !isJsonRecord(features)) {
|
|
451
|
-
throw new Error('Codex config.toml `features` must be a table');
|
|
452
|
-
}
|
|
453
|
-
return features;
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
const enableCodexHooks = (raw: string): string => {
|
|
457
|
-
if (parseCodexFeatures(raw)?.['hooks'] === true) {
|
|
458
|
-
return raw;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
const newline = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
462
|
-
const featuresHeader = /^[\t ]*\[features\][\t ]*(?:#.*)?(?:\r?\n|$)/m.exec(raw);
|
|
463
|
-
let next: string;
|
|
464
|
-
if (featuresHeader === null) {
|
|
465
|
-
const separator = raw.length === 0 || raw.endsWith('\n') ? '' : newline;
|
|
466
|
-
const finalNewline = raw.length === 0 || raw.endsWith('\n') ? newline : '';
|
|
467
|
-
next = `${raw}${separator}[features]${newline}hooks = true${finalNewline}`;
|
|
468
|
-
} else {
|
|
469
|
-
const sectionStart = featuresHeader.index + featuresHeader[0].length;
|
|
470
|
-
const nextHeaderPattern = /^[\t ]*\[/gm;
|
|
471
|
-
nextHeaderPattern.lastIndex = sectionStart;
|
|
472
|
-
const nextHeader = nextHeaderPattern.exec(raw);
|
|
473
|
-
const sectionEnd = nextHeader?.index ?? raw.length;
|
|
474
|
-
const section = raw.slice(sectionStart, sectionEnd);
|
|
475
|
-
const assignment =
|
|
476
|
-
/^(?<prefix>[\t ]*hooks[\t ]*=[\t ]*)(?<value>[^#\r\n]*?)(?<suffix>[\t ]*(?:#.*)?)$/m.exec(
|
|
477
|
-
section,
|
|
478
|
-
);
|
|
479
|
-
|
|
480
|
-
if (assignment === null) {
|
|
481
|
-
const headerHasNewline = featuresHeader[0].endsWith('\n');
|
|
482
|
-
const insertion = headerHasNewline ? `hooks = true${newline}` : `${newline}hooks = true`;
|
|
483
|
-
next = `${raw.slice(0, sectionStart)}${insertion}${raw.slice(sectionStart)}`;
|
|
484
|
-
} else {
|
|
485
|
-
const prefix = assignment.groups?.['prefix'];
|
|
486
|
-
const value = assignment.groups?.['value'];
|
|
487
|
-
if (prefix === undefined || value === undefined) {
|
|
488
|
-
throw new Error('Could not locate Codex hooks value in config.toml');
|
|
489
|
-
}
|
|
490
|
-
const valueStart = sectionStart + assignment.index + prefix.length;
|
|
491
|
-
const valueEnd = valueStart + value.length;
|
|
492
|
-
next = `${raw.slice(0, valueStart)}true${raw.slice(valueEnd)}`;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
if (parseCodexFeatures(next)?.['hooks'] !== true) {
|
|
497
|
-
throw new Error('Could not enable Codex hooks in config.toml');
|
|
498
|
-
}
|
|
499
|
-
return next;
|
|
500
|
-
};
|
|
501
|
-
|
|
502
|
-
export const installCodex = async (
|
|
503
|
-
options: InstallOptions = {},
|
|
504
|
-
): Promise<{ success: boolean; message: string }> => {
|
|
505
|
-
const homeDirectory = options.homeDirectory ?? homedir();
|
|
506
|
-
const configTomlPath = `${homeDirectory}/.codex/config.toml`;
|
|
507
|
-
const hooksJsonPath = `${homeDirectory}/.codex/hooks.json`;
|
|
508
|
-
|
|
509
|
-
try {
|
|
510
|
-
// Read and validate both files before the first write.
|
|
511
|
-
const [hooksRaw, tomlRaw] = await Promise.all([
|
|
512
|
-
readFile(hooksJsonPath, 'utf8'),
|
|
513
|
-
readFile(configTomlPath, 'utf8'),
|
|
514
|
-
]);
|
|
515
|
-
const config = parseAgentHooksConfig(hooksRaw, 'Codex');
|
|
516
|
-
config.hooks ??= {};
|
|
517
|
-
const [preToolUse] = addHookIfMissing(config.hooks.PreToolUse);
|
|
518
|
-
const [postToolUse] = addHookIfMissing(config.hooks.PostToolUse);
|
|
519
|
-
|
|
520
|
-
const addTimeout = (hooks: HookGroup[]): HookGroup[] =>
|
|
521
|
-
hooks.map((group) => ({
|
|
522
|
-
...group,
|
|
523
|
-
hooks: group.hooks.map((hook) =>
|
|
524
|
-
hook.command === TRIPWIRE_HOOK && hook.timeout === undefined
|
|
525
|
-
? { ...hook, timeout: 10 }
|
|
526
|
-
: hook,
|
|
527
|
-
),
|
|
528
|
-
}));
|
|
529
|
-
|
|
530
|
-
config.hooks.PreToolUse = addTimeout(preToolUse);
|
|
531
|
-
config.hooks.PostToolUse = addTimeout(postToolUse);
|
|
532
|
-
const nextHooksRaw = `${JSON.stringify(config, null, 2)}\n`;
|
|
533
|
-
const nextTomlRaw = enableCodexHooks(tomlRaw);
|
|
534
|
-
const hooksUpdated = nextHooksRaw !== hooksRaw;
|
|
535
|
-
const tomlUpdated = nextTomlRaw !== tomlRaw;
|
|
536
|
-
|
|
537
|
-
// Publish hooks.json first. It is inert while the feature is disabled, and a retry repairs
|
|
538
|
-
// a first-file-only partial update without changing the already published bytes.
|
|
539
|
-
if (hooksUpdated) {
|
|
540
|
-
await replaceTextAtomically(hooksJsonPath, nextHooksRaw);
|
|
541
|
-
}
|
|
542
|
-
if (tomlUpdated) {
|
|
543
|
-
await replaceTextAtomically(configTomlPath, nextTomlRaw);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
if (!hooksUpdated && !tomlUpdated) {
|
|
547
|
-
return {
|
|
548
|
-
success: true,
|
|
549
|
-
message: `Already configured: ${configTomlPath} and ${hooksJsonPath}`,
|
|
550
|
-
};
|
|
551
|
-
}
|
|
552
|
-
return { success: true, message: `Updated ${configTomlPath} and ${hooksJsonPath}` };
|
|
553
|
-
} catch (error) {
|
|
554
|
-
if (isErrno(error, 'ENOENT')) {
|
|
555
|
-
const missingPath =
|
|
556
|
-
isJsonRecord(error) && typeof error['path'] === 'string'
|
|
557
|
-
? error['path']
|
|
558
|
-
: `${configTomlPath} or ${hooksJsonPath}`;
|
|
559
|
-
return { success: false, message: `Config file not found: ${missingPath}` };
|
|
560
|
-
}
|
|
561
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
562
|
-
return { success: false, message: `Failed to update Codex config: ${message}` };
|
|
563
|
-
}
|
|
564
|
-
};
|
|
565
|
-
|
|
566
|
-
export const installCursor = async (
|
|
567
|
-
options: InstallOptions = {},
|
|
568
|
-
): Promise<{ success: boolean; message: string }> => {
|
|
569
|
-
const homeDirectory = options.homeDirectory ?? homedir();
|
|
570
|
-
const configPath = `${homeDirectory}/.cursor/hooks.json`;
|
|
571
|
-
try {
|
|
572
|
-
const raw = await readFile(configPath, 'utf8');
|
|
573
|
-
const config = parseCursorConfig(raw);
|
|
574
|
-
config.version ??= 1;
|
|
575
|
-
config.hooks ??= {};
|
|
576
|
-
|
|
577
|
-
let updated = false;
|
|
578
|
-
for (const eventName of CURSOR_HOOK_EVENTS) {
|
|
579
|
-
const [hooks, changed] = addCursorHook(config.hooks[eventName], eventName);
|
|
580
|
-
config.hooks[eventName] = hooks;
|
|
581
|
-
updated ||= changed;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
if (!updated) {
|
|
585
|
-
return { success: true, message: `Already configured: ${configPath}` };
|
|
586
|
-
}
|
|
587
|
-
await replaceTextAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
588
|
-
return { success: true, message: `Updated ${configPath}` };
|
|
589
|
-
} catch (error) {
|
|
590
|
-
if (isErrno(error, 'ENOENT')) {
|
|
591
|
-
return { success: false, message: `Config file not found: ${configPath}` };
|
|
592
|
-
}
|
|
593
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
594
|
-
return { success: false, message: `Failed to update Cursor hooks.json: ${message}` };
|
|
595
|
-
}
|
|
596
|
-
};
|
|
597
|
-
|
|
598
|
-
export const installAll = async (
|
|
599
|
-
options: InstallOptions = {},
|
|
600
|
-
): Promise<{ target: string; success: boolean; message: string }[]> => {
|
|
601
|
-
return [
|
|
602
|
-
{ target: 'claude', ...(await installClaude(options)) },
|
|
603
|
-
{ target: 'codex', ...(await installCodex(options)) },
|
|
604
|
-
{ target: 'cursor', ...(await installCursor(options)) },
|
|
605
|
-
{ target: 'pi', ...(await installPi(options)) },
|
|
606
|
-
{ target: 'oh-my-pi', ...(await installOhMyPi(options)) },
|
|
607
|
-
];
|
|
608
|
-
};
|
|
609
|
-
|
|
610
|
-
export { addCursorHook, parseCursorConfig, type CursorHook };
|
package/src/lib/log.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
const LOG_PATH = `${homedir()}/.claude/tripwire.log`;
|
|
6
|
-
|
|
7
|
-
try {
|
|
8
|
-
mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
9
|
-
} catch {
|
|
10
|
-
// Directory creation failure is non-fatal — logging is best-effort.
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const logError = (rule: string, err: unknown): void => {
|
|
14
|
-
const stamp = new Date().toISOString();
|
|
15
|
-
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
16
|
-
try {
|
|
17
|
-
appendFileSync(LOG_PATH, `[${stamp}] [${rule}] ${msg}\n`);
|
|
18
|
-
} catch {
|
|
19
|
-
// Never block the agent on a logging failure.
|
|
20
|
-
}
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export { logError };
|