@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/dispatch.ts
DELETED
|
@@ -1,562 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
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.
|
|
5
|
-
|
|
6
|
-
import { BunRuntime } from '@effect/platform-bun';
|
|
7
|
-
import { Cause, Data, Effect, Exit, Schema } from 'effect';
|
|
8
|
-
|
|
9
|
-
import { parseCommand } from './lib/bash';
|
|
10
|
-
import {
|
|
11
|
-
CONFIG_PATH,
|
|
12
|
-
getDefaultConfig,
|
|
13
|
-
loadConfigResult,
|
|
14
|
-
mergeWithDefaults,
|
|
15
|
-
type Config,
|
|
16
|
-
type ResolvedConfig,
|
|
17
|
-
type SecretScannerConfig,
|
|
18
|
-
} from './lib/config';
|
|
19
|
-
import { cursorHost, normalizeHookInput, type HookHost } from './lib/cursor';
|
|
20
|
-
import { type Decision, allow, deny, merge } from './lib/decision';
|
|
21
|
-
import {
|
|
22
|
-
type BashInput,
|
|
23
|
-
type EditInput,
|
|
24
|
-
type HookEvent,
|
|
25
|
-
HookEventSchema,
|
|
26
|
-
type ReadInput,
|
|
27
|
-
type WriteInput,
|
|
28
|
-
isBashInput,
|
|
29
|
-
isEditInput,
|
|
30
|
-
isReadInput,
|
|
31
|
-
isWriteInput,
|
|
32
|
-
} from './lib/event.ts';
|
|
33
|
-
import { logError } from './lib/log';
|
|
34
|
-
import { bashDeny } from './rules/bash-deny';
|
|
35
|
-
import { bashGit } from './rules/bash-git';
|
|
36
|
-
import { bashNetworkInstall } from './rules/bash-network-install';
|
|
37
|
-
import { bashRedirect } from './rules/bash-redirect';
|
|
38
|
-
import { bashScopedRm } from './rules/bash-scoped-rm';
|
|
39
|
-
import { bashTarExplosion } from './rules/bash-tar-explosion';
|
|
40
|
-
import { configCustom } from './rules/config-custom';
|
|
41
|
-
import { lazyCode } from './rules/lazy-code';
|
|
42
|
-
import { pathProtect } from './rules/path-protect';
|
|
43
|
-
import { postSecretScrub } from './rules/post-secret-scrub';
|
|
44
|
-
import { readProtect } from './rules/read-protect';
|
|
45
|
-
import { toolPolicy } from './rules/tool-policy';
|
|
46
|
-
|
|
47
|
-
const readStdin = async (): Promise<string> => {
|
|
48
|
-
const chunks: Buffer[] = [];
|
|
49
|
-
for await (const chunk of process.stdin) {
|
|
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
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
58
|
-
};
|
|
59
|
-
|
|
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
|
-
}
|
|
70
|
-
process.stdout.write('{"continue": true}\n');
|
|
71
|
-
};
|
|
72
|
-
|
|
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.
|
|
93
|
-
const isCodex = (event: HookEvent): boolean => event.turn_id !== undefined;
|
|
94
|
-
|
|
95
|
-
interface WarnOutput {
|
|
96
|
-
hookEventName: string;
|
|
97
|
-
additionalContext?: string;
|
|
98
|
-
}
|
|
99
|
-
|
|
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
|
-
}
|
|
111
|
-
const eventName = event.hook_event_name;
|
|
112
|
-
const reason = `[tripwire:${decision.rule}] ${decision.message}`;
|
|
113
|
-
if (isCodex(event)) {
|
|
114
|
-
// Codex rejects `additionalContext` on PreToolUse. Send only
|
|
115
|
-
// `systemMessage`.
|
|
116
|
-
process.stdout.write(`${JSON.stringify({ continue: true, systemMessage: reason })}\n`);
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
const hookSpecificOutput: WarnOutput = { hookEventName: eventName, additionalContext: reason };
|
|
120
|
-
process.stdout.write(`${JSON.stringify({ continue: true, hookSpecificOutput })}\n`);
|
|
121
|
-
};
|
|
122
|
-
|
|
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
|
-
}
|
|
132
|
-
const out = {
|
|
133
|
-
hookSpecificOutput: {
|
|
134
|
-
hookEventName: eventName,
|
|
135
|
-
permissionDecision: decision.kind === 'deny' ? 'deny' : 'ask',
|
|
136
|
-
permissionDecisionReason: `[tripwire:${decision.rule}] ${decision.message}`,
|
|
137
|
-
},
|
|
138
|
-
};
|
|
139
|
-
process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
140
|
-
};
|
|
141
|
-
|
|
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
|
-
}
|
|
148
|
-
const out = {
|
|
149
|
-
continue: true,
|
|
150
|
-
decision: 'block',
|
|
151
|
-
reason: `[tripwire:${decision.rule}] ${decision.message}`,
|
|
152
|
-
};
|
|
153
|
-
process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
154
|
-
};
|
|
155
|
-
|
|
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.
|
|
158
|
-
const normalizeToolName = (name: string): string => {
|
|
159
|
-
const n = name.toLowerCase();
|
|
160
|
-
if (n === 'bash' || n === 'exec' || n === 'shell' || n === 'run_command') {
|
|
161
|
-
return 'Bash';
|
|
162
|
-
}
|
|
163
|
-
if (n === 'read' || n === 'read_file') {
|
|
164
|
-
return 'Read';
|
|
165
|
-
}
|
|
166
|
-
if (n === 'write' || n === 'write_file') {
|
|
167
|
-
return 'Write';
|
|
168
|
-
}
|
|
169
|
-
if (n === 'edit' || n === 'edit_file' || n === 'multiedit' || n === 'apply_patch') {
|
|
170
|
-
return 'Edit';
|
|
171
|
-
}
|
|
172
|
-
if (n === 'webfetch' || n === 'web_fetch' || n === 'fetch') {
|
|
173
|
-
return 'WebFetch';
|
|
174
|
-
}
|
|
175
|
-
if (n === 'powershell') {
|
|
176
|
-
return 'PowerShell';
|
|
177
|
-
}
|
|
178
|
-
return name;
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
type RuleFn = () => Decision;
|
|
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
|
-
|
|
191
|
-
const runRule = (name: string, fn: RuleFn, timeoutMs: number): Effect.Effect<Decision> =>
|
|
192
|
-
Effect.gen(function* runRuleEffect() {
|
|
193
|
-
const exit = yield* Effect.exit(
|
|
194
|
-
Effect.try({ try: fn, catch: (cause) => new RuleExecutionError({ cause }) }).pipe(
|
|
195
|
-
Effect.timeout(timeoutMs),
|
|
196
|
-
),
|
|
197
|
-
);
|
|
198
|
-
if (Exit.isSuccess(exit)) {
|
|
199
|
-
return exit.value;
|
|
200
|
-
}
|
|
201
|
-
logError(name, Cause.pretty(exit.cause));
|
|
202
|
-
return allow(name);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
interface Rule {
|
|
206
|
-
readonly name: string;
|
|
207
|
-
readonly fn: RuleFn;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const collectPreToolUseRules = (tool: string, input: unknown, config: ResolvedConfig): Rule[] => {
|
|
211
|
-
const rules: Rule[] = [];
|
|
212
|
-
if (tool === 'PowerShell') {
|
|
213
|
-
rules.push({
|
|
214
|
-
name: 'powershell-unsupported',
|
|
215
|
-
fn: () =>
|
|
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.',
|
|
219
|
-
),
|
|
220
|
-
});
|
|
221
|
-
return rules;
|
|
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
|
-
}
|
|
241
|
-
if (tool === 'Read' && isReadInput(input)) {
|
|
242
|
-
const i: ReadInput = input;
|
|
243
|
-
rules.push({ name: 'read-protect', fn: () => readProtect(i) });
|
|
244
|
-
return rules;
|
|
245
|
-
}
|
|
246
|
-
const isEdit = (tool === 'Edit' || tool === 'MultiEdit') && isEditInput(input);
|
|
247
|
-
const isWrite = tool === 'Write' && isWriteInput(input);
|
|
248
|
-
if (isEdit) {
|
|
249
|
-
const i: EditInput = input;
|
|
250
|
-
rules.push(
|
|
251
|
-
{ name: 'path-protect', fn: () => pathProtect(i) },
|
|
252
|
-
{ name: 'lazy-code', fn: () => lazyCode(i) },
|
|
253
|
-
);
|
|
254
|
-
} else if (isWrite) {
|
|
255
|
-
const i: WriteInput = input;
|
|
256
|
-
rules.push(
|
|
257
|
-
{ name: 'path-protect', fn: () => pathProtect(i) },
|
|
258
|
-
{ name: 'lazy-code', fn: () => lazyCode(i) },
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
return rules;
|
|
262
|
-
};
|
|
263
|
-
|
|
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
|
-
];
|
|
277
|
-
}
|
|
278
|
-
return [];
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
const runRules = (rules: readonly Rule[], timeoutMs: number): Effect.Effect<Decision> =>
|
|
282
|
-
Effect.gen(function* runRulesEffect() {
|
|
283
|
-
if (rules.length === 0) {
|
|
284
|
-
return allow('no-rules');
|
|
285
|
-
}
|
|
286
|
-
const decisions: Decision[] = [];
|
|
287
|
-
for (const r of rules) {
|
|
288
|
-
decisions.push(yield* runRule(r.name, r.fn, timeoutMs));
|
|
289
|
-
}
|
|
290
|
-
return merge(decisions);
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
const runRulesSync = (rules: readonly Rule[]): Decision => {
|
|
294
|
-
if (rules.length === 0) {
|
|
295
|
-
return allow('no-rules');
|
|
296
|
-
}
|
|
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);
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
const decide = (event: HookEvent, config: Config = {}): Decision => {
|
|
310
|
-
const mergedConfig = mergeWithDefaults(config);
|
|
311
|
-
const tool = normalizeToolName(event.tool_name ?? '');
|
|
312
|
-
if (event.hook_event_name === 'PreToolUse') {
|
|
313
|
-
return runRulesSync(collectPreToolUseRules(tool, event.tool_input, mergedConfig));
|
|
314
|
-
}
|
|
315
|
-
if (event.hook_event_name === 'PostToolUse') {
|
|
316
|
-
return runRulesSync(
|
|
317
|
-
collectPostToolUseRules(tool, event.tool_response, mergedConfig.secretScanner),
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
return allow('no-rules');
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
const handleAllow = (event: HookEvent, decision: Decision, host: HookHost): void => {
|
|
324
|
-
const eventName = event.hook_event_name;
|
|
325
|
-
const tool = normalizeToolName(event.tool_name ?? '');
|
|
326
|
-
if (eventName === 'PreToolUse' && tool === 'Bash') {
|
|
327
|
-
if (decision.kind === 'warn') {
|
|
328
|
-
writeWarn(event, decision, host);
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
writeAllow(host);
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
if (decision.kind === 'warn') {
|
|
335
|
-
writeWarn(event, decision, host);
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
writeAllow(host);
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
// A broken config (bad JSON / unknown field / decode failure) silently dropping
|
|
342
|
-
// All custom policy is the dangerous case this guards. Fail closed: deny the
|
|
343
|
-
// Pending PreToolUse call with the decode error inline so the agent halts and
|
|
344
|
-
// The user sees it, rather than running on bare defaults unannounced.
|
|
345
|
-
const configErrorMessage = (error: string): string =>
|
|
346
|
-
`tripwire config at ${CONFIG_PATH} failed to load, so ALL custom safety policy is ` +
|
|
347
|
-
`inactive. Failing closed until it is fixed. Fix the JSON, then this clears on the next ` +
|
|
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
|
-
};
|
|
358
|
-
|
|
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() {
|
|
466
|
-
const configLoad = yield* loadConfigResult();
|
|
467
|
-
const raw = yield* Effect.promise(readStdin);
|
|
468
|
-
|
|
469
|
-
const parseExit = yield* Effect.exit(
|
|
470
|
-
Effect.try({
|
|
471
|
-
try: () => JSON.parse(raw) as unknown,
|
|
472
|
-
catch: (cause) => new HookInputParseError({ cause }),
|
|
473
|
-
}),
|
|
474
|
-
);
|
|
475
|
-
if (Exit.isFailure(parseExit)) {
|
|
476
|
-
logError('parse', Cause.pretty(parseExit.cause));
|
|
477
|
-
writeHookFailure('invalid JSON');
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const prepared = yield* prepareHookInput(parseExit.value);
|
|
482
|
-
if (!prepared.ok) {
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
const { events, firstEvent, host, phase } = prepared;
|
|
486
|
-
|
|
487
|
-
if (phase === 'PreToolUse') {
|
|
488
|
-
if (!configLoad.ok) {
|
|
489
|
-
writePreToolGate(phase, deny('config-error', configErrorMessage(configLoad.error)), host);
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
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);
|
|
503
|
-
if (decision.kind === 'deny' || decision.kind === 'ask') {
|
|
504
|
-
writePreToolGate(phase, decision, host);
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
handleAllow(firstEvent, decision, host);
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
|
|
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);
|
|
526
|
-
if (decision.kind === 'deny') {
|
|
527
|
-
writePostToolBlock(decision, host);
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
writeAllow(host);
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
writeAllow(host);
|
|
535
|
-
});
|
|
536
|
-
|
|
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.
|
|
539
|
-
Effect.catchCause((cause) => {
|
|
540
|
-
logError('dispatch-fatal', Cause.pretty(cause));
|
|
541
|
-
writeHookFailure('dispatcher failure');
|
|
542
|
-
return Effect.void;
|
|
543
|
-
}),
|
|
544
|
-
);
|
|
545
|
-
|
|
546
|
-
const runHook = (): void => {
|
|
547
|
-
BunRuntime.runMain(handled);
|
|
548
|
-
};
|
|
549
|
-
|
|
550
|
-
if (import.meta.main) {
|
|
551
|
-
runHook();
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
export {
|
|
555
|
-
collectPostToolUseRules,
|
|
556
|
-
collectPreToolUseRules,
|
|
557
|
-
decide,
|
|
558
|
-
normalizeToolName,
|
|
559
|
-
runHook,
|
|
560
|
-
runRules,
|
|
561
|
-
runRulesSync,
|
|
562
|
-
};
|
package/src/index.ts
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
export type { Decision } from './lib/decision.ts';
|
|
2
|
-
export type { HookEvent } from './lib/event.ts';
|
|
3
|
-
export type { Config, ConfigLoad, ResolvedConfig } from './lib/config.ts';
|
|
4
|
-
export { allow, deny, ask, warn } from './lib/decision.ts';
|
|
5
|
-
export { decide } from './dispatch.ts';
|
|
6
|
-
export { getDefaultConfig, loadConfig, loadConfigResult, mergeWithDefaults } from './lib/config.ts';
|