@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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanmozeik/tripwire",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
5
5
  "license": "MIT",
6
6
  "bin": {
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 { spawnSync } from 'node:child_process';
12
-
13
- const DISPATCH_BIN = `${import.meta.dir}/../dist/tripwire.js`;
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
- interface CliArgs {
16
- readonly tool: string;
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 parseArgs = (argv: readonly string[]): CliArgs => {
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 = (tool: string, args: CliArgs): unknown => {
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: args.command ?? '' };
42
+ return { command: command ?? '' };
75
43
  }
76
44
  if (tool === 'Read') {
77
- return { file_path: args.path ?? '' };
45
+ return { file_path: path ?? '' };
78
46
  }
79
47
  if (tool === 'Write') {
80
- return { file_path: args.path ?? '', content: args.content ?? '' };
48
+ return { file_path: path ?? '', content: content ?? '' };
81
49
  }
82
50
  if (tool === 'Edit' || tool === 'MultiEdit') {
83
- return { file_path: args.path ?? '', old_string: '', new_string: args.content ?? '' };
51
+ return { file_path: path ?? '', old_string: '', new_string: content ?? '' };
84
52
  }
85
53
  return undefined;
86
54
  };
87
55
 
88
- const buildEvent = (args: CliArgs): BuiltEvent => {
89
- const eventName = args.post ? 'PostToolUse' : 'PreToolUse';
90
- const tool = args.tool;
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, args),
74
+ tool_input: buildToolInput(tool, command, path, content),
97
75
  };
98
- if (args.post) {
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 printUsage = (): void => {
108
- process.stdout.write(
109
- [
110
- 'tripwire CLI synthetic-event tester',
111
- '',
112
- 'Usage:',
113
- " tripwire test '<command>' # PreToolUse Bash",
114
- ' tripwire test --tool=Read --path=.env # PreToolUse Read',
115
- " tripwire test --tool=Write --path=foo.ts --content='TODO finish'",
116
- " tripwire test --post --tool=Bash --stdout='ghp_REAL_TOKEN' # PostToolUse",
117
- '',
118
- ].join('\n'),
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 main = (): void => {
123
- const argv = process.argv.slice(2);
124
- if (argv.length === 0 || argv[0] !== 'test') {
125
- printUsage();
126
- process.exit(0);
127
- }
128
- const args = parseArgs(argv.slice(1));
129
- const event = buildEvent(args);
130
- const result = spawnSync(DISPATCH_BIN, [], {
131
- input: JSON.stringify(event),
132
- encoding: 'utf8',
133
- timeout: 10_000,
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
- if (result.error !== undefined) {
136
- process.stderr.write(`error: ${String(result.error)}\n`);
137
- process.exit(1);
138
- }
139
- const stdout: string = result.stdout;
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
- const parsed = JSON.parse(stdout) as unknown;
142
- process.stdout.write(`${JSON.stringify(parsed, null, 2)}\n`);
143
- } catch {
144
- process.stdout.write(stdout);
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
- main();
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 rules = collectPreToolUseRules(tool, event.tool_input, config);
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 rules = collectPostToolUseRules(tool, event.tool_response);
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
- BunRuntime.runMain(handled);
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
- // eslint-disable-next-line promise/prefer-await-to-then
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 };