@seanmozeik/tripwire 0.1.0 → 0.4.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.
- package/README.md +25 -7
- package/dist/tripwire-cli.js +9 -4
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +49 -49
- package/dist/tripwire.js.jsc +0 -0
- package/package.json +1 -1
- package/src/cli.ts +190 -98
- package/src/dispatch.ts +34 -10
- package/src/index.ts +2 -0
- package/src/lib/config.ts +6 -2
- package/src/lib/install.ts +238 -0
- package/src/rules/config-custom.ts +120 -11
package/dist/tripwire.js.jsc
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -3,62 +3,25 @@
|
|
|
3
3
|
// Dispatcher and pretty-print the decision. Indispensable for tuning
|
|
4
4
|
// Rules without going through Claude Code.
|
|
5
5
|
//
|
|
6
|
+
// `tripwire install <target>` — install tripwire hooks for AI agents.
|
|
7
|
+
//
|
|
6
8
|
// Usage:
|
|
7
9
|
// Bun src/cli.ts test 'rm -rf /etc'
|
|
8
10
|
// Bun src/cli.ts test --tool=Read --path=.env
|
|
9
11
|
// Bun src/cli.ts test --post --tool=Bash --stdout='ghp_<token>'
|
|
12
|
+
// Bun src/cli.ts install claude
|
|
13
|
+
// Bun src/cli.ts install codex
|
|
14
|
+
// Bun src/cli.ts install pi
|
|
15
|
+
// Bun src/cli.ts install all
|
|
10
16
|
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
import { BunServices } from '@effect/platform-bun';
|
|
18
|
+
import { Effect, Option } from 'effect';
|
|
19
|
+
import { Argument, Command, Flag } from 'effect/unstable/cli';
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
readonly post: boolean;
|
|
18
|
-
readonly path: string | undefined;
|
|
19
|
-
readonly command: string | undefined;
|
|
20
|
-
readonly stdout: string | undefined;
|
|
21
|
-
readonly stderr: string | undefined;
|
|
22
|
-
readonly content: string | undefined;
|
|
23
|
-
}
|
|
21
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
22
|
+
import { installAll, installClaude, installCodex, installPi } from './lib/install';
|
|
24
23
|
|
|
25
|
-
const
|
|
26
|
-
let tool = 'Bash';
|
|
27
|
-
let post = false;
|
|
28
|
-
let path: string | undefined;
|
|
29
|
-
let command: string | undefined;
|
|
30
|
-
let stdout: string | undefined;
|
|
31
|
-
let stderr: string | undefined;
|
|
32
|
-
let content: string | undefined;
|
|
33
|
-
for (const a of argv) {
|
|
34
|
-
if (a === '--post') {
|
|
35
|
-
post = true;
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
if (a.startsWith('--tool=')) {
|
|
39
|
-
tool = a.slice('--tool='.length);
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
if (a.startsWith('--path=')) {
|
|
43
|
-
path = a.slice('--path='.length);
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
if (a.startsWith('--stdout=')) {
|
|
47
|
-
stdout = a.slice('--stdout='.length);
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
if (a.startsWith('--stderr=')) {
|
|
51
|
-
stderr = a.slice('--stderr='.length);
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
if (a.startsWith('--content=')) {
|
|
55
|
-
content = a.slice('--content='.length);
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
command ??= a;
|
|
59
|
-
}
|
|
60
|
-
return { tool, post, path, command, stdout, stderr, content };
|
|
61
|
-
};
|
|
24
|
+
const DISPATCH_BIN = `${import.meta.dir}/../dist/tripwire.js`;
|
|
62
25
|
|
|
63
26
|
interface BuiltEvent {
|
|
64
27
|
hook_event_name: string;
|
|
@@ -69,80 +32,209 @@ interface BuiltEvent {
|
|
|
69
32
|
tool_response?: unknown;
|
|
70
33
|
}
|
|
71
34
|
|
|
72
|
-
const buildToolInput = (
|
|
35
|
+
const buildToolInput = (
|
|
36
|
+
tool: string,
|
|
37
|
+
command: string | undefined,
|
|
38
|
+
path: string | undefined,
|
|
39
|
+
content: string | undefined,
|
|
40
|
+
): unknown => {
|
|
73
41
|
if (tool === 'Bash') {
|
|
74
|
-
return { command:
|
|
42
|
+
return { command: command ?? '' };
|
|
75
43
|
}
|
|
76
44
|
if (tool === 'Read') {
|
|
77
|
-
return { file_path:
|
|
45
|
+
return { file_path: path ?? '' };
|
|
78
46
|
}
|
|
79
47
|
if (tool === 'Write') {
|
|
80
|
-
return { file_path:
|
|
48
|
+
return { file_path: path ?? '', content: content ?? '' };
|
|
81
49
|
}
|
|
82
50
|
if (tool === 'Edit' || tool === 'MultiEdit') {
|
|
83
|
-
return { file_path:
|
|
51
|
+
return { file_path: path ?? '', old_string: '', new_string: content ?? '' };
|
|
84
52
|
}
|
|
85
53
|
return undefined;
|
|
86
54
|
};
|
|
87
55
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
56
|
+
interface EventParams {
|
|
57
|
+
readonly tool: string;
|
|
58
|
+
readonly post: boolean;
|
|
59
|
+
readonly command: string | undefined;
|
|
60
|
+
readonly path: string | undefined;
|
|
61
|
+
readonly stdout: string | undefined;
|
|
62
|
+
readonly stderr: string | undefined;
|
|
63
|
+
readonly content: string | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const buildEvent = (params: EventParams): BuiltEvent => {
|
|
67
|
+
const { tool, post, command, path, stdout, stderr, content } = params;
|
|
68
|
+
const eventName = post ? 'PostToolUse' : 'PreToolUse';
|
|
91
69
|
const event: BuiltEvent = {
|
|
92
70
|
hook_event_name: eventName,
|
|
93
71
|
tool_name: tool,
|
|
94
72
|
cwd: process.cwd(),
|
|
95
73
|
session_id: 'tripwire-cli-test',
|
|
96
|
-
tool_input: buildToolInput(tool,
|
|
74
|
+
tool_input: buildToolInput(tool, command, path, content),
|
|
97
75
|
};
|
|
98
|
-
if (
|
|
76
|
+
if (post) {
|
|
99
77
|
event.tool_response =
|
|
100
|
-
tool === 'Bash'
|
|
101
|
-
? { stdout: args.stdout ?? '', stderr: args.stderr ?? '' }
|
|
102
|
-
: { content: args.content ?? '' };
|
|
78
|
+
tool === 'Bash' ? { stdout: stdout ?? '', stderr: stderr ?? '' } : { content: content ?? '' };
|
|
103
79
|
}
|
|
104
80
|
return event;
|
|
105
81
|
};
|
|
106
82
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
83
|
+
const runTest = (config: {
|
|
84
|
+
readonly command: string | undefined;
|
|
85
|
+
readonly content: string | undefined;
|
|
86
|
+
readonly path: string | undefined;
|
|
87
|
+
readonly post: boolean;
|
|
88
|
+
readonly stderr: string | undefined;
|
|
89
|
+
readonly stdout: string | undefined;
|
|
90
|
+
readonly tool: string;
|
|
91
|
+
}): Effect.Effect<void> =>
|
|
92
|
+
Effect.sync(() => {
|
|
93
|
+
const { command, content, path, post, stderr, stdout, tool } = config;
|
|
94
|
+
const event = buildEvent({ tool, post, command, path, stdout, stderr, content });
|
|
95
|
+
const result = Bun.spawnSync([DISPATCH_BIN], {
|
|
96
|
+
stdin: new TextEncoder().encode(JSON.stringify(event)),
|
|
97
|
+
timeout: 10_000,
|
|
98
|
+
stdout: 'pipe',
|
|
99
|
+
stderr: 'pipe',
|
|
100
|
+
});
|
|
101
|
+
if (result.exitCode !== 0) {
|
|
102
|
+
const errorOutput = new TextDecoder().decode(result.stderr);
|
|
103
|
+
console.error(`error: ${errorOutput}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const output = new TextDecoder().decode(result.stdout);
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(output) as unknown;
|
|
109
|
+
console.log(JSON.stringify(parsed, null, 2));
|
|
110
|
+
} catch {
|
|
111
|
+
console.log(output);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
121
114
|
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
115
|
+
const testCommand = Command.make(
|
|
116
|
+
'test',
|
|
117
|
+
{
|
|
118
|
+
command: Argument.string('command').pipe(
|
|
119
|
+
Argument.optional,
|
|
120
|
+
Argument.withDescription('Command to test (for Bash tool)'),
|
|
121
|
+
),
|
|
122
|
+
content: Flag.string('content').pipe(
|
|
123
|
+
Flag.optional,
|
|
124
|
+
Flag.withDescription('Content for Write/Edit tools'),
|
|
125
|
+
),
|
|
126
|
+
path: Flag.string('path').pipe(
|
|
127
|
+
Flag.optional,
|
|
128
|
+
Flag.withDescription('File path for Read/Write/Edit tools'),
|
|
129
|
+
),
|
|
130
|
+
post: Flag.boolean('post').pipe(Flag.withDescription('Test PostToolUse instead of PreToolUse')),
|
|
131
|
+
stderr: Flag.string('stderr').pipe(
|
|
132
|
+
Flag.optional,
|
|
133
|
+
Flag.withDescription('Stderr for PostToolUse Bash'),
|
|
134
|
+
),
|
|
135
|
+
stdout: Flag.string('stdout').pipe(
|
|
136
|
+
Flag.optional,
|
|
137
|
+
Flag.withDescription('Stdout for PostToolUse Bash'),
|
|
138
|
+
),
|
|
139
|
+
tool: Flag.string('tool').pipe(
|
|
140
|
+
Flag.withDefault('Bash'),
|
|
141
|
+
Flag.withDescription('Tool name (Bash, Read, Write, Edit, MultiEdit)'),
|
|
142
|
+
),
|
|
143
|
+
},
|
|
144
|
+
({ command, content, path, post, stderr, stdout, tool }) =>
|
|
145
|
+
runTest({
|
|
146
|
+
command: Option.getOrUndefined(command),
|
|
147
|
+
content: Option.getOrUndefined(content),
|
|
148
|
+
path: Option.getOrUndefined(path),
|
|
149
|
+
post,
|
|
150
|
+
stderr: Option.getOrUndefined(stderr),
|
|
151
|
+
stdout: Option.getOrUndefined(stdout),
|
|
152
|
+
tool,
|
|
153
|
+
}),
|
|
154
|
+
).pipe(Command.withDescription('Test a synthetic hook event'));
|
|
155
|
+
|
|
156
|
+
const runInstall = (target: string): Effect.Effect<void> =>
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
if (!['claude', 'codex', 'pi', 'all'].includes(target)) {
|
|
159
|
+
console.error(`error: unknown target "${target}"`);
|
|
160
|
+
console.error('Valid targets: claude, codex, pi, all');
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let results: {
|
|
165
|
+
readonly target: string;
|
|
166
|
+
readonly result: { readonly success: boolean; readonly message: string };
|
|
167
|
+
}[];
|
|
168
|
+
|
|
169
|
+
switch (target) {
|
|
170
|
+
case 'claude': {
|
|
171
|
+
const result = yield* Effect.promise(() => installClaude());
|
|
172
|
+
results = [{ target: 'claude', result }];
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case 'codex': {
|
|
176
|
+
const result = yield* Effect.promise(() => installCodex());
|
|
177
|
+
results = [{ target: 'codex', result }];
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case 'pi': {
|
|
181
|
+
const result = yield* Effect.promise(() => installPi());
|
|
182
|
+
results = [{ target: 'pi', result }];
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case 'all': {
|
|
186
|
+
const installResults = yield* Effect.promise(() => installAll());
|
|
187
|
+
results = installResults.map((r) => ({ target: r.target, result: r }));
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
default: {
|
|
191
|
+
results = [];
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let hasFailure = false;
|
|
197
|
+
for (const { target: t, result: r } of results) {
|
|
198
|
+
if (r.success) {
|
|
199
|
+
const symbol = r.message.startsWith('Already configured') ? '⊙' : '✓';
|
|
200
|
+
console.log(`${symbol} [${t}] ${r.message}`);
|
|
201
|
+
} else {
|
|
202
|
+
console.error(`✗ [${t}] ${r.message}`);
|
|
203
|
+
hasFailure = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (hasFailure) {
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
134
210
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
211
|
+
|
|
212
|
+
const installCommand = Command.make(
|
|
213
|
+
'install',
|
|
214
|
+
{
|
|
215
|
+
target: Argument.string('target').pipe(
|
|
216
|
+
Argument.withDescription('Target agent (claude, codex, pi, or all)'),
|
|
217
|
+
),
|
|
218
|
+
},
|
|
219
|
+
({ target }) => runInstall(target),
|
|
220
|
+
).pipe(Command.withDescription('Install tripwire hooks for AI agents'));
|
|
221
|
+
|
|
222
|
+
const app = Command.make('tripwire').pipe(
|
|
223
|
+
Command.withDescription('Opinionated hooks dispatcher for AI coding agents'),
|
|
224
|
+
Command.withSubcommands([testCommand, installCommand]),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const program = Command.run(app, { version: pkg.version });
|
|
228
|
+
|
|
229
|
+
const main = async (): Promise<void> => {
|
|
140
230
|
try {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
231
|
+
await Effect.runPromise(program.pipe(Effect.provide(BunServices.layer)));
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
234
|
+
console.error(message);
|
|
235
|
+
process.exitCode = 1;
|
|
145
236
|
}
|
|
146
237
|
};
|
|
147
238
|
|
|
148
|
-
|
|
239
|
+
// oxlint-disable-next-line no-void, unicorn/prefer-top-level-await
|
|
240
|
+
void main();
|
package/src/dispatch.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { BunRuntime } from '@effect/platform-bun';
|
|
|
19
19
|
import { Cause, Effect, Exit, Schema } from 'effect';
|
|
20
20
|
|
|
21
21
|
import { parseCommand } from './lib/bash';
|
|
22
|
-
import { loadConfig, type Config } from './lib/config';
|
|
22
|
+
import { getDefaultConfig, loadConfig, mergeWithDefaults, type Config } from './lib/config';
|
|
23
23
|
import { type Decision, allow, merge } from './lib/decision';
|
|
24
24
|
import {
|
|
25
25
|
type BashInput,
|
|
@@ -48,9 +48,6 @@ import { pathProtect } from './rules/path-protect';
|
|
|
48
48
|
import { postSecretScrub } from './rules/post-secret-scrub';
|
|
49
49
|
import { readProtect } from './rules/read-protect';
|
|
50
50
|
|
|
51
|
-
const RULE_TIMEOUT_MS = 250;
|
|
52
|
-
const POST_RULE_TIMEOUT_MS = 5000; // Betterleaks subprocess can take longer
|
|
53
|
-
|
|
54
51
|
const readStdin = async (): Promise<string> => {
|
|
55
52
|
const chunks: Buffer[] = [];
|
|
56
53
|
for await (const chunk of process.stdin) {
|
|
@@ -243,6 +240,25 @@ const runRules = (rules: readonly Rule[], timeoutMs: number): Effect.Effect<Deci
|
|
|
243
240
|
return merge(decisions);
|
|
244
241
|
});
|
|
245
242
|
|
|
243
|
+
const runRulesSync = (rules: readonly Rule[]): Decision => {
|
|
244
|
+
if (rules.length === 0) {
|
|
245
|
+
return allow('no-rules');
|
|
246
|
+
}
|
|
247
|
+
return merge(rules.map((r) => r.fn()));
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const decide = (event: HookEvent, config: Config = getDefaultConfig()): Decision => {
|
|
251
|
+
const mergedConfig = mergeWithDefaults(config);
|
|
252
|
+
const tool = normalizeToolName(event.tool_name ?? '');
|
|
253
|
+
if (event.hook_event_name === 'PreToolUse') {
|
|
254
|
+
return runRulesSync(collectPreToolUseRules(tool, event.tool_input, mergedConfig));
|
|
255
|
+
}
|
|
256
|
+
if (event.hook_event_name === 'PostToolUse') {
|
|
257
|
+
return runRulesSync(collectPostToolUseRules(tool, event.tool_response));
|
|
258
|
+
}
|
|
259
|
+
return allow('no-rules');
|
|
260
|
+
};
|
|
261
|
+
|
|
246
262
|
const handleBashAllow = (event: HookEvent, decision: Decision, config: Config): void => {
|
|
247
263
|
// After the gate passes (allow or warn), apply rtk command-rewrite. If
|
|
248
264
|
// Rtk doesn't change the command, fall through to normal allow / warn.
|
|
@@ -302,11 +318,9 @@ const program = Effect.gen(function* () {
|
|
|
302
318
|
return;
|
|
303
319
|
}
|
|
304
320
|
const event = decodeExit.value;
|
|
305
|
-
const tool = normalizeToolName(event.tool_name ?? '');
|
|
306
321
|
|
|
307
322
|
if (event.hook_event_name === 'PreToolUse') {
|
|
308
|
-
const
|
|
309
|
-
const decision = yield* runRules(rules, RULE_TIMEOUT_MS);
|
|
323
|
+
const decision = decide(event, config);
|
|
310
324
|
if (decision.kind === 'deny' || decision.kind === 'ask') {
|
|
311
325
|
writePreToolGate(event.hook_event_name, decision);
|
|
312
326
|
return;
|
|
@@ -316,8 +330,7 @@ const program = Effect.gen(function* () {
|
|
|
316
330
|
}
|
|
317
331
|
|
|
318
332
|
if (event.hook_event_name === 'PostToolUse') {
|
|
319
|
-
const
|
|
320
|
-
const decision = yield* runRules(rules, POST_RULE_TIMEOUT_MS);
|
|
333
|
+
const decision = decide(event, config);
|
|
321
334
|
if (decision.kind === 'deny') {
|
|
322
335
|
writePostToolBlock(decision);
|
|
323
336
|
return;
|
|
@@ -337,4 +350,15 @@ const handled = program.pipe(
|
|
|
337
350
|
}),
|
|
338
351
|
);
|
|
339
352
|
|
|
340
|
-
|
|
353
|
+
if (import.meta.main) {
|
|
354
|
+
BunRuntime.runMain(handled);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export {
|
|
358
|
+
collectPostToolUseRules,
|
|
359
|
+
collectPreToolUseRules,
|
|
360
|
+
decide,
|
|
361
|
+
normalizeToolName,
|
|
362
|
+
runRules,
|
|
363
|
+
runRulesSync,
|
|
364
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -2,3 +2,5 @@ export type { Decision } from './lib/decision.ts';
|
|
|
2
2
|
export type { HookEvent } from './lib/event.ts';
|
|
3
3
|
export type { Config } from './lib/config.ts';
|
|
4
4
|
export { allow, deny, ask, warn } from './lib/decision.ts';
|
|
5
|
+
export { decide } from './dispatch.ts';
|
|
6
|
+
export { getDefaultConfig, loadConfig, mergeWithDefaults } from './lib/config.ts';
|
package/src/lib/config.ts
CHANGED
|
@@ -11,6 +11,10 @@ const BlockRuleSchema = Schema.Struct({
|
|
|
11
11
|
pattern: Schema.String,
|
|
12
12
|
message: Schema.String,
|
|
13
13
|
action: Schema.optional(Schema.Union([Schema.Literal('deny'), Schema.Literal('ask')])),
|
|
14
|
+
requiresFlags: Schema.optional(Schema.Array(Schema.String)),
|
|
15
|
+
forbidsFlagValues: Schema.optional(
|
|
16
|
+
Schema.Array(Schema.Struct({ flag: Schema.String, values: Schema.Array(Schema.String) })),
|
|
17
|
+
),
|
|
14
18
|
});
|
|
15
19
|
|
|
16
20
|
const RtkConfigSchema = Schema.Struct({
|
|
@@ -89,7 +93,7 @@ export const loadConfig = (): Effect.Effect<Config> =>
|
|
|
89
93
|
return mergeWithDefaults(config);
|
|
90
94
|
}).pipe(
|
|
91
95
|
Effect.timeout(1000),
|
|
92
|
-
//
|
|
96
|
+
// oxlint-disable-next-line promise/prefer-await-to-then
|
|
93
97
|
Effect.catch(() => {
|
|
94
98
|
// Log error but return defaults to never block the agent
|
|
95
99
|
console.error('[tripwire] Config loading failed, using defaults');
|
|
@@ -103,4 +107,4 @@ export type GitConfig = typeof GitConfigSchema.Type;
|
|
|
103
107
|
export type SafePathsConfig = typeof SafePathsConfigSchema.Type;
|
|
104
108
|
export type Config = typeof ConfigSchema.Type;
|
|
105
109
|
|
|
106
|
-
export { CONFIG_PATH, ConfigSchema };
|
|
110
|
+
export { CONFIG_PATH, ConfigSchema, getDefaultConfig, mergeWithDefaults };
|