@seanmozeik/tripwire 0.6.7 → 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.
package/src/dispatch.ts CHANGED
@@ -1,21 +1,10 @@
1
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), scans PostToolUse output for secrets via
7
- // Betterleaks, and writes Claude Code's expected JSON response on stdout.
8
- //
9
- // Design rules:
10
- // - A buggy or slow rule must never block the agent. Every rule runs
11
- // Under a timeout; any defect or timeout collapses to `allow`, logged.
12
- // - Block messages address the agent in second person and name the
13
- // Concrete alternative tool / approach. No vague "denied for safety".
14
- // - One bypass token: `tripwire-allow` (any comment syntax) on a code
15
- // Line, or `# tripwire-allow` in a bash command.
2
+ // Hook dispatcher: decode stdin, apply timed rules, and write the host response.
3
+ // Rule defects and timeouts allow the tool call and write an error log.
4
+ // Denials tell the agent which safe alternative to use.
16
5
 
17
6
  import { BunRuntime } from '@effect/platform-bun';
18
- import { Cause, Effect, Exit, Schema } from 'effect';
7
+ import { Cause, Data, Effect, Exit, Schema } from 'effect';
19
8
 
20
9
  import { parseCommand } from './lib/bash';
21
10
  import {
@@ -24,7 +13,10 @@ import {
24
13
  loadConfigResult,
25
14
  mergeWithDefaults,
26
15
  type Config,
16
+ type ResolvedConfig,
17
+ type SecretScannerConfig,
27
18
  } from './lib/config';
19
+ import { cursorHost, normalizeHookInput, type HookHost } from './lib/cursor';
28
20
  import { type Decision, allow, deny, merge } from './lib/decision';
29
21
  import {
30
22
  type BashInput,
@@ -45,29 +37,59 @@ import { bashNetworkInstall } from './rules/bash-network-install';
45
37
  import { bashRedirect } from './rules/bash-redirect';
46
38
  import { bashScopedRm } from './rules/bash-scoped-rm';
47
39
  import { bashTarExplosion } from './rules/bash-tar-explosion';
48
- import { bashToolPolicy } from './rules/bash-tool-policy';
49
40
  import { configCustom } from './rules/config-custom';
50
41
  import { lazyCode } from './rules/lazy-code';
51
42
  import { pathProtect } from './rules/path-protect';
52
43
  import { postSecretScrub } from './rules/post-secret-scrub';
53
44
  import { readProtect } from './rules/read-protect';
45
+ import { toolPolicy } from './rules/tool-policy';
54
46
 
55
47
  const readStdin = async (): Promise<string> => {
56
48
  const chunks: Buffer[] = [];
57
49
  for await (const chunk of process.stdin) {
58
- chunks.push(chunk as Buffer);
50
+ const value: unknown = chunk;
51
+ if (value instanceof Uint8Array) {
52
+ chunks.push(Buffer.from(value));
53
+ } else {
54
+ throw new TypeError('Tripwire received a non-byte stdin chunk');
55
+ }
59
56
  }
60
57
  return Buffer.concat(chunks).toString('utf8');
61
58
  };
62
59
 
63
- const writeAllow = (): void => {
60
+ const NATIVE_HOST: HookHost = { kind: 'native' };
61
+ const RULE_TIMEOUT_MS = 250;
62
+ const PRIVATE_HOOK_FLAG = '--tripwire-hook';
63
+ const HookEventBatchSchema = Schema.Array(HookEventSchema).check(Schema.isMinLength(1));
64
+
65
+ const writeAllow = (host: HookHost = NATIVE_HOST): void => {
66
+ if (host.kind === 'cursor' && !host.post) {
67
+ process.stdout.write('{"continue":true,"permission":"allow"}\n');
68
+ return;
69
+ }
64
70
  process.stdout.write('{"continue": true}\n');
65
71
  };
66
72
 
67
- // Codex's PreToolUse hook rejects `hookSpecificOutput.additionalContext`
68
- // (openai/codex issue #19385). Detect Codex via its `turn_id` extension
69
- // And downgrade output accordingly. Claude Code accepts it, so we only
70
- // Narrow when we can confirm we're on Codex.
73
+ const cursorReason = (decision: Decision): string =>
74
+ `[tripwire:${decision.rule}] ${decision.message}`;
75
+
76
+ const writeCursorPreGate = (decision: Decision): void => {
77
+ const reason =
78
+ decision.kind === 'ask'
79
+ ? `${cursorReason(decision)} This unattended Cursor run cannot ask a human, so the action is denied.`
80
+ : cursorReason(decision);
81
+ process.stdout.write(
82
+ `${JSON.stringify({
83
+ continue: true,
84
+ permission: 'deny',
85
+ user_message: reason,
86
+ agent_message: reason,
87
+ })}\n`,
88
+ );
89
+ };
90
+
91
+ // Codex rejects `hookSpecificOutput.additionalContext`. Its `turn_id`
92
+ // extension selects the narrower output without changing Claude responses.
71
93
  const isCodex = (event: HookEvent): boolean => event.turn_id !== undefined;
72
94
 
73
95
  interface WarnOutput {
@@ -75,7 +97,17 @@ interface WarnOutput {
75
97
  additionalContext?: string;
76
98
  }
77
99
 
78
- const writeWarn = (event: HookEvent, decision: Decision): void => {
100
+ const writeWarn = (event: HookEvent, decision: Decision, host: HookHost): void => {
101
+ if (host.kind === 'cursor') {
102
+ process.stdout.write(
103
+ `${JSON.stringify({
104
+ continue: true,
105
+ permission: 'allow',
106
+ agent_message: cursorReason(decision),
107
+ })}\n`,
108
+ );
109
+ return;
110
+ }
79
111
  const eventName = event.hook_event_name;
80
112
  const reason = `[tripwire:${decision.rule}] ${decision.message}`;
81
113
  if (isCodex(event)) {
@@ -88,7 +120,15 @@ const writeWarn = (event: HookEvent, decision: Decision): void => {
88
120
  process.stdout.write(`${JSON.stringify({ continue: true, hookSpecificOutput })}\n`);
89
121
  };
90
122
 
91
- const writePreToolGate = (eventName: string, decision: Decision): void => {
123
+ const writePreToolGate = (
124
+ eventName: string,
125
+ decision: Decision,
126
+ host: HookHost = NATIVE_HOST,
127
+ ): void => {
128
+ if (host.kind === 'cursor') {
129
+ writeCursorPreGate(decision);
130
+ return;
131
+ }
92
132
  const out = {
93
133
  hookSpecificOutput: {
94
134
  hookEventName: eventName,
@@ -99,7 +139,12 @@ const writePreToolGate = (eventName: string, decision: Decision): void => {
99
139
  process.stdout.write(`${JSON.stringify(out)}\n`);
100
140
  };
101
141
 
102
- const writePostToolBlock = (decision: Decision): void => {
142
+ const writePostToolBlock = (decision: Decision, host: HookHost): void => {
143
+ if (host.kind === 'cursor') {
144
+ // The tool already ran. Cursor has no post-event rollback channel.
145
+ writeAllow(host);
146
+ return;
147
+ }
103
148
  const out = {
104
149
  continue: true,
105
150
  decision: 'block',
@@ -108,11 +153,8 @@ const writePostToolBlock = (decision: Decision): void => {
108
153
  process.stdout.write(`${JSON.stringify(out)}\n`);
109
154
  };
110
155
 
111
- // Tool names vary across hosts. Claude Code uses `Bash`/`Read`/`Write`/
112
- // `Edit`/`MultiEdit`. Codex sends `apply_patch` for file edits. Devin sends
113
- // `exec` for shell. Pi (via pi-hooks) sends lowercase `bash`/`read`/`write`/
114
- // `edit`. Normalize everything to the Claude vocabulary so the rest of the
115
- // Dispatcher only deals with one set of names.
156
+ // Tool names vary across hosts. Normalize Claude Code, Codex, Cursor, Pi, and
157
+ // Oh My Pi names to one canonical vocabulary before rules run.
116
158
  const normalizeToolName = (name: string): string => {
117
159
  const n = name.toLowerCase();
118
160
  if (n === 'bash' || n === 'exec' || n === 'shell' || n === 'run_command') {
@@ -130,15 +172,28 @@ const normalizeToolName = (name: string): string => {
130
172
  if (n === 'webfetch' || n === 'web_fetch' || n === 'fetch') {
131
173
  return 'WebFetch';
132
174
  }
175
+ if (n === 'powershell') {
176
+ return 'PowerShell';
177
+ }
133
178
  return name;
134
179
  };
135
180
 
136
181
  type RuleFn = () => Decision;
137
182
 
183
+ class RuleExecutionError extends Data.TaggedError('RuleExecutionError')<{
184
+ readonly cause: unknown;
185
+ }> {}
186
+
187
+ class HookInputParseError extends Data.TaggedError('HookInputParseError')<{
188
+ readonly cause: unknown;
189
+ }> {}
190
+
138
191
  const runRule = (name: string, fn: RuleFn, timeoutMs: number): Effect.Effect<Decision> =>
139
- Effect.gen(function* () {
192
+ Effect.gen(function* runRuleEffect() {
140
193
  const exit = yield* Effect.exit(
141
- Effect.try({ try: fn, catch: (e) => e }).pipe(Effect.timeout(timeoutMs)),
194
+ Effect.try({ try: fn, catch: (cause) => new RuleExecutionError({ cause }) }).pipe(
195
+ Effect.timeout(timeoutMs),
196
+ ),
142
197
  );
143
198
  if (Exit.isSuccess(exit)) {
144
199
  return exit.value;
@@ -152,36 +207,37 @@ interface Rule {
152
207
  readonly fn: RuleFn;
153
208
  }
154
209
 
155
- const collectPreToolUseRules = (tool: string, input: unknown, config: Config): Rule[] => {
210
+ const collectPreToolUseRules = (tool: string, input: unknown, config: ResolvedConfig): Rule[] => {
156
211
  const rules: Rule[] = [];
157
- if (tool === 'Bash' && isBashInput(input)) {
158
- const i: BashInput = input;
159
- const segments = parseCommand(i.command);
160
- rules.push({ name: 'bash-deny', fn: () => bashDeny(segments, i.command) });
161
- rules.push({
162
- name: 'bash-git',
163
- fn: () => bashGit(segments, i.command, config.git ?? { enforceConventionalCommits: true }),
164
- });
212
+ if (tool === 'PowerShell') {
165
213
  rules.push({
166
- name: 'bash-scoped-rm',
167
- fn: () => bashScopedRm(segments, i.command, config.safePaths ?? {}),
168
- });
169
- rules.push({ name: 'bash-redirect', fn: () => bashRedirect(segments, i.command) });
170
- rules.push({ name: 'bash-network-install', fn: () => bashNetworkInstall(segments, i.command) });
171
- rules.push({ name: 'bash-tar-explosion', fn: () => bashTarExplosion(segments, i.command) });
172
- rules.push({ name: 'bash-tool-policy', fn: () => bashToolPolicy(segments, i.command) });
173
- rules.push({
174
- name: 'config-custom',
214
+ name: 'powershell-unsupported',
175
215
  fn: () =>
176
- configCustom(
177
- segments,
178
- i.command,
179
- config.blockedCommands ?? [],
180
- config.allowedCommands ?? [],
216
+ deny(
217
+ 'powershell-unsupported',
218
+ 'PowerShell commands are blocked because Tripwire cannot parse PowerShell grammar. Use the Bash tool, or add a PowerShell parser before you enable this tool.',
181
219
  ),
182
220
  });
183
221
  return rules;
184
222
  }
223
+ if (tool === 'Bash' && isBashInput(input)) {
224
+ const i: BashInput = input;
225
+ const segments = parseCommand(i.command);
226
+ rules.push(
227
+ { name: 'bash-deny', fn: () => bashDeny(segments, i.command) },
228
+ { name: 'bash-git', fn: () => bashGit(segments, i.command, config.git) },
229
+ { name: 'bash-scoped-rm', fn: () => bashScopedRm(segments, i.command, config.safePaths) },
230
+ { name: 'bash-redirect', fn: () => bashRedirect(segments, i.command) },
231
+ { name: 'bash-network-install', fn: () => bashNetworkInstall(segments, i.command) },
232
+ { name: 'bash-tar-explosion', fn: () => bashTarExplosion(segments, i.command) },
233
+ { name: 'tool-policy', fn: () => toolPolicy(segments, i.command, config.toolPolicies) },
234
+ {
235
+ name: 'config-custom',
236
+ fn: () => configCustom(segments, i.command, config.blockedCommands, config.allowedCommands),
237
+ },
238
+ );
239
+ return rules;
240
+ }
185
241
  if (tool === 'Read' && isReadInput(input)) {
186
242
  const i: ReadInput = input;
187
243
  rules.push({ name: 'read-protect', fn: () => readProtect(i) });
@@ -191,25 +247,39 @@ const collectPreToolUseRules = (tool: string, input: unknown, config: Config): R
191
247
  const isWrite = tool === 'Write' && isWriteInput(input);
192
248
  if (isEdit) {
193
249
  const i: EditInput = input;
194
- rules.push({ name: 'path-protect', fn: () => pathProtect(i) });
195
- rules.push({ name: 'lazy-code', fn: () => lazyCode(i) });
250
+ rules.push(
251
+ { name: 'path-protect', fn: () => pathProtect(i) },
252
+ { name: 'lazy-code', fn: () => lazyCode(i) },
253
+ );
196
254
  } else if (isWrite) {
197
255
  const i: WriteInput = input;
198
- rules.push({ name: 'path-protect', fn: () => pathProtect(i) });
199
- rules.push({ name: 'lazy-code', fn: () => lazyCode(i) });
256
+ rules.push(
257
+ { name: 'path-protect', fn: () => pathProtect(i) },
258
+ { name: 'lazy-code', fn: () => lazyCode(i) },
259
+ );
200
260
  }
201
261
  return rules;
202
262
  };
203
263
 
204
- const collectPostToolUseRules = (tool: string, response: unknown): Rule[] => {
205
- if (tool === 'Bash' || tool === 'Read' || tool === 'WebFetch') {
206
- return [{ name: 'post-secret-scrub', fn: () => postSecretScrub({ toolName: tool, response }) }];
264
+ const collectPostToolUseRules = (
265
+ tool: string,
266
+ response: unknown,
267
+ secretScanner: SecretScannerConfig,
268
+ ): Rule[] => {
269
+ if (tool === 'Bash' || tool === 'PowerShell' || tool === 'Read' || tool === 'WebFetch') {
270
+ const scanTool = tool === 'PowerShell' ? 'Bash' : tool;
271
+ return [
272
+ {
273
+ name: 'post-secret-scrub',
274
+ fn: () => postSecretScrub({ toolName: scanTool, response, secretScanner }),
275
+ },
276
+ ];
207
277
  }
208
278
  return [];
209
279
  };
210
280
 
211
281
  const runRules = (rules: readonly Rule[], timeoutMs: number): Effect.Effect<Decision> =>
212
- Effect.gen(function* () {
282
+ Effect.gen(function* runRulesEffect() {
213
283
  if (rules.length === 0) {
214
284
  return allow('no-rules');
215
285
  }
@@ -224,41 +294,48 @@ const runRulesSync = (rules: readonly Rule[]): Decision => {
224
294
  if (rules.length === 0) {
225
295
  return allow('no-rules');
226
296
  }
227
- return merge(rules.map((r) => r.fn()));
297
+ const decisions: Decision[] = [];
298
+ for (const rule of rules) {
299
+ try {
300
+ decisions.push(rule.fn());
301
+ } catch (cause) {
302
+ logError(rule.name, cause);
303
+ decisions.push(allow(rule.name));
304
+ }
305
+ }
306
+ return merge(decisions);
228
307
  };
229
308
 
230
- const decide = (event: HookEvent, config: Config = getDefaultConfig()): Decision => {
309
+ const decide = (event: HookEvent, config: Config = {}): Decision => {
231
310
  const mergedConfig = mergeWithDefaults(config);
232
311
  const tool = normalizeToolName(event.tool_name ?? '');
233
312
  if (event.hook_event_name === 'PreToolUse') {
234
313
  return runRulesSync(collectPreToolUseRules(tool, event.tool_input, mergedConfig));
235
314
  }
236
315
  if (event.hook_event_name === 'PostToolUse') {
237
- return runRulesSync(collectPostToolUseRules(tool, event.tool_response));
316
+ return runRulesSync(
317
+ collectPostToolUseRules(tool, event.tool_response, mergedConfig.secretScanner),
318
+ );
238
319
  }
239
320
  return allow('no-rules');
240
321
  };
241
322
 
242
- const handleBashAllow = (event: HookEvent, decision: Decision, _config: Config): void => {
243
- if (decision.kind === 'warn') {
244
- writeWarn(event, decision);
245
- return;
246
- }
247
- writeAllow();
248
- };
249
-
250
- const handleAllow = (event: HookEvent, decision: Decision, config: Config): void => {
323
+ const handleAllow = (event: HookEvent, decision: Decision, host: HookHost): void => {
251
324
  const eventName = event.hook_event_name;
252
325
  const tool = normalizeToolName(event.tool_name ?? '');
253
326
  if (eventName === 'PreToolUse' && tool === 'Bash') {
254
- handleBashAllow(event, decision, config);
327
+ if (decision.kind === 'warn') {
328
+ writeWarn(event, decision, host);
329
+ return;
330
+ }
331
+ writeAllow(host);
255
332
  return;
256
333
  }
257
334
  if (decision.kind === 'warn') {
258
- writeWarn(event, decision);
335
+ writeWarn(event, decision, host);
259
336
  return;
260
337
  }
261
- writeAllow();
338
+ writeAllow(host);
262
339
  };
263
340
 
264
341
  // A broken config (bad JSON / unknown field / decode failure) silently dropping
@@ -268,80 +345,210 @@ const handleAllow = (event: HookEvent, decision: Decision, config: Config): void
268
345
  const configErrorMessage = (error: string): string =>
269
346
  `tripwire config at ${CONFIG_PATH} failed to load, so ALL custom safety policy is ` +
270
347
  `inactive. Failing closed until it is fixed. Fix the JSON, then this clears on the next ` +
271
- `call (the shim daemon caches config at warm restart it there). Error: ${error}`;
348
+ `hook call. Restart any long-running consumer that caches the loaded config. Error: ${error}`;
349
+
350
+ const cursorEventNameFromArgs = (): string | undefined => {
351
+ const index = process.argv.indexOf('--cursor-event');
352
+ if (index !== -1) {
353
+ return process.argv[index + 1] ?? '';
354
+ }
355
+ const value = process.argv.find((arg) => arg.startsWith('--cursor-event='));
356
+ return value?.slice('--cursor-event='.length);
357
+ };
272
358
 
273
- const program = Effect.gen(function* () {
359
+ const isPrivateHookInvocation = (): boolean => process.argv.includes(PRIVATE_HOOK_FLAG);
360
+
361
+ const cursorHostFromArgs = (): HookHost => {
362
+ const eventName = cursorEventNameFromArgs();
363
+ return eventName === undefined ? NATIVE_HOST : cursorHost(eventName);
364
+ };
365
+
366
+ const writeHookFailure = (stage: string, batch = false): void => {
367
+ const host = cursorHostFromArgs();
368
+ if (batch) {
369
+ writePreToolGate(
370
+ 'PreToolUse',
371
+ deny(
372
+ 'tripwire-batch-error',
373
+ `Tripwire batch input could not be processed (${stage}). Failing closed.`,
374
+ ),
375
+ host,
376
+ );
377
+ return;
378
+ }
379
+ if (host.kind === 'cursor' && !host.post) {
380
+ writeCursorPreGate(
381
+ deny(
382
+ 'cursor-hook-error',
383
+ `Cursor hook input could not be processed (${stage}). Failing closed.`,
384
+ ),
385
+ );
386
+ return;
387
+ }
388
+ writeAllow(host);
389
+ };
390
+
391
+ const hookHostKey = (event: HookEvent, host: HookHost): string => {
392
+ if (host.kind === 'cursor') {
393
+ return `cursor:${host.eventName}`;
394
+ }
395
+ return isCodex(event) ? 'codex' : 'native';
396
+ };
397
+
398
+ const mergedDecisions = (decisions: readonly Decision[]): Decision =>
399
+ decisions.length === 1 ? (decisions[0] ?? allow('no-rules')) : merge(decisions);
400
+
401
+ type PreparedHookInput =
402
+ | {
403
+ readonly ok: true;
404
+ readonly events: readonly HookEvent[];
405
+ readonly firstEvent: HookEvent;
406
+ readonly host: HookHost;
407
+ readonly phase: string;
408
+ }
409
+ | { readonly ok: false };
410
+
411
+ const prepareHookInput = (input: unknown): Effect.Effect<PreparedHookInput> =>
412
+ Effect.gen(function* prepareHookInputEffect() {
413
+ const batchInput = Array.isArray(input);
414
+ if (batchInput && !isPrivateHookInvocation()) {
415
+ logError('decode', 'Batch hook input is only available through --tripwire-hook');
416
+ writeHookFailure('unsupported batch input');
417
+ return { ok: false };
418
+ }
419
+
420
+ let events: readonly HookEvent[];
421
+ let host: HookHost;
422
+ if (batchInput) {
423
+ const decodeExit = yield* Effect.exit(Schema.decodeEffect(HookEventBatchSchema)(input));
424
+ if (Exit.isFailure(decodeExit)) {
425
+ logError('decode', Cause.pretty(decodeExit.cause));
426
+ writeHookFailure('unsupported batch event shape', true);
427
+ return { ok: false };
428
+ }
429
+ events = decodeExit.value;
430
+ host = cursorHostFromArgs();
431
+ } else {
432
+ const normalized = normalizeHookInput(input, cursorEventNameFromArgs());
433
+ const decodeExit = yield* Effect.exit(
434
+ Schema.decodeUnknownEffect(HookEventSchema)(normalized.event),
435
+ );
436
+ if (Exit.isFailure(decodeExit)) {
437
+ logError('decode', Cause.pretty(decodeExit.cause));
438
+ writeHookFailure('unsupported event shape');
439
+ return { ok: false };
440
+ }
441
+ events = [decodeExit.value];
442
+ ({ host } = normalized);
443
+ }
444
+
445
+ const [firstEvent] = events;
446
+ if (firstEvent === undefined) {
447
+ writeHookFailure('empty batch', true);
448
+ return { ok: false };
449
+ }
450
+ const phase = firstEvent.hook_event_name;
451
+ const hostKey = hookHostKey(firstEvent, host);
452
+ if (events.some((event) => event.hook_event_name !== phase)) {
453
+ logError('decode', 'Batch hook input mixes event phases');
454
+ writeHookFailure('mixed event phases', true);
455
+ return { ok: false };
456
+ }
457
+ if (events.some((event) => hookHostKey(event, host) !== hostKey)) {
458
+ logError('decode', 'Batch hook input mixes hosts');
459
+ writeHookFailure('mixed hosts', true);
460
+ return { ok: false };
461
+ }
462
+ return { ok: true, events, firstEvent, host, phase };
463
+ });
464
+
465
+ const program = Effect.gen(function* tripwireProgram() {
274
466
  const configLoad = yield* loadConfigResult();
275
467
  const raw = yield* Effect.promise(readStdin);
276
468
 
277
469
  const parseExit = yield* Effect.exit(
278
- Effect.try({ try: () => JSON.parse(raw) as unknown, catch: (e) => e }),
470
+ Effect.try({
471
+ try: () => JSON.parse(raw) as unknown,
472
+ catch: (cause) => new HookInputParseError({ cause }),
473
+ }),
279
474
  );
280
475
  if (Exit.isFailure(parseExit)) {
281
476
  logError('parse', Cause.pretty(parseExit.cause));
282
- writeAllow();
477
+ writeHookFailure('invalid JSON');
283
478
  return;
284
479
  }
285
480
 
286
- const decodeExit = yield* Effect.exit(
287
- Schema.decodeUnknownEffect(HookEventSchema)(parseExit.value),
288
- );
289
- if (Exit.isFailure(decodeExit)) {
290
- logError('decode', Cause.pretty(decodeExit.cause));
291
- writeAllow();
481
+ const prepared = yield* prepareHookInput(parseExit.value);
482
+ if (!prepared.ok) {
292
483
  return;
293
484
  }
294
- const event = decodeExit.value;
485
+ const { events, firstEvent, host, phase } = prepared;
295
486
 
296
- if (!configLoad.ok) {
297
- if (event.hook_event_name === 'PreToolUse') {
298
- writePreToolGate(
299
- event.hook_event_name,
300
- deny('config-error', configErrorMessage(configLoad.error)),
301
- );
487
+ if (phase === 'PreToolUse') {
488
+ if (!configLoad.ok) {
489
+ writePreToolGate(phase, deny('config-error', configErrorMessage(configLoad.error)), host);
302
490
  return;
303
491
  }
304
- // Config governs PreToolUse gating; PostToolUse secret-scrub is config-
305
- // Independent and there is always an imminent next PreToolUse to surface the
306
- // Deny, so don't block already-run output here.
307
- writeAllow();
308
- return;
309
- }
310
- const config = configLoad.config;
311
-
312
- if (event.hook_event_name === 'PreToolUse') {
313
- const decision = decide(event, config);
492
+ const decisions: Decision[] = [];
493
+ for (const event of events) {
494
+ const tool = normalizeToolName(event.tool_name ?? '');
495
+ decisions.push(
496
+ yield* runRules(
497
+ collectPreToolUseRules(tool, event.tool_input, configLoad.config),
498
+ RULE_TIMEOUT_MS,
499
+ ),
500
+ );
501
+ }
502
+ const decision = mergedDecisions(decisions);
314
503
  if (decision.kind === 'deny' || decision.kind === 'ask') {
315
- writePreToolGate(event.hook_event_name, decision);
504
+ writePreToolGate(phase, decision, host);
316
505
  return;
317
506
  }
318
- handleAllow(event, decision, config);
507
+ handleAllow(firstEvent, decision, host);
319
508
  return;
320
509
  }
321
510
 
322
- if (event.hook_event_name === 'PostToolUse') {
323
- const decision = decide(event, config);
511
+ if (phase === 'PostToolUse') {
512
+ const secretScanner = configLoad.ok
513
+ ? configLoad.config.secretScanner
514
+ : getDefaultConfig().secretScanner;
515
+ const decisions: Decision[] = [];
516
+ for (const event of events) {
517
+ const tool = normalizeToolName(event.tool_name ?? '');
518
+ decisions.push(
519
+ yield* runRules(
520
+ collectPostToolUseRules(tool, event.tool_response, secretScanner),
521
+ RULE_TIMEOUT_MS,
522
+ ),
523
+ );
524
+ }
525
+ const decision = mergedDecisions(decisions);
324
526
  if (decision.kind === 'deny') {
325
- writePostToolBlock(decision);
527
+ writePostToolBlock(decision, host);
326
528
  return;
327
529
  }
328
- writeAllow();
530
+ writeAllow(host);
329
531
  return;
330
532
  }
331
533
 
332
- writeAllow();
534
+ writeAllow(host);
333
535
  });
334
536
 
335
537
  const handled = program.pipe(
538
+ // oxlint-disable-next-line de-clank-effect/no-silent-effect-error-swallow -- fatal causes are logged before the host receives its fail-policy response.
336
539
  Effect.catchCause((cause) => {
337
540
  logError('dispatch-fatal', Cause.pretty(cause));
338
- writeAllow();
541
+ writeHookFailure('dispatcher failure');
339
542
  return Effect.void;
340
543
  }),
341
544
  );
342
545
 
343
- if (import.meta.main) {
546
+ const runHook = (): void => {
344
547
  BunRuntime.runMain(handled);
548
+ };
549
+
550
+ if (import.meta.main) {
551
+ runHook();
345
552
  }
346
553
 
347
554
  export {
@@ -349,6 +556,7 @@ export {
349
556
  collectPreToolUseRules,
350
557
  decide,
351
558
  normalizeToolName,
559
+ runHook,
352
560
  runRules,
353
561
  runRulesSync,
354
562
  };
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type { Decision } from './lib/decision.ts';
2
2
  export type { HookEvent } from './lib/event.ts';
3
- export type { Config, ConfigLoad } from './lib/config.ts';
3
+ export type { Config, ConfigLoad, ResolvedConfig } from './lib/config.ts';
4
4
  export { allow, deny, ask, warn } from './lib/decision.ts';
5
5
  export { decide } from './dispatch.ts';
6
6
  export { getDefaultConfig, loadConfig, loadConfigResult, mergeWithDefaults } from './lib/config.ts';