@seanmozeik/tripwire 0.6.7 → 0.7.1
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 +154 -141
- package/dist/index.js +35 -0
- package/dist/tripwire-cli.js +2 -10
- package/dist/tripwire-hook.js +3 -0
- package/dist/tripwire-pi.js +4 -0
- package/dist/tripwire.js +135 -90
- 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 +53 -22
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js.jsc +0 -0
- package/src/cli.ts +0 -264
- package/src/dispatch.ts +0 -354
- package/src/index.ts +0 -6
- package/src/lib/bash.ts +0 -1284
- package/src/lib/config.ts +0 -127
- package/src/lib/decision.ts +0 -36
- package/src/lib/diff.ts +0 -26
- package/src/lib/event.ts +0 -106
- package/src/lib/install.ts +0 -238
- package/src/lib/log.ts +0 -24
- package/src/lib/secrets.ts +0 -121
- package/src/rules/bash-deny.ts +0 -394
- package/src/rules/bash-git.ts +0 -603
- package/src/rules/bash-network-install.ts +0 -72
- 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 -76
- package/src/rules/bash-tool-policy.ts +0 -146
- package/src/rules/config-custom.ts +0 -160
- package/src/rules/lazy-code.ts +0 -95
- package/src/rules/path-protect.ts +0 -68
- package/src/rules/post-secret-scrub.ts +0 -38
- package/src/rules/read-protect.ts +0 -67
package/src/cli.ts
DELETED
|
@@ -1,264 +0,0 @@
|
|
|
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
|
-
// `tripwire install <target>` — install tripwire hooks for AI agents.
|
|
7
|
-
//
|
|
8
|
-
// Usage:
|
|
9
|
-
// Bun src/cli.ts test 'rm -rf /etc'
|
|
10
|
-
// Bun src/cli.ts test --tool=Read --path=.env
|
|
11
|
-
// Bun src/cli.ts test --post --tool=Bash --stdout='ghp_<token>'
|
|
12
|
-
// Bun src/cli.ts install claude
|
|
13
|
-
// Bun src/cli.ts install codex
|
|
14
|
-
// Bun src/cli.ts install pi
|
|
15
|
-
// Bun src/cli.ts install all
|
|
16
|
-
|
|
17
|
-
// oxlint-disable-next-line unicorn/import-style
|
|
18
|
-
import { dirname } from 'node:path';
|
|
19
|
-
|
|
20
|
-
import { BunServices } from '@effect/platform-bun';
|
|
21
|
-
import { file } from 'bun';
|
|
22
|
-
import { Effect, Option } from 'effect';
|
|
23
|
-
import { Argument, Command, Flag } from 'effect/unstable/cli';
|
|
24
|
-
|
|
25
|
-
import pkg from '../package.json' with { type: 'json' };
|
|
26
|
-
import { installAll, installClaude, installCodex, installPi } from './lib/install';
|
|
27
|
-
|
|
28
|
-
// Resolve tripwire-hook path at runtime using process.argv
|
|
29
|
-
// This works in both script mode (bun run) and compiled/bundled mode
|
|
30
|
-
const runtimeSelf = (): string => {
|
|
31
|
-
const isBunCli = /\/bun(?<ext>\.exe)?$/.test(process.argv[0] ?? '');
|
|
32
|
-
return isBunCli ? process.argv[1]! : process.argv[0]!;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const dispatchBin = async (): Promise<string> => {
|
|
36
|
-
const cliPath = runtimeSelf();
|
|
37
|
-
const cliDir = dirname(cliPath);
|
|
38
|
-
// Try tripwire-hook in same directory first (installed scenario)
|
|
39
|
-
const installedPath = `${cliDir}/tripwire-hook`;
|
|
40
|
-
try {
|
|
41
|
-
await file(installedPath).text();
|
|
42
|
-
return installedPath;
|
|
43
|
-
} catch {
|
|
44
|
-
// Fallback to development scenario: tripwire-hook in ../dist relative to CLI
|
|
45
|
-
return `${cliDir}/tripwire.js`;
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
interface BuiltEvent {
|
|
50
|
-
hook_event_name: string;
|
|
51
|
-
tool_name: string;
|
|
52
|
-
cwd: string;
|
|
53
|
-
session_id: string;
|
|
54
|
-
tool_input?: unknown;
|
|
55
|
-
tool_response?: unknown;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const buildToolInput = (
|
|
59
|
-
tool: string,
|
|
60
|
-
command: string | undefined,
|
|
61
|
-
path: string | undefined,
|
|
62
|
-
content: string | undefined,
|
|
63
|
-
): unknown => {
|
|
64
|
-
if (tool === 'Bash') {
|
|
65
|
-
return { command: command ?? '' };
|
|
66
|
-
}
|
|
67
|
-
if (tool === 'Read') {
|
|
68
|
-
return { file_path: path ?? '' };
|
|
69
|
-
}
|
|
70
|
-
if (tool === 'Write') {
|
|
71
|
-
return { file_path: path ?? '', content: content ?? '' };
|
|
72
|
-
}
|
|
73
|
-
if (tool === 'Edit' || tool === 'MultiEdit') {
|
|
74
|
-
return { file_path: path ?? '', old_string: '', new_string: content ?? '' };
|
|
75
|
-
}
|
|
76
|
-
return undefined;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
interface EventParams {
|
|
80
|
-
readonly tool: string;
|
|
81
|
-
readonly post: boolean;
|
|
82
|
-
readonly command: string | undefined;
|
|
83
|
-
readonly path: string | undefined;
|
|
84
|
-
readonly stdout: string | undefined;
|
|
85
|
-
readonly stderr: string | undefined;
|
|
86
|
-
readonly content: string | undefined;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const buildEvent = (params: EventParams): BuiltEvent => {
|
|
90
|
-
const { tool, post, command, path, stdout, stderr, content } = params;
|
|
91
|
-
const eventName = post ? 'PostToolUse' : 'PreToolUse';
|
|
92
|
-
const event: BuiltEvent = {
|
|
93
|
-
hook_event_name: eventName,
|
|
94
|
-
tool_name: tool,
|
|
95
|
-
cwd: process.cwd(),
|
|
96
|
-
session_id: 'tripwire-cli-test',
|
|
97
|
-
tool_input: buildToolInput(tool, command, path, content),
|
|
98
|
-
};
|
|
99
|
-
if (post) {
|
|
100
|
-
event.tool_response =
|
|
101
|
-
tool === 'Bash' ? { stdout: stdout ?? '', stderr: stderr ?? '' } : { content: content ?? '' };
|
|
102
|
-
}
|
|
103
|
-
return event;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const runTest = (config: {
|
|
107
|
-
readonly command: string | undefined;
|
|
108
|
-
readonly content: string | undefined;
|
|
109
|
-
readonly path: string | undefined;
|
|
110
|
-
readonly post: boolean;
|
|
111
|
-
readonly stderr: string | undefined;
|
|
112
|
-
readonly stdout: string | undefined;
|
|
113
|
-
readonly tool: string;
|
|
114
|
-
}): Effect.Effect<void> =>
|
|
115
|
-
Effect.gen(function* () {
|
|
116
|
-
const { command, content, path, post, stderr, stdout, tool } = config;
|
|
117
|
-
const event = buildEvent({ tool, post, command, path, stdout, stderr, content });
|
|
118
|
-
const bin = yield* Effect.promise(() => dispatchBin());
|
|
119
|
-
const result = Bun.spawnSync([bin], {
|
|
120
|
-
stdin: new TextEncoder().encode(JSON.stringify(event)),
|
|
121
|
-
timeout: 10_000,
|
|
122
|
-
stdout: 'pipe',
|
|
123
|
-
stderr: 'pipe',
|
|
124
|
-
});
|
|
125
|
-
if (result.exitCode !== 0) {
|
|
126
|
-
const errorOutput = new TextDecoder().decode(result.stderr);
|
|
127
|
-
console.error(`error: ${errorOutput}`);
|
|
128
|
-
process.exit(1);
|
|
129
|
-
}
|
|
130
|
-
const output = new TextDecoder().decode(result.stdout);
|
|
131
|
-
try {
|
|
132
|
-
const parsed = JSON.parse(output) as unknown;
|
|
133
|
-
console.log(JSON.stringify(parsed, null, 2));
|
|
134
|
-
} catch {
|
|
135
|
-
console.log(output);
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
const testCommand = Command.make(
|
|
140
|
-
'test',
|
|
141
|
-
{
|
|
142
|
-
command: Argument.string('command').pipe(
|
|
143
|
-
Argument.optional,
|
|
144
|
-
Argument.withDescription('Command to test (for Bash tool)'),
|
|
145
|
-
),
|
|
146
|
-
content: Flag.string('content').pipe(
|
|
147
|
-
Flag.optional,
|
|
148
|
-
Flag.withDescription('Content for Write/Edit tools'),
|
|
149
|
-
),
|
|
150
|
-
path: Flag.string('path').pipe(
|
|
151
|
-
Flag.optional,
|
|
152
|
-
Flag.withDescription('File path for Read/Write/Edit tools'),
|
|
153
|
-
),
|
|
154
|
-
post: Flag.boolean('post').pipe(Flag.withDescription('Test PostToolUse instead of PreToolUse')),
|
|
155
|
-
stderr: Flag.string('stderr').pipe(
|
|
156
|
-
Flag.optional,
|
|
157
|
-
Flag.withDescription('Stderr for PostToolUse Bash'),
|
|
158
|
-
),
|
|
159
|
-
stdout: Flag.string('stdout').pipe(
|
|
160
|
-
Flag.optional,
|
|
161
|
-
Flag.withDescription('Stdout for PostToolUse Bash'),
|
|
162
|
-
),
|
|
163
|
-
tool: Flag.string('tool').pipe(
|
|
164
|
-
Flag.withDefault('Bash'),
|
|
165
|
-
Flag.withDescription('Tool name (Bash, Read, Write, Edit, MultiEdit)'),
|
|
166
|
-
),
|
|
167
|
-
},
|
|
168
|
-
({ command, content, path, post, stderr, stdout, tool }) =>
|
|
169
|
-
runTest({
|
|
170
|
-
command: Option.getOrUndefined(command),
|
|
171
|
-
content: Option.getOrUndefined(content),
|
|
172
|
-
path: Option.getOrUndefined(path),
|
|
173
|
-
post,
|
|
174
|
-
stderr: Option.getOrUndefined(stderr),
|
|
175
|
-
stdout: Option.getOrUndefined(stdout),
|
|
176
|
-
tool,
|
|
177
|
-
}),
|
|
178
|
-
).pipe(Command.withDescription('Test a synthetic hook event'));
|
|
179
|
-
|
|
180
|
-
const runInstall = (target: string): Effect.Effect<void> =>
|
|
181
|
-
Effect.gen(function* () {
|
|
182
|
-
if (!['claude', 'codex', 'pi', 'all'].includes(target)) {
|
|
183
|
-
console.error(`error: unknown target "${target}"`);
|
|
184
|
-
console.error('Valid targets: claude, codex, pi, all');
|
|
185
|
-
process.exit(1);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
let results: {
|
|
189
|
-
readonly target: string;
|
|
190
|
-
readonly result: { readonly success: boolean; readonly message: string };
|
|
191
|
-
}[];
|
|
192
|
-
|
|
193
|
-
switch (target) {
|
|
194
|
-
case 'claude': {
|
|
195
|
-
const result = yield* Effect.promise(() => installClaude());
|
|
196
|
-
results = [{ target: 'claude', result }];
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
case 'codex': {
|
|
200
|
-
const result = yield* Effect.promise(() => installCodex());
|
|
201
|
-
results = [{ target: 'codex', result }];
|
|
202
|
-
break;
|
|
203
|
-
}
|
|
204
|
-
case 'pi': {
|
|
205
|
-
const result = yield* Effect.promise(() => installPi());
|
|
206
|
-
results = [{ target: 'pi', result }];
|
|
207
|
-
break;
|
|
208
|
-
}
|
|
209
|
-
case 'all': {
|
|
210
|
-
const installResults = yield* Effect.promise(() => installAll());
|
|
211
|
-
results = installResults.map((r) => ({ target: r.target, result: r }));
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
|
-
default: {
|
|
215
|
-
results = [];
|
|
216
|
-
break;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
let hasFailure = false;
|
|
221
|
-
for (const { target: t, result: r } of results) {
|
|
222
|
-
if (r.success) {
|
|
223
|
-
const symbol = r.message.startsWith('Already configured') ? '⊙' : '✓';
|
|
224
|
-
console.log(`${symbol} [${t}] ${r.message}`);
|
|
225
|
-
} else {
|
|
226
|
-
console.error(`✗ [${t}] ${r.message}`);
|
|
227
|
-
hasFailure = true;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (hasFailure) {
|
|
232
|
-
process.exit(1);
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
const installCommand = Command.make(
|
|
237
|
-
'install',
|
|
238
|
-
{
|
|
239
|
-
target: Argument.string('target').pipe(
|
|
240
|
-
Argument.withDescription('Target agent (claude, codex, pi, or all)'),
|
|
241
|
-
),
|
|
242
|
-
},
|
|
243
|
-
({ target }) => runInstall(target),
|
|
244
|
-
).pipe(Command.withDescription('Install tripwire hooks for AI agents'));
|
|
245
|
-
|
|
246
|
-
const app = Command.make('tripwire').pipe(
|
|
247
|
-
Command.withDescription('Opinionated hooks dispatcher for AI coding agents'),
|
|
248
|
-
Command.withSubcommands([testCommand, installCommand]),
|
|
249
|
-
);
|
|
250
|
-
|
|
251
|
-
const program = Command.run(app, { version: pkg.version });
|
|
252
|
-
|
|
253
|
-
const main = async (): Promise<void> => {
|
|
254
|
-
try {
|
|
255
|
-
await Effect.runPromise(program.pipe(Effect.provide(BunServices.layer)));
|
|
256
|
-
} catch (error) {
|
|
257
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
258
|
-
console.error(message);
|
|
259
|
-
process.exitCode = 1;
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
// oxlint-disable-next-line no-void, unicorn/prefer-top-level-await
|
|
264
|
-
void main();
|
package/src/dispatch.ts
DELETED
|
@@ -1,354 +0,0 @@
|
|
|
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.
|
|
16
|
-
|
|
17
|
-
import { BunRuntime } from '@effect/platform-bun';
|
|
18
|
-
import { Cause, Effect, Exit, Schema } from 'effect';
|
|
19
|
-
|
|
20
|
-
import { parseCommand } from './lib/bash';
|
|
21
|
-
import {
|
|
22
|
-
CONFIG_PATH,
|
|
23
|
-
getDefaultConfig,
|
|
24
|
-
loadConfigResult,
|
|
25
|
-
mergeWithDefaults,
|
|
26
|
-
type Config,
|
|
27
|
-
} from './lib/config';
|
|
28
|
-
import { type Decision, allow, deny, merge } from './lib/decision';
|
|
29
|
-
import {
|
|
30
|
-
type BashInput,
|
|
31
|
-
type EditInput,
|
|
32
|
-
type HookEvent,
|
|
33
|
-
HookEventSchema,
|
|
34
|
-
type ReadInput,
|
|
35
|
-
type WriteInput,
|
|
36
|
-
isBashInput,
|
|
37
|
-
isEditInput,
|
|
38
|
-
isReadInput,
|
|
39
|
-
isWriteInput,
|
|
40
|
-
} from './lib/event.ts';
|
|
41
|
-
import { logError } from './lib/log';
|
|
42
|
-
import { bashDeny } from './rules/bash-deny';
|
|
43
|
-
import { bashGit } from './rules/bash-git';
|
|
44
|
-
import { bashNetworkInstall } from './rules/bash-network-install';
|
|
45
|
-
import { bashRedirect } from './rules/bash-redirect';
|
|
46
|
-
import { bashScopedRm } from './rules/bash-scoped-rm';
|
|
47
|
-
import { bashTarExplosion } from './rules/bash-tar-explosion';
|
|
48
|
-
import { bashToolPolicy } from './rules/bash-tool-policy';
|
|
49
|
-
import { configCustom } from './rules/config-custom';
|
|
50
|
-
import { lazyCode } from './rules/lazy-code';
|
|
51
|
-
import { pathProtect } from './rules/path-protect';
|
|
52
|
-
import { postSecretScrub } from './rules/post-secret-scrub';
|
|
53
|
-
import { readProtect } from './rules/read-protect';
|
|
54
|
-
|
|
55
|
-
const readStdin = async (): Promise<string> => {
|
|
56
|
-
const chunks: Buffer[] = [];
|
|
57
|
-
for await (const chunk of process.stdin) {
|
|
58
|
-
chunks.push(chunk as Buffer);
|
|
59
|
-
}
|
|
60
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const writeAllow = (): void => {
|
|
64
|
-
process.stdout.write('{"continue": true}\n');
|
|
65
|
-
};
|
|
66
|
-
|
|
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.
|
|
71
|
-
const isCodex = (event: HookEvent): boolean => event.turn_id !== undefined;
|
|
72
|
-
|
|
73
|
-
interface WarnOutput {
|
|
74
|
-
hookEventName: string;
|
|
75
|
-
additionalContext?: string;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const writeWarn = (event: HookEvent, decision: Decision): void => {
|
|
79
|
-
const eventName = event.hook_event_name;
|
|
80
|
-
const reason = `[tripwire:${decision.rule}] ${decision.message}`;
|
|
81
|
-
if (isCodex(event)) {
|
|
82
|
-
// Codex rejects `additionalContext` on PreToolUse. Send only
|
|
83
|
-
// `systemMessage`.
|
|
84
|
-
process.stdout.write(`${JSON.stringify({ continue: true, systemMessage: reason })}\n`);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
const hookSpecificOutput: WarnOutput = { hookEventName: eventName, additionalContext: reason };
|
|
88
|
-
process.stdout.write(`${JSON.stringify({ continue: true, hookSpecificOutput })}\n`);
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const writePreToolGate = (eventName: string, decision: Decision): void => {
|
|
92
|
-
const out = {
|
|
93
|
-
hookSpecificOutput: {
|
|
94
|
-
hookEventName: eventName,
|
|
95
|
-
permissionDecision: decision.kind === 'deny' ? 'deny' : 'ask',
|
|
96
|
-
permissionDecisionReason: `[tripwire:${decision.rule}] ${decision.message}`,
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
const writePostToolBlock = (decision: Decision): void => {
|
|
103
|
-
const out = {
|
|
104
|
-
continue: true,
|
|
105
|
-
decision: 'block',
|
|
106
|
-
reason: `[tripwire:${decision.rule}] ${decision.message}`,
|
|
107
|
-
};
|
|
108
|
-
process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
109
|
-
};
|
|
110
|
-
|
|
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.
|
|
116
|
-
const normalizeToolName = (name: string): string => {
|
|
117
|
-
const n = name.toLowerCase();
|
|
118
|
-
if (n === 'bash' || n === 'exec' || n === 'shell' || n === 'run_command') {
|
|
119
|
-
return 'Bash';
|
|
120
|
-
}
|
|
121
|
-
if (n === 'read' || n === 'read_file') {
|
|
122
|
-
return 'Read';
|
|
123
|
-
}
|
|
124
|
-
if (n === 'write' || n === 'write_file') {
|
|
125
|
-
return 'Write';
|
|
126
|
-
}
|
|
127
|
-
if (n === 'edit' || n === 'edit_file' || n === 'multiedit' || n === 'apply_patch') {
|
|
128
|
-
return 'Edit';
|
|
129
|
-
}
|
|
130
|
-
if (n === 'webfetch' || n === 'web_fetch' || n === 'fetch') {
|
|
131
|
-
return 'WebFetch';
|
|
132
|
-
}
|
|
133
|
-
return name;
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
type RuleFn = () => Decision;
|
|
137
|
-
|
|
138
|
-
const runRule = (name: string, fn: RuleFn, timeoutMs: number): Effect.Effect<Decision> =>
|
|
139
|
-
Effect.gen(function* () {
|
|
140
|
-
const exit = yield* Effect.exit(
|
|
141
|
-
Effect.try({ try: fn, catch: (e) => e }).pipe(Effect.timeout(timeoutMs)),
|
|
142
|
-
);
|
|
143
|
-
if (Exit.isSuccess(exit)) {
|
|
144
|
-
return exit.value;
|
|
145
|
-
}
|
|
146
|
-
logError(name, Cause.pretty(exit.cause));
|
|
147
|
-
return allow(name);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
interface Rule {
|
|
151
|
-
readonly name: string;
|
|
152
|
-
readonly fn: RuleFn;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const collectPreToolUseRules = (tool: string, input: unknown, config: Config): Rule[] => {
|
|
156
|
-
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
|
-
});
|
|
165
|
-
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',
|
|
175
|
-
fn: () =>
|
|
176
|
-
configCustom(
|
|
177
|
-
segments,
|
|
178
|
-
i.command,
|
|
179
|
-
config.blockedCommands ?? [],
|
|
180
|
-
config.allowedCommands ?? [],
|
|
181
|
-
),
|
|
182
|
-
});
|
|
183
|
-
return rules;
|
|
184
|
-
}
|
|
185
|
-
if (tool === 'Read' && isReadInput(input)) {
|
|
186
|
-
const i: ReadInput = input;
|
|
187
|
-
rules.push({ name: 'read-protect', fn: () => readProtect(i) });
|
|
188
|
-
return rules;
|
|
189
|
-
}
|
|
190
|
-
const isEdit = (tool === 'Edit' || tool === 'MultiEdit') && isEditInput(input);
|
|
191
|
-
const isWrite = tool === 'Write' && isWriteInput(input);
|
|
192
|
-
if (isEdit) {
|
|
193
|
-
const i: EditInput = input;
|
|
194
|
-
rules.push({ name: 'path-protect', fn: () => pathProtect(i) });
|
|
195
|
-
rules.push({ name: 'lazy-code', fn: () => lazyCode(i) });
|
|
196
|
-
} else if (isWrite) {
|
|
197
|
-
const i: WriteInput = input;
|
|
198
|
-
rules.push({ name: 'path-protect', fn: () => pathProtect(i) });
|
|
199
|
-
rules.push({ name: 'lazy-code', fn: () => lazyCode(i) });
|
|
200
|
-
}
|
|
201
|
-
return rules;
|
|
202
|
-
};
|
|
203
|
-
|
|
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 }) }];
|
|
207
|
-
}
|
|
208
|
-
return [];
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
const runRules = (rules: readonly Rule[], timeoutMs: number): Effect.Effect<Decision> =>
|
|
212
|
-
Effect.gen(function* () {
|
|
213
|
-
if (rules.length === 0) {
|
|
214
|
-
return allow('no-rules');
|
|
215
|
-
}
|
|
216
|
-
const decisions: Decision[] = [];
|
|
217
|
-
for (const r of rules) {
|
|
218
|
-
decisions.push(yield* runRule(r.name, r.fn, timeoutMs));
|
|
219
|
-
}
|
|
220
|
-
return merge(decisions);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
const runRulesSync = (rules: readonly Rule[]): Decision => {
|
|
224
|
-
if (rules.length === 0) {
|
|
225
|
-
return allow('no-rules');
|
|
226
|
-
}
|
|
227
|
-
return merge(rules.map((r) => r.fn()));
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
const decide = (event: HookEvent, config: Config = getDefaultConfig()): Decision => {
|
|
231
|
-
const mergedConfig = mergeWithDefaults(config);
|
|
232
|
-
const tool = normalizeToolName(event.tool_name ?? '');
|
|
233
|
-
if (event.hook_event_name === 'PreToolUse') {
|
|
234
|
-
return runRulesSync(collectPreToolUseRules(tool, event.tool_input, mergedConfig));
|
|
235
|
-
}
|
|
236
|
-
if (event.hook_event_name === 'PostToolUse') {
|
|
237
|
-
return runRulesSync(collectPostToolUseRules(tool, event.tool_response));
|
|
238
|
-
}
|
|
239
|
-
return allow('no-rules');
|
|
240
|
-
};
|
|
241
|
-
|
|
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 => {
|
|
251
|
-
const eventName = event.hook_event_name;
|
|
252
|
-
const tool = normalizeToolName(event.tool_name ?? '');
|
|
253
|
-
if (eventName === 'PreToolUse' && tool === 'Bash') {
|
|
254
|
-
handleBashAllow(event, decision, config);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
if (decision.kind === 'warn') {
|
|
258
|
-
writeWarn(event, decision);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
writeAllow();
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
// A broken config (bad JSON / unknown field / decode failure) silently dropping
|
|
265
|
-
// All custom policy is the dangerous case this guards. Fail closed: deny the
|
|
266
|
-
// Pending PreToolUse call with the decode error inline so the agent halts and
|
|
267
|
-
// The user sees it, rather than running on bare defaults unannounced.
|
|
268
|
-
const configErrorMessage = (error: string): string =>
|
|
269
|
-
`tripwire config at ${CONFIG_PATH} failed to load, so ALL custom safety policy is ` +
|
|
270
|
-
`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}`;
|
|
272
|
-
|
|
273
|
-
const program = Effect.gen(function* () {
|
|
274
|
-
const configLoad = yield* loadConfigResult();
|
|
275
|
-
const raw = yield* Effect.promise(readStdin);
|
|
276
|
-
|
|
277
|
-
const parseExit = yield* Effect.exit(
|
|
278
|
-
Effect.try({ try: () => JSON.parse(raw) as unknown, catch: (e) => e }),
|
|
279
|
-
);
|
|
280
|
-
if (Exit.isFailure(parseExit)) {
|
|
281
|
-
logError('parse', Cause.pretty(parseExit.cause));
|
|
282
|
-
writeAllow();
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
|
|
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();
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
const event = decodeExit.value;
|
|
295
|
-
|
|
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
|
-
);
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
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);
|
|
314
|
-
if (decision.kind === 'deny' || decision.kind === 'ask') {
|
|
315
|
-
writePreToolGate(event.hook_event_name, decision);
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
handleAllow(event, decision, config);
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
if (event.hook_event_name === 'PostToolUse') {
|
|
323
|
-
const decision = decide(event, config);
|
|
324
|
-
if (decision.kind === 'deny') {
|
|
325
|
-
writePostToolBlock(decision);
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
writeAllow();
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
writeAllow();
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
const handled = program.pipe(
|
|
336
|
-
Effect.catchCause((cause) => {
|
|
337
|
-
logError('dispatch-fatal', Cause.pretty(cause));
|
|
338
|
-
writeAllow();
|
|
339
|
-
return Effect.void;
|
|
340
|
-
}),
|
|
341
|
-
);
|
|
342
|
-
|
|
343
|
-
if (import.meta.main) {
|
|
344
|
-
BunRuntime.runMain(handled);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
export {
|
|
348
|
-
collectPostToolUseRules,
|
|
349
|
-
collectPreToolUseRules,
|
|
350
|
-
decide,
|
|
351
|
-
normalizeToolName,
|
|
352
|
-
runRules,
|
|
353
|
-
runRulesSync,
|
|
354
|
-
};
|
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 } 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';
|