@seanmozeik/tripwire 0.1.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 ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@seanmozeik/tripwire",
3
+ "version": "0.1.0",
4
+ "description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "tripwire": "./dist/tripwire-cli.js",
8
+ "tripwire-hook": "./dist/tripwire.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "package.json",
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "type": "module",
17
+ "exports": {
18
+ ".": "./src/index.ts"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "build": "bun scripts/build.ts",
25
+ "prepublishOnly": "bun run build",
26
+ "check": "bun run format && bun run lint:fix && bun run typecheck",
27
+ "format": "oxfmt --write .",
28
+ "lint": "oxlint --tsconfig tsconfig.oxlint.json .",
29
+ "lint:fix": "oxlint --format agent --tsconfig tsconfig.oxlint.json --fix .",
30
+ "test": "bun test",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "dependencies": {
34
+ "@effect/platform-bun": "^4.0.0-beta.65",
35
+ "effect": "^4.0.0-beta.65",
36
+ "shell-quote": "^1.8.3"
37
+ },
38
+ "devDependencies": {
39
+ "@types/bun": "^1.3.13",
40
+ "@types/shell-quote": "^1.7.5",
41
+ "oxfmt": "^0.48.0",
42
+ "oxlint": "^1.61.0",
43
+ "oxlint-tsgolint": "^0.22.1",
44
+ "typescript": "^6.0.3"
45
+ },
46
+ "engines": {
47
+ "bun": ">=1.0"
48
+ }
49
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env bun
2
+ // `tripwire test '<command>'` — pipe a synthetic event through the
3
+ // Dispatcher and pretty-print the decision. Indispensable for tuning
4
+ // Rules without going through Claude Code.
5
+ //
6
+ // Usage:
7
+ // Bun src/cli.ts test 'rm -rf /etc'
8
+ // Bun src/cli.ts test --tool=Read --path=.env
9
+ // Bun src/cli.ts test --post --tool=Bash --stdout='ghp_<token>'
10
+
11
+ import { spawnSync } from 'node:child_process';
12
+
13
+ const DISPATCH_BIN = `${import.meta.dir}/../dist/tripwire.js`;
14
+
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
+ }
24
+
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
+ };
62
+
63
+ interface BuiltEvent {
64
+ hook_event_name: string;
65
+ tool_name: string;
66
+ cwd: string;
67
+ session_id: string;
68
+ tool_input?: unknown;
69
+ tool_response?: unknown;
70
+ }
71
+
72
+ const buildToolInput = (tool: string, args: CliArgs): unknown => {
73
+ if (tool === 'Bash') {
74
+ return { command: args.command ?? '' };
75
+ }
76
+ if (tool === 'Read') {
77
+ return { file_path: args.path ?? '' };
78
+ }
79
+ if (tool === 'Write') {
80
+ return { file_path: args.path ?? '', content: args.content ?? '' };
81
+ }
82
+ if (tool === 'Edit' || tool === 'MultiEdit') {
83
+ return { file_path: args.path ?? '', old_string: '', new_string: args.content ?? '' };
84
+ }
85
+ return undefined;
86
+ };
87
+
88
+ const buildEvent = (args: CliArgs): BuiltEvent => {
89
+ const eventName = args.post ? 'PostToolUse' : 'PreToolUse';
90
+ const tool = args.tool;
91
+ const event: BuiltEvent = {
92
+ hook_event_name: eventName,
93
+ tool_name: tool,
94
+ cwd: process.cwd(),
95
+ session_id: 'tripwire-cli-test',
96
+ tool_input: buildToolInput(tool, args),
97
+ };
98
+ if (args.post) {
99
+ event.tool_response =
100
+ tool === 'Bash'
101
+ ? { stdout: args.stdout ?? '', stderr: args.stderr ?? '' }
102
+ : { content: args.content ?? '' };
103
+ }
104
+ return event;
105
+ };
106
+
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
+ };
121
+
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,
134
+ });
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;
140
+ 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);
145
+ }
146
+ };
147
+
148
+ main();
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env bun
2
+ // Tripwire — Claude Code hooks dispatcher.
3
+ //
4
+ // Reads a hook event JSON payload on stdin, routes by hook_event_name +
5
+ // Tool_name, runs rules with per-rule timeouts, merges decisions
6
+ // (most-restrictive wins), wraps allowed Bash commands through rtk for
7
+ // Token-saver rewriting, scans PostToolUse output for secrets via
8
+ // Betterleaks, and writes Claude Code's expected JSON response on stdout.
9
+ //
10
+ // Design rules:
11
+ // - A buggy or slow rule must never block the agent. Every rule runs
12
+ // Under a timeout; any defect or timeout collapses to `allow`, logged.
13
+ // - Block messages address the agent in second person and name the
14
+ // Concrete alternative tool / approach. No vague "denied for safety".
15
+ // - One bypass token: `tripwire-allow` (any comment syntax) on a code
16
+ // Line, or `# tripwire-allow` in a bash command.
17
+
18
+ import { BunRuntime } from '@effect/platform-bun';
19
+ import { Cause, Effect, Exit, Schema } from 'effect';
20
+
21
+ import { parseCommand } from './lib/bash';
22
+ import { loadConfig, type Config } from './lib/config';
23
+ import { type Decision, allow, merge } from './lib/decision';
24
+ import {
25
+ type BashInput,
26
+ type EditInput,
27
+ type HookEvent,
28
+ HookEventSchema,
29
+ type ReadInput,
30
+ type WriteInput,
31
+ isBashInput,
32
+ isEditInput,
33
+ isReadInput,
34
+ isWriteInput,
35
+ } from './lib/event.ts';
36
+ import { logError } from './lib/log';
37
+ import { runRtkRewrite } from './lib/rtk';
38
+ import { bashDeny } from './rules/bash-deny';
39
+ import { bashGit } from './rules/bash-git';
40
+ import { bashNetworkInstall } from './rules/bash-network-install';
41
+ import { bashRedirect } from './rules/bash-redirect';
42
+ import { bashScopedRm } from './rules/bash-scoped-rm';
43
+ import { bashTarExplosion } from './rules/bash-tar-explosion';
44
+ import { bashToolPolicy } from './rules/bash-tool-policy';
45
+ import { configCustom } from './rules/config-custom';
46
+ import { lazyCode } from './rules/lazy-code';
47
+ import { pathProtect } from './rules/path-protect';
48
+ import { postSecretScrub } from './rules/post-secret-scrub';
49
+ import { readProtect } from './rules/read-protect';
50
+
51
+ const RULE_TIMEOUT_MS = 250;
52
+ const POST_RULE_TIMEOUT_MS = 5000; // Betterleaks subprocess can take longer
53
+
54
+ const readStdin = async (): Promise<string> => {
55
+ const chunks: Buffer[] = [];
56
+ for await (const chunk of process.stdin) {
57
+ chunks.push(chunk as Buffer);
58
+ }
59
+ return Buffer.concat(chunks).toString('utf8');
60
+ };
61
+
62
+ const writeAllow = (): void => {
63
+ process.stdout.write('{"continue": true}\n');
64
+ };
65
+
66
+ // Codex's PreToolUse hook rejects `hookSpecificOutput.additionalContext`
67
+ // (openai/codex issue #19385) and `updatedInput` (#18491). Detect Codex
68
+ // Via its `turn_id` extension and downgrade output accordingly. Claude
69
+ // Code accepts both, so we only narrow when we can confirm we're on Codex.
70
+ const isCodex = (event: HookEvent): boolean => event.turn_id !== undefined;
71
+
72
+ const writeRewriteAllow = (event: HookEvent, command: string, _reason?: string): void => {
73
+ // Codex's PreToolUse parser strict-rejects `updatedInput` (openai/codex
74
+ // #18491 — parsed but unimplemented as of codex-cli 0.12x). On Codex we
75
+ // Can't transparently rewrite, so pass the original command through
76
+ // Silently. Until #18491 lands, RTK savings are Claude-only.
77
+ if (isCodex(event)) {
78
+ writeAllow();
79
+ return;
80
+ }
81
+ // Claude Code: rewrite silently. We deliberately omit
82
+ // `permissionDecisionReason` so the model's context isn't polluted with
83
+ // "rtk rewrote your command" chatter every Bash call.
84
+ const out = {
85
+ continue: true,
86
+ hookSpecificOutput: { hookEventName: event.hook_event_name, updatedInput: { command } },
87
+ };
88
+ process.stdout.write(`${JSON.stringify(out)}\n`);
89
+ };
90
+
91
+ interface WarnOutput {
92
+ hookEventName: string;
93
+ additionalContext?: string;
94
+ updatedInput?: { command: string };
95
+ }
96
+
97
+ const writeWarn = (event: HookEvent, decision: Decision): void => {
98
+ const eventName = event.hook_event_name;
99
+ const reason = `[tripwire:${decision.rule}] ${decision.message}`;
100
+ if (isCodex(event)) {
101
+ // Codex rejects both `additionalContext` and `updatedInput` on
102
+ // PreToolUse. Send only `systemMessage`; the rewrite (if any) is
103
+ // Dropped and the original command runs.
104
+ process.stdout.write(`${JSON.stringify({ continue: true, systemMessage: reason })}\n`);
105
+ return;
106
+ }
107
+ const hookSpecificOutput: WarnOutput = { hookEventName: eventName, additionalContext: reason };
108
+ if (decision.rewriteCommand !== undefined) {
109
+ hookSpecificOutput.updatedInput = { command: decision.rewriteCommand };
110
+ }
111
+ process.stdout.write(`${JSON.stringify({ continue: true, hookSpecificOutput })}\n`);
112
+ };
113
+
114
+ const writePreToolGate = (eventName: string, decision: Decision): void => {
115
+ const out = {
116
+ hookSpecificOutput: {
117
+ hookEventName: eventName,
118
+ permissionDecision: decision.kind === 'deny' ? 'deny' : 'ask',
119
+ permissionDecisionReason: `[tripwire:${decision.rule}] ${decision.message}`,
120
+ },
121
+ };
122
+ process.stdout.write(`${JSON.stringify(out)}\n`);
123
+ };
124
+
125
+ const writePostToolBlock = (decision: Decision): void => {
126
+ const out = {
127
+ continue: true,
128
+ decision: 'block',
129
+ reason: `[tripwire:${decision.rule}] ${decision.message}`,
130
+ };
131
+ process.stdout.write(`${JSON.stringify(out)}\n`);
132
+ };
133
+
134
+ // Tool names vary across hosts. Claude Code uses `Bash`/`Read`/`Write`/
135
+ // `Edit`/`MultiEdit`. Codex sends `apply_patch` for file edits. Devin sends
136
+ // `exec` for shell. Pi (via pi-hooks) sends lowercase `bash`/`read`/`write`/
137
+ // `edit`. Normalize everything to the Claude vocabulary so the rest of the
138
+ // Dispatcher only deals with one set of names.
139
+ const normalizeToolName = (name: string): string => {
140
+ const n = name.toLowerCase();
141
+ if (n === 'bash' || n === 'exec' || n === 'shell' || n === 'run_command') {
142
+ return 'Bash';
143
+ }
144
+ if (n === 'read' || n === 'read_file') {
145
+ return 'Read';
146
+ }
147
+ if (n === 'write' || n === 'write_file') {
148
+ return 'Write';
149
+ }
150
+ if (n === 'edit' || n === 'edit_file' || n === 'multiedit' || n === 'apply_patch') {
151
+ return 'Edit';
152
+ }
153
+ if (n === 'webfetch' || n === 'web_fetch' || n === 'fetch') {
154
+ return 'WebFetch';
155
+ }
156
+ return name;
157
+ };
158
+
159
+ type RuleFn = () => Decision;
160
+
161
+ const runRule = (name: string, fn: RuleFn, timeoutMs: number): Effect.Effect<Decision> =>
162
+ Effect.gen(function* () {
163
+ const exit = yield* Effect.exit(
164
+ Effect.try({ try: fn, catch: (e) => e }).pipe(Effect.timeout(timeoutMs)),
165
+ );
166
+ if (Exit.isSuccess(exit)) {
167
+ return exit.value;
168
+ }
169
+ logError(name, Cause.pretty(exit.cause));
170
+ return allow(name);
171
+ });
172
+
173
+ interface Rule {
174
+ readonly name: string;
175
+ readonly fn: RuleFn;
176
+ }
177
+
178
+ const collectPreToolUseRules = (tool: string, input: unknown, config: Config): Rule[] => {
179
+ const rules: Rule[] = [];
180
+ if (tool === 'Bash' && isBashInput(input)) {
181
+ const i: BashInput = input;
182
+ const segments = parseCommand(i.command);
183
+ rules.push({ name: 'bash-deny', fn: () => bashDeny(segments, i.command) });
184
+ rules.push({
185
+ name: 'bash-git',
186
+ fn: () => bashGit(segments, i.command, config.git ?? { enforceConventionalCommits: true }),
187
+ });
188
+ rules.push({
189
+ name: 'bash-scoped-rm',
190
+ fn: () => bashScopedRm(segments, i.command, config.safePaths ?? {}),
191
+ });
192
+ rules.push({ name: 'bash-redirect', fn: () => bashRedirect(segments, i.command) });
193
+ rules.push({ name: 'bash-network-install', fn: () => bashNetworkInstall(segments, i.command) });
194
+ rules.push({ name: 'bash-tar-explosion', fn: () => bashTarExplosion(segments, i.command) });
195
+ rules.push({ name: 'bash-tool-policy', fn: () => bashToolPolicy(segments, i.command) });
196
+ rules.push({
197
+ name: 'config-custom',
198
+ fn: () =>
199
+ configCustom(
200
+ segments,
201
+ i.command,
202
+ config.blockedCommands ?? [],
203
+ config.allowedCommands ?? [],
204
+ ),
205
+ });
206
+ return rules;
207
+ }
208
+ if (tool === 'Read' && isReadInput(input)) {
209
+ const i: ReadInput = input;
210
+ rules.push({ name: 'read-protect', fn: () => readProtect(i) });
211
+ return rules;
212
+ }
213
+ const isEdit = (tool === 'Edit' || tool === 'MultiEdit') && isEditInput(input);
214
+ const isWrite = tool === 'Write' && isWriteInput(input);
215
+ if (isEdit) {
216
+ const i: EditInput = input;
217
+ rules.push({ name: 'path-protect', fn: () => pathProtect(i) });
218
+ rules.push({ name: 'lazy-code', fn: () => lazyCode(i) });
219
+ } else if (isWrite) {
220
+ const i: WriteInput = input;
221
+ rules.push({ name: 'path-protect', fn: () => pathProtect(i) });
222
+ rules.push({ name: 'lazy-code', fn: () => lazyCode(i) });
223
+ }
224
+ return rules;
225
+ };
226
+
227
+ const collectPostToolUseRules = (tool: string, response: unknown): Rule[] => {
228
+ if (tool === 'Bash' || tool === 'Read' || tool === 'WebFetch') {
229
+ return [{ name: 'post-secret-scrub', fn: () => postSecretScrub({ toolName: tool, response }) }];
230
+ }
231
+ return [];
232
+ };
233
+
234
+ const runRules = (rules: readonly Rule[], timeoutMs: number): Effect.Effect<Decision> =>
235
+ Effect.gen(function* () {
236
+ if (rules.length === 0) {
237
+ return allow('no-rules');
238
+ }
239
+ const decisions: Decision[] = [];
240
+ for (const r of rules) {
241
+ decisions.push(yield* runRule(r.name, r.fn, timeoutMs));
242
+ }
243
+ return merge(decisions);
244
+ });
245
+
246
+ const handleBashAllow = (event: HookEvent, decision: Decision, config: Config): void => {
247
+ // After the gate passes (allow or warn), apply rtk command-rewrite. If
248
+ // Rtk doesn't change the command, fall through to normal allow / warn.
249
+ const rtk = runRtkRewrite(event, config.rtk ?? { enabled: false });
250
+ const original = (event.tool_input as { command?: string } | undefined)?.command ?? '';
251
+ const rewritten =
252
+ rtk.updatedCommand !== undefined && rtk.updatedCommand !== original ? rtk.updatedCommand : null;
253
+
254
+ if (decision.kind === 'warn') {
255
+ if (rewritten !== null) {
256
+ writeWarn(event, { ...decision, rewriteCommand: rewritten });
257
+ return;
258
+ }
259
+ writeWarn(event, decision);
260
+ return;
261
+ }
262
+ if (rewritten !== null) {
263
+ writeRewriteAllow(event, rewritten, rtk.reason);
264
+ return;
265
+ }
266
+ writeAllow();
267
+ };
268
+
269
+ const handleAllow = (event: HookEvent, decision: Decision, config: Config): void => {
270
+ const eventName = event.hook_event_name;
271
+ const tool = normalizeToolName(event.tool_name ?? '');
272
+ if (eventName === 'PreToolUse' && tool === 'Bash') {
273
+ handleBashAllow(event, decision, config);
274
+ return;
275
+ }
276
+ if (decision.kind === 'warn') {
277
+ writeWarn(event, decision);
278
+ return;
279
+ }
280
+ writeAllow();
281
+ };
282
+
283
+ const program = Effect.gen(function* () {
284
+ const config = yield* loadConfig();
285
+ const raw = yield* Effect.promise(readStdin);
286
+
287
+ const parseExit = yield* Effect.exit(
288
+ Effect.try({ try: () => JSON.parse(raw) as unknown, catch: (e) => e }),
289
+ );
290
+ if (Exit.isFailure(parseExit)) {
291
+ logError('parse', Cause.pretty(parseExit.cause));
292
+ writeAllow();
293
+ return;
294
+ }
295
+
296
+ const decodeExit = yield* Effect.exit(
297
+ Schema.decodeUnknownEffect(HookEventSchema)(parseExit.value),
298
+ );
299
+ if (Exit.isFailure(decodeExit)) {
300
+ logError('decode', Cause.pretty(decodeExit.cause));
301
+ writeAllow();
302
+ return;
303
+ }
304
+ const event = decodeExit.value;
305
+ const tool = normalizeToolName(event.tool_name ?? '');
306
+
307
+ if (event.hook_event_name === 'PreToolUse') {
308
+ const rules = collectPreToolUseRules(tool, event.tool_input, config);
309
+ const decision = yield* runRules(rules, RULE_TIMEOUT_MS);
310
+ if (decision.kind === 'deny' || decision.kind === 'ask') {
311
+ writePreToolGate(event.hook_event_name, decision);
312
+ return;
313
+ }
314
+ handleAllow(event, decision, config);
315
+ return;
316
+ }
317
+
318
+ if (event.hook_event_name === 'PostToolUse') {
319
+ const rules = collectPostToolUseRules(tool, event.tool_response);
320
+ const decision = yield* runRules(rules, POST_RULE_TIMEOUT_MS);
321
+ if (decision.kind === 'deny') {
322
+ writePostToolBlock(decision);
323
+ return;
324
+ }
325
+ writeAllow();
326
+ return;
327
+ }
328
+
329
+ writeAllow();
330
+ });
331
+
332
+ const handled = program.pipe(
333
+ Effect.catchCause((cause) => {
334
+ logError('dispatch-fatal', Cause.pretty(cause));
335
+ writeAllow();
336
+ return Effect.void;
337
+ }),
338
+ );
339
+
340
+ BunRuntime.runMain(handled);
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type { Decision } from './lib/decision.ts';
2
+ export type { HookEvent } from './lib/event.ts';
3
+ export type { Config } from './lib/config.ts';
4
+ export { allow, deny, ask, warn } from './lib/decision.ts';