@seanmozeik/tripwire 0.6.6 → 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/LICENSE +21 -0
- package/README.md +152 -141
- package/dist/tripwire +0 -0
- package/dist/tripwire-pi.js +4 -0
- package/package.json +53 -19
- package/scripts/tripwire-cli +12 -0
- package/src/cli.ts +64 -57
- package/src/dispatch.ts +322 -114
- package/src/index.ts +1 -1
- package/src/lib/bash.ts +106 -62
- package/src/lib/config.ts +93 -46
- package/src/lib/cursor.ts +336 -0
- package/src/lib/diff.ts +5 -2
- package/src/lib/event.ts +20 -21
- package/src/lib/install.ts +508 -136
- package/src/lib/log.ts +2 -3
- package/src/lib/secrets.ts +151 -88
- package/src/main.ts +31 -0
- package/src/pi-extension.ts +337 -0
- package/src/rules/bash-deny.ts +13 -3
- package/src/rules/bash-git.ts +32 -45
- package/src/rules/bash-network-install.ts +6 -3
- package/src/rules/bash-redirect.ts +5 -5
- package/src/rules/bash-scoped-rm.ts +1 -1
- package/src/rules/bash-tar-explosion.ts +16 -15
- package/src/rules/config-custom.ts +21 -15
- package/src/rules/lazy-code.ts +1 -1
- package/src/rules/path-protect.ts +73 -10
- package/src/rules/post-secret-scrub.ts +17 -6
- package/src/rules/read-protect.ts +5 -15
- package/src/rules/tool-policy.ts +54 -0
- package/dist/tripwire-cli.js +0 -11
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +0 -96
- package/dist/tripwire.js.jsc +0 -0
- package/src/rules/bash-tool-policy.ts +0 -146
package/src/dispatch.ts
CHANGED
|
@@ -1,21 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 = (
|
|
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
|
|
112
|
-
//
|
|
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: (
|
|
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:
|
|
210
|
+
const collectPreToolUseRules = (tool: string, input: unknown, config: ResolvedConfig): Rule[] => {
|
|
156
211
|
const rules: Rule[] = [];
|
|
157
|
-
if (tool === '
|
|
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: '
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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(
|
|
195
|
-
|
|
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(
|
|
199
|
-
|
|
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 = (
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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({
|
|
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
|
-
|
|
477
|
+
writeHookFailure('invalid JSON');
|
|
283
478
|
return;
|
|
284
479
|
}
|
|
285
480
|
|
|
286
|
-
const
|
|
287
|
-
|
|
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
|
|
485
|
+
const { events, firstEvent, host, phase } = prepared;
|
|
295
486
|
|
|
296
|
-
if (
|
|
297
|
-
if (
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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(
|
|
504
|
+
writePreToolGate(phase, decision, host);
|
|
316
505
|
return;
|
|
317
506
|
}
|
|
318
|
-
handleAllow(
|
|
507
|
+
handleAllow(firstEvent, decision, host);
|
|
319
508
|
return;
|
|
320
509
|
}
|
|
321
510
|
|
|
322
|
-
if (
|
|
323
|
-
const
|
|
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
|
-
|
|
541
|
+
writeHookFailure('dispatcher failure');
|
|
339
542
|
return Effect.void;
|
|
340
543
|
}),
|
|
341
544
|
);
|
|
342
545
|
|
|
343
|
-
|
|
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';
|