@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/lib/log.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  import { appendFileSync, mkdirSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- // oxlint-disable-next-line unicorn/import-style
4
- import { dirname } from 'node:path';
3
+ import path from 'node:path';
5
4
 
6
5
  const LOG_PATH = `${homedir()}/.claude/tripwire.log`;
7
6
 
8
7
  try {
9
- mkdirSync(dirname(LOG_PATH), { recursive: true });
8
+ mkdirSync(path.dirname(LOG_PATH), { recursive: true });
10
9
  } catch {
11
10
  // Directory creation failure is non-fatal — logging is best-effort.
12
11
  }
@@ -1,121 +1,184 @@
1
- // Secret scanning via the `betterleaks` binary (Zach Rice's gitleaks
2
- // Successor, MIT). We spawn it once per PostToolUse, write the tool
3
- // Output to a temp file, scan the file, parse JSON findings, redact
4
- // Matches in-place, and delete the temp file.
5
- //
6
- // Why subprocess vs. inline regex: betterleaks ships the curated
7
- // 100+-rule pack the gitleaks ecosystem has tuned over years (AWS, GH,
8
- // Stripe, OpenAI, Anthropic, mongo URLs, JWTs, private keys, plus ~70
9
- // Long-tail vendors). We get all of it for one fork+exec, ~250–300ms.
10
- //
11
- // Why a temp file vs. `--pipe`: betterleaks `--pipe` *adds* stdin to its
12
- // Scan but does not replace the directory walk, so it scans the cwd as
13
- // Well. Writing to a tempfile in /tmp and using `--source <file>` is
14
- // Scoped, deterministic, and only ~5ms slower.
1
+ // Betterleaks supplies the maintained secret-rule set. The stdin command keeps
2
+ // scanned content in memory and writes its JSON report to stdout.
15
3
 
16
4
  import { spawnSync } from 'node:child_process';
17
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
- import { tmpdir } from 'node:os';
19
- // oxlint-disable-next-line unicorn/import-style
20
- import { join } from 'node:path';
21
-
22
- interface BetterleaksFinding {
23
- readonly RuleID: string;
24
- readonly Description: string;
25
- readonly StartLine: number;
26
- readonly EndLine: number;
27
- readonly Secret: string;
28
- readonly Match: string;
29
- }
30
5
 
31
- interface ScanResult {
6
+ import { Result, Schema } from 'effect';
7
+
8
+ import type { SecretScannerConfig } from './config';
9
+
10
+ const BetterleaksFindingSchema = Schema.Struct({
11
+ RuleID: Schema.String,
12
+ Description: Schema.String,
13
+ StartLine: Schema.Finite,
14
+ EndLine: Schema.Finite,
15
+ Secret: Schema.String,
16
+ Match: Schema.String,
17
+ });
18
+
19
+ const BetterleaksReportSchema = Schema.Array(BetterleaksFindingSchema);
20
+
21
+ type BetterleaksFinding = typeof BetterleaksFindingSchema.Type;
22
+
23
+ interface ScanSuccess {
24
+ readonly ok: true;
32
25
  readonly hits: readonly { readonly rule: string; readonly count: number }[];
33
26
  readonly redacted: string;
34
27
  }
35
28
 
36
- const BETTERLEAKS_BIN = '/opt/homebrew/bin/betterleaks';
29
+ type ScanFailureCategory = 'missing-executable' | 'timeout' | 'non-zero-exit' | 'malformed-json';
30
+
31
+ interface ScanFailure {
32
+ readonly ok: false;
33
+ readonly category: ScanFailureCategory;
34
+ }
35
+
36
+ type ScanResult = ScanSuccess | ScanFailure;
37
+
38
+ interface ScannerInvocation {
39
+ readonly executable: string;
40
+ readonly args: readonly string[];
41
+ readonly input: string;
42
+ readonly timeoutMs: number;
43
+ }
44
+
45
+ interface ScannerProcessResult {
46
+ readonly status: number | null;
47
+ readonly stdout: string;
48
+ readonly error?: unknown;
49
+ }
50
+
51
+ type ScannerRunner = (invocation: ScannerInvocation) => ScannerProcessResult;
52
+
53
+ const BETTERLEAKS_ARGS = [
54
+ 'stdin',
55
+ '--report-format',
56
+ 'json',
57
+ '--report-path',
58
+ '-',
59
+ '--exit-code',
60
+ '0',
61
+ '--no-banner',
62
+ '--no-color',
63
+ '--log-level',
64
+ 'error',
65
+ ] as const;
66
+
67
+ const defaultScannerRunner: ScannerRunner = ({ executable, args, input, timeoutMs }) => {
68
+ const result = spawnSync(executable, args, {
69
+ encoding: 'utf8',
70
+ input,
71
+ maxBuffer: 64 * 1024 * 1024,
72
+ stdio: ['pipe', 'pipe', 'ignore'],
73
+ timeout: timeoutMs,
74
+ });
75
+ const processResult: ScannerProcessResult = {
76
+ status: result.status,
77
+ stdout: typeof result.stdout === 'string' ? result.stdout : '',
78
+ };
79
+ return result.error === undefined ? processResult : { ...processResult, error: result.error };
80
+ };
81
+
82
+ const errorCode = (cause: unknown): string | undefined => {
83
+ if (typeof cause !== 'object' || cause === null || !('code' in cause)) {
84
+ return undefined;
85
+ }
86
+ return typeof cause.code === 'string' ? cause.code : undefined;
87
+ };
88
+
89
+ const classifyExecutionFailure = (cause: unknown): ScanFailureCategory => {
90
+ const code = errorCode(cause);
91
+ if (code === 'ENOENT') {
92
+ return 'missing-executable';
93
+ }
94
+ if (code === 'ETIMEDOUT') {
95
+ return 'timeout';
96
+ }
97
+ return 'non-zero-exit';
98
+ };
37
99
 
38
100
  const summarizeHits = (
39
101
  findings: readonly BetterleaksFinding[],
40
102
  ): readonly { rule: string; count: number }[] => {
41
103
  const counts = new Map<string, number>();
42
- for (const f of findings) {
43
- counts.set(f.RuleID, (counts.get(f.RuleID) ?? 0) + 1);
104
+ for (const finding of findings) {
105
+ counts.set(finding.RuleID, (counts.get(finding.RuleID) ?? 0) + 1);
44
106
  }
45
107
  return [...counts.entries()].map(([rule, count]) => ({ rule, count }));
46
108
  };
47
109
 
48
- const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
110
+ const escapeRegExp = (value: string): string =>
111
+ value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
49
112
 
50
113
  // Replace every found secret in the original input with a tagged redaction.
51
114
  const redactWith = (input: string, findings: readonly BetterleaksFinding[]): string => {
52
- let out = input;
115
+ let output = input;
53
116
  // Sort by length descending so shorter matches that are substrings of
54
- // Longer ones don't fire first and break the longer match.
117
+ // Longer ones do not fire first and break the longer match.
55
118
  const sorted = [...findings].toSorted((a, b) => b.Secret.length - a.Secret.length);
56
- for (const f of sorted) {
57
- if (f.Secret === '') {
119
+ for (const finding of sorted) {
120
+ if (finding.Secret === '') {
58
121
  continue;
59
122
  }
60
- out = out.replaceAll(f.Secret, `[REDACTED:${f.RuleID}]`);
123
+ output = output.replaceAll(finding.Secret, `[REDACTED:${finding.RuleID}]`);
61
124
  }
62
- return out;
125
+ return output;
63
126
  };
64
127
 
65
- const scanAndRedact = (input: string, timeoutMs = 5000): ScanResult => {
128
+ const scanAndRedact = (
129
+ input: string,
130
+ config: SecretScannerConfig,
131
+ runner: ScannerRunner = defaultScannerRunner,
132
+ ): ScanResult => {
66
133
  if (input.length === 0) {
67
- return { hits: [], redacted: input };
134
+ return { ok: true, hits: [], redacted: input };
68
135
  }
69
- const dir = mkdtempSync(join(tmpdir(), 'tripwire-scan-'));
70
- const inPath = join(dir, 'input');
71
- const reportPath = join(dir, 'report.json');
136
+
137
+ let processResult: ScannerProcessResult;
72
138
  try {
73
- writeFileSync(inPath, input);
74
- const result = spawnSync(
75
- BETTERLEAKS_BIN,
76
- [
77
- 'detect',
78
- '--no-git',
79
- '--no-banner',
80
- '--no-color',
81
- '--report-format',
82
- 'json',
83
- '--report-path',
84
- reportPath,
85
- '--source',
86
- inPath,
87
- '--exit-code',
88
- '0',
89
- '--log-level',
90
- 'error',
91
- ],
92
- { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 },
93
- );
94
- if (result.error !== undefined) {
95
- return { hits: [], redacted: input };
96
- }
97
- let findings: BetterleaksFinding[];
98
- try {
99
- const raw = readFileSync(reportPath, 'utf8');
100
- const parsed = JSON.parse(raw || '[]') as unknown;
101
- findings = Array.isArray(parsed) ? (parsed as BetterleaksFinding[]) : [];
102
- } catch {
103
- return { hits: [], redacted: input };
104
- }
105
- if (findings.length === 0) {
106
- return { hits: [], redacted: input };
107
- }
108
- return { hits: summarizeHits(findings), redacted: redactWith(input, findings) };
109
- } finally {
110
- try {
111
- rmSync(dir, { recursive: true, force: true });
112
- } catch {
113
- // Best-effort cleanup.
114
- }
139
+ processResult = runner({
140
+ executable: config.executable,
141
+ args: BETTERLEAKS_ARGS,
142
+ input,
143
+ timeoutMs: config.timeoutMs,
144
+ });
145
+ } catch {
146
+ return { ok: false, category: 'non-zero-exit' };
115
147
  }
148
+
149
+ if (processResult.error !== undefined) {
150
+ return { ok: false, category: classifyExecutionFailure(processResult.error) };
151
+ }
152
+ if (processResult.status !== 0) {
153
+ return { ok: false, category: 'non-zero-exit' };
154
+ }
155
+
156
+ let parsed: unknown;
157
+ try {
158
+ parsed = JSON.parse(processResult.stdout) as unknown;
159
+ } catch {
160
+ return { ok: false, category: 'malformed-json' };
161
+ }
162
+
163
+ const decoded = Schema.decodeUnknownResult(BetterleaksReportSchema)(parsed);
164
+ if (Result.isFailure(decoded)) {
165
+ return { ok: false, category: 'malformed-json' };
166
+ }
167
+
168
+ const findings = decoded.success;
169
+ return { ok: true, hits: summarizeHits(findings), redacted: redactWith(input, findings) };
116
170
  };
117
171
 
118
- // `escapeRegExp` is exported so tests / callers can build patterns over the
119
- // Redacted output without re-implementing escaping.
120
- export type { BetterleaksFinding, ScanResult };
172
+ // `escapeRegExp` is exported so tests and callers can build patterns over the
173
+ // Redacted output without reimplementing escaping.
174
+ export type {
175
+ BetterleaksFinding,
176
+ ScanFailure,
177
+ ScanFailureCategory,
178
+ ScannerInvocation,
179
+ ScannerProcessResult,
180
+ ScannerRunner,
181
+ ScanResult,
182
+ ScanSuccess,
183
+ };
121
184
  export { escapeRegExp, scanAndRedact };
package/src/main.ts ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import path from 'node:path';
4
+
5
+ import pkg from '../package.json' with { type: 'json' };
6
+
7
+ const INTERNAL_HOOK_FLAG = '--tripwire-hook';
8
+ const FORCE_CLI_FLAG = '--tripwire-force-cli';
9
+
10
+ const forceCliIndex = process.argv.indexOf(FORCE_CLI_FLAG, 2);
11
+ const forceCli = forceCliIndex !== -1;
12
+ if (forceCli) {
13
+ process.argv.splice(forceCliIndex, 1);
14
+ }
15
+ const cliArguments = process.argv.slice(2);
16
+ const executableName = path.basename(process.argv0);
17
+ const isHook =
18
+ !forceCli &&
19
+ (executableName === 'tripwire-hook' ||
20
+ cliArguments.includes(INTERNAL_HOOK_FLAG) ||
21
+ cliArguments.some((argument) => argument.startsWith('--cursor-event')));
22
+
23
+ if (isHook) {
24
+ const { runHook } = await import('./dispatch');
25
+ runHook();
26
+ } else if (cliArguments.length === 1 && cliArguments[0] === '--version') {
27
+ process.stdout.write(`${pkg.version}\n`);
28
+ } else {
29
+ const { runCli } = await import('./cli');
30
+ await runCli();
31
+ }
@@ -0,0 +1,337 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { once } from 'node:events';
3
+ import { realpathSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import type { Readable } from 'node:stream';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ interface PiToolCallEvent {
9
+ readonly input: Record<string, unknown>;
10
+ readonly toolCallId: string;
11
+ readonly toolName: string;
12
+ }
13
+
14
+ type PiToolResultEvent = PiToolCallEvent & {
15
+ readonly content: unknown;
16
+ readonly details: unknown;
17
+ readonly isError: boolean;
18
+ };
19
+
20
+ interface PiExtensionContext {
21
+ readonly abort: () => void;
22
+ readonly cwd: string;
23
+ readonly ui: { readonly notify: (message: string, level: 'error') => void };
24
+ }
25
+
26
+ interface TripwirePiExtensionApi {
27
+ readonly on: {
28
+ (
29
+ event: 'tool_call',
30
+ handler: (
31
+ event: PiToolCallEvent,
32
+ context: PiExtensionContext,
33
+ ) => Promise<{ readonly block?: boolean; readonly reason?: string } | undefined>,
34
+ ): void;
35
+ (
36
+ event: 'tool_result',
37
+ handler: (event: PiToolResultEvent, context: PiExtensionContext) => Promise<void>,
38
+ ): void;
39
+ };
40
+ }
41
+
42
+ interface HookResult {
43
+ readonly exitCode: number;
44
+ readonly stderr: string;
45
+ readonly stdout: string;
46
+ }
47
+
48
+ type TripwireProcessRunner = (hookPath: string, input: unknown) => Promise<HookResult>;
49
+
50
+ const TIMEOUT_MS = 60_000;
51
+
52
+ /** Resolve the Bun hook next to the built extension, following install symlinks. */
53
+ const resolveShippedHookPath = (extensionUrl: string | URL = import.meta.url): string => {
54
+ const extensionPath = fileURLToPath(extensionUrl);
55
+ let resolved = extensionPath;
56
+ try {
57
+ resolved = realpathSync(extensionPath);
58
+ } catch {
59
+ // Keep the unresolved path when the module is not on disk yet (unit tests).
60
+ }
61
+ return path.join(path.dirname(resolved), 'tripwire');
62
+ };
63
+
64
+ const shippedHookPath = resolveShippedHookPath();
65
+
66
+ const requiredStringField = (
67
+ value: Record<string, unknown>,
68
+ names: readonly string[],
69
+ label: string,
70
+ ): string => {
71
+ for (const name of names) {
72
+ const field = value[name];
73
+ if (typeof field === 'string') {
74
+ return field;
75
+ }
76
+ }
77
+ throw new Error(`Pi ${label} is missing`);
78
+ };
79
+
80
+ const stringArrayField = (value: Record<string, unknown>, name: string): string[] => {
81
+ const field = value[name];
82
+ return Array.isArray(field) ? field.filter((item) => typeof item === 'string') : [];
83
+ };
84
+
85
+ const editStrings = (
86
+ input: Record<string, unknown>,
87
+ ): { old_string: string; new_string: string } => {
88
+ const { edits } = input;
89
+ if (Array.isArray(edits)) {
90
+ const oldStrings: string[] = [];
91
+ const newStrings: string[] = [];
92
+ for (const edit of edits) {
93
+ if (!isRecord(edit)) {
94
+ continue;
95
+ }
96
+ const oldText = stringField(edit, 'oldText') ?? stringField(edit, 'old_string');
97
+ const newText = stringField(edit, 'newText') ?? stringField(edit, 'new_string');
98
+ if (oldText !== undefined) {
99
+ oldStrings.push(oldText);
100
+ }
101
+ if (newText !== undefined) {
102
+ newStrings.push(newText);
103
+ }
104
+ }
105
+ return { old_string: oldStrings.join('\n'), new_string: newStrings.join('\n') };
106
+ }
107
+ return {
108
+ old_string: stringField(input, 'oldText') ?? stringField(input, 'old_string') ?? '',
109
+ new_string:
110
+ stringField(input, 'newText') ??
111
+ stringField(input, 'new_string') ??
112
+ stringField(input, 'input') ??
113
+ stringField(input, '_input') ??
114
+ '',
115
+ };
116
+ };
117
+
118
+ const normalizedToolInputs = (event: PiToolCallEvent): readonly Record<string, unknown>[] => {
119
+ const { input, toolName } = event;
120
+ if (toolName === 'bash' || toolName === 'powershell') {
121
+ return [{ command: requiredStringField(input, ['command'], `${toolName} command`) }];
122
+ }
123
+ if (toolName === 'read') {
124
+ return [{ file_path: requiredStringField(input, ['path', 'file_path'], 'read path') }];
125
+ }
126
+ if (toolName === 'write') {
127
+ return [
128
+ {
129
+ content: requiredStringField(input, ['content'], 'write content'),
130
+ file_path: requiredStringField(input, ['path', 'file_path'], 'write path'),
131
+ },
132
+ ];
133
+ }
134
+ if (toolName === 'edit') {
135
+ const directPath = stringField(input, 'path') ?? stringField(input, 'file_path');
136
+ const paths = stringArrayField(input, 'paths');
137
+ let targets = paths;
138
+ if (targets.length === 0 && directPath !== undefined) {
139
+ targets = [directPath];
140
+ }
141
+ if (targets.length === 0) {
142
+ throw new Error('Pi edit path is missing');
143
+ }
144
+ const strings = editStrings(input);
145
+ const inputs: Record<string, unknown>[] = [];
146
+ for (const filePath of targets) {
147
+ inputs.push({ file_path: filePath, ...strings });
148
+ }
149
+ return inputs;
150
+ }
151
+ return [input];
152
+ };
153
+
154
+ const textFromContent = (content: unknown): string => {
155
+ if (typeof content === 'string') {
156
+ return content;
157
+ }
158
+ if (!Array.isArray(content)) {
159
+ return '';
160
+ }
161
+ const texts: string[] = [];
162
+ for (const item of content) {
163
+ if (isRecord(item) && item['type'] === 'text' && typeof item['text'] === 'string') {
164
+ texts.push(item['text']);
165
+ }
166
+ }
167
+ return texts.join('\n');
168
+ };
169
+
170
+ const normalizedToolResponse = (event: PiToolResultEvent): Record<string, unknown> => {
171
+ const text = textFromContent(event.content);
172
+ if (event.toolName === 'bash' || event.toolName === 'powershell') {
173
+ return { stdout: text, stderr: event.isError ? text : '', interrupted: event.isError };
174
+ }
175
+ if (event.toolName === 'read') {
176
+ return { content: text };
177
+ }
178
+ return { content: text, details: event.details, isError: event.isError };
179
+ };
180
+
181
+ const hookInputs = (
182
+ event: PiToolCallEvent | PiToolResultEvent,
183
+ hookEventName: 'PostToolUse' | 'PreToolUse',
184
+ cwd: string,
185
+ ): readonly Record<string, unknown>[] => {
186
+ const base = {
187
+ cwd,
188
+ hook_event_name: hookEventName,
189
+ tool_name: event.toolName,
190
+ tool_use_id: event.toolCallId,
191
+ };
192
+ if ('content' in event) {
193
+ return [{ ...base, tool_input: event.input, tool_response: normalizedToolResponse(event) }];
194
+ }
195
+ const inputs: Record<string, unknown>[] = [];
196
+ for (const toolInput of normalizedToolInputs(event)) {
197
+ inputs.push({ ...base, tool_input: toolInput });
198
+ }
199
+ return inputs;
200
+ };
201
+
202
+ const readStream = async (stream: Readable): Promise<string> => {
203
+ stream.setEncoding('utf8');
204
+ let output = '';
205
+ for await (const chunk of stream) {
206
+ output += String(chunk);
207
+ }
208
+ return output;
209
+ };
210
+
211
+ const runTripwire = async (hookPath: string, input: unknown): Promise<HookResult> => {
212
+ const child = spawn(hookPath, ['--tripwire-hook'], { stdio: ['pipe', 'pipe', 'pipe'] });
213
+ const state = { timedOut: false };
214
+ const timeout = setTimeout(() => {
215
+ state.timedOut = true;
216
+ child.kill('SIGKILL');
217
+ }, TIMEOUT_MS);
218
+ child.stdin.end(JSON.stringify(input));
219
+ try {
220
+ const [closed, stdout, stderr] = await Promise.all([
221
+ once(child, 'close'),
222
+ readStream(child.stdout),
223
+ readStream(child.stderr),
224
+ ]);
225
+ if (state.timedOut) {
226
+ throw new Error(`Tripwire exceeded its ${TIMEOUT_MS}ms timeout`);
227
+ }
228
+ return { exitCode: typeof closed[0] === 'number' ? closed[0] : 1, stderr, stdout };
229
+ } finally {
230
+ clearTimeout(timeout);
231
+ }
232
+ };
233
+
234
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
235
+ typeof value === 'object' && value !== null && !Array.isArray(value);
236
+
237
+ const stringField = (value: Record<string, unknown>, key: string): string | undefined => {
238
+ const field = value[key];
239
+ return typeof field === 'string' ? field : undefined;
240
+ };
241
+
242
+ const tripwirePiDenialReason = (result: HookResult): string | undefined => {
243
+ if (result.exitCode !== 0) {
244
+ return result.stderr.trim() || `Tripwire exited ${result.exitCode}`;
245
+ }
246
+ if (result.stdout.trim().length === 0) {
247
+ return 'Tripwire returned no decision';
248
+ }
249
+ try {
250
+ const value: unknown = JSON.parse(result.stdout);
251
+ if (!isRecord(value)) {
252
+ return 'Tripwire returned invalid JSON';
253
+ }
254
+ const specific = isRecord(value['hookSpecificOutput'])
255
+ ? value['hookSpecificOutput']
256
+ : undefined;
257
+ const decision =
258
+ (specific === undefined ? undefined : stringField(specific, 'permissionDecision')) ??
259
+ stringField(value, 'permissionDecision') ??
260
+ stringField(value, 'permission');
261
+ const reason =
262
+ (specific === undefined ? undefined : stringField(specific, 'permissionDecisionReason')) ??
263
+ stringField(value, 'permissionDecisionReason') ??
264
+ stringField(value, 'reason') ??
265
+ 'Blocked by Tripwire';
266
+ if (decision === 'deny' || decision === 'ask') {
267
+ return reason;
268
+ }
269
+ if (stringField(value, 'decision') === 'block' || value['continue'] === false) {
270
+ return reason;
271
+ }
272
+ return value['continue'] === true || decision === 'allow'
273
+ ? undefined
274
+ : 'Tripwire returned an unrecognized response';
275
+ } catch {
276
+ return 'Tripwire returned invalid JSON';
277
+ }
278
+ };
279
+
280
+ const createTripwirePiExtension =
281
+ (hookPath: string, processRunner: TripwireProcessRunner = runTripwire) =>
282
+ (pi: TripwirePiExtensionApi): void => {
283
+ pi.on('tool_call', async (event, context) => {
284
+ try {
285
+ const inputs = hookInputs(event, 'PreToolUse', context.cwd);
286
+ const input = inputs.length === 1 ? inputs[0] : inputs;
287
+ if (input === undefined) {
288
+ throw new Error('Tripwire could not normalize the Pi tool call');
289
+ }
290
+ const reason = tripwirePiDenialReason(await processRunner(hookPath, input));
291
+ if (reason !== undefined) {
292
+ return { block: true, reason };
293
+ }
294
+ return {};
295
+ } catch (error) {
296
+ return {
297
+ block: true,
298
+ reason: `Tripwire failed closed: ${error instanceof Error ? error.message : String(error)}`,
299
+ };
300
+ }
301
+ });
302
+
303
+ pi.on('tool_result', async (event, context) => {
304
+ try {
305
+ const [input] = hookInputs(event, 'PostToolUse', context.cwd);
306
+ if (input === undefined) {
307
+ throw new Error('Tripwire could not normalize the Pi tool result');
308
+ }
309
+ const reason = tripwirePiDenialReason(await processRunner(hookPath, input));
310
+ if (reason !== undefined) {
311
+ context.ui.notify(`Tripwire stopped the session: ${reason}`, 'error');
312
+ context.abort();
313
+ }
314
+ } catch (error) {
315
+ context.ui.notify(
316
+ `Tripwire failed closed after tool use: ${error instanceof Error ? error.message : String(error)}`,
317
+ 'error',
318
+ );
319
+ context.abort();
320
+ }
321
+ });
322
+ };
323
+
324
+ export default createTripwirePiExtension(shippedHookPath);
325
+
326
+ export {
327
+ createTripwirePiExtension,
328
+ hookInputs,
329
+ resolveShippedHookPath,
330
+ tripwirePiDenialReason,
331
+ type HookResult,
332
+ type PiExtensionContext,
333
+ type PiToolCallEvent,
334
+ type PiToolResultEvent,
335
+ type TripwirePiExtensionApi,
336
+ type TripwireProcessRunner,
337
+ };
@@ -1,4 +1,4 @@
1
- import { type Segment, hasBypass } from '../lib/bash';
1
+ import { type Segment, UNSUPPORTED_SHELL_HEAD, hasBypass } from '../lib/bash';
2
2
  import { type Decision, allow, ask, deny, merge } from '../lib/decision';
3
3
 
4
4
  interface Spec {
@@ -12,10 +12,19 @@ interface Spec {
12
12
 
13
13
  const argsJoined = (seg: Segment): string => seg.tokens.slice(1).join(' ');
14
14
 
15
- const flagPresent = (seg: Segment, ...flags: readonly string[]): boolean =>
16
- seg.flags.some((f) => flags.includes(f));
15
+ const flagPresent = (seg: Segment, ...flags: readonly string[]): boolean => {
16
+ const segmentFlags = new Set(seg.flags);
17
+ return flags.some((flag) => segmentFlags.has(flag));
18
+ };
17
19
 
18
20
  const SPECS: readonly Spec[] = [
21
+ {
22
+ rule: 'unsupported-shell-structure',
23
+ action: 'deny',
24
+ message:
25
+ 'Tripwire cannot inspect every executable branch in this shell structure. Rewrite it as simple commands joined with `;`, `&&`, or `||` so each command can be checked.',
26
+ match: (seg) => seg.head === UNSUPPORTED_SHELL_HEAD,
27
+ },
19
28
  // ── catastrophic deletions ────────────────────────────────────────────
20
29
  {
21
30
  rule: 'rm-rf-root',
@@ -342,6 +351,7 @@ const SPECS: readonly Spec[] = [
342
351
  // Have no legitimate prompt-line override. If the user genuinely needs
343
352
  // One of these to run, they should do it in a terminal themselves.
344
353
  const UNBYPASSABLE_RULES: ReadonlySet<string> = new Set([
354
+ 'unsupported-shell-structure',
345
355
  // Catastrophic / irreversible
346
356
  'rm-rf-root',
347
357
  'rm-rf-home',