@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.
Files changed (57) hide show
  1. package/README.md +16 -14
  2. package/dist/index.js +35 -0
  3. package/dist/tripwire-cli.js +3 -0
  4. package/dist/tripwire-hook.js +3 -0
  5. package/dist/tripwire-pi.js +6 -4
  6. package/dist/tripwire.js +141 -0
  7. package/dist/types/dispatch.d.ts +18 -0
  8. package/dist/types/index.d.ts +6 -0
  9. package/dist/types/lib/bash.d.ts +27 -0
  10. package/dist/types/lib/config.d.ts +110 -0
  11. package/dist/types/lib/cursor.d.ts +16 -0
  12. package/dist/types/lib/decision.d.ts +13 -0
  13. package/dist/types/lib/diff.d.ts +3 -0
  14. package/dist/types/lib/event.d.ts +45 -0
  15. package/dist/types/lib/log.d.ts +2 -0
  16. package/dist/types/lib/secrets.d.ts +41 -0
  17. package/dist/types/rules/bash-deny.d.ts +5 -0
  18. package/dist/types/rules/bash-git.d.ts +5 -0
  19. package/dist/types/rules/bash-network-install.d.ts +4 -0
  20. package/dist/types/rules/bash-redirect.d.ts +4 -0
  21. package/dist/types/rules/bash-scoped-rm.d.ts +5 -0
  22. package/dist/types/rules/bash-tar-explosion.d.ts +4 -0
  23. package/dist/types/rules/config-custom.d.ts +6 -0
  24. package/dist/types/rules/lazy-code.d.ts +4 -0
  25. package/dist/types/rules/path-protect.d.ts +12 -0
  26. package/dist/types/rules/post-secret-scrub.d.ts +12 -0
  27. package/dist/types/rules/read-protect.d.ts +4 -0
  28. package/dist/types/rules/tool-policy.d.ts +5 -0
  29. package/package.json +16 -13
  30. package/dist/tripwire +0 -0
  31. package/scripts/tripwire-cli +0 -12
  32. package/src/cli.ts +0 -271
  33. package/src/dispatch.ts +0 -562
  34. package/src/index.ts +0 -6
  35. package/src/lib/bash.ts +0 -1328
  36. package/src/lib/config.ts +0 -174
  37. package/src/lib/cursor.ts +0 -336
  38. package/src/lib/decision.ts +0 -36
  39. package/src/lib/diff.ts +0 -29
  40. package/src/lib/event.ts +0 -105
  41. package/src/lib/install.ts +0 -610
  42. package/src/lib/log.ts +0 -23
  43. package/src/lib/secrets.ts +0 -184
  44. package/src/main.ts +0 -31
  45. package/src/pi-extension.ts +0 -337
  46. package/src/rules/bash-deny.ts +0 -404
  47. package/src/rules/bash-git.ts +0 -590
  48. package/src/rules/bash-network-install.ts +0 -75
  49. package/src/rules/bash-redirect.ts +0 -91
  50. package/src/rules/bash-scoped-rm.ts +0 -84
  51. package/src/rules/bash-tar-explosion.ts +0 -77
  52. package/src/rules/config-custom.ts +0 -166
  53. package/src/rules/lazy-code.ts +0 -95
  54. package/src/rules/path-protect.ts +0 -131
  55. package/src/rules/post-secret-scrub.ts +0 -49
  56. package/src/rules/read-protect.ts +0 -57
  57. package/src/rules/tool-policy.ts +0 -54
@@ -1,184 +0,0 @@
1
- // Betterleaks supplies the maintained secret-rule set. The stdin command keeps
2
- // scanned content in memory and writes its JSON report to stdout.
3
-
4
- import { spawnSync } from 'node:child_process';
5
-
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;
25
- readonly hits: readonly { readonly rule: string; readonly count: number }[];
26
- readonly redacted: string;
27
- }
28
-
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
- };
99
-
100
- const summarizeHits = (
101
- findings: readonly BetterleaksFinding[],
102
- ): readonly { rule: string; count: number }[] => {
103
- const counts = new Map<string, number>();
104
- for (const finding of findings) {
105
- counts.set(finding.RuleID, (counts.get(finding.RuleID) ?? 0) + 1);
106
- }
107
- return [...counts.entries()].map(([rule, count]) => ({ rule, count }));
108
- };
109
-
110
- const escapeRegExp = (value: string): string =>
111
- value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
112
-
113
- // Replace every found secret in the original input with a tagged redaction.
114
- const redactWith = (input: string, findings: readonly BetterleaksFinding[]): string => {
115
- let output = input;
116
- // Sort by length descending so shorter matches that are substrings of
117
- // Longer ones do not fire first and break the longer match.
118
- const sorted = [...findings].toSorted((a, b) => b.Secret.length - a.Secret.length);
119
- for (const finding of sorted) {
120
- if (finding.Secret === '') {
121
- continue;
122
- }
123
- output = output.replaceAll(finding.Secret, `[REDACTED:${finding.RuleID}]`);
124
- }
125
- return output;
126
- };
127
-
128
- const scanAndRedact = (
129
- input: string,
130
- config: SecretScannerConfig,
131
- runner: ScannerRunner = defaultScannerRunner,
132
- ): ScanResult => {
133
- if (input.length === 0) {
134
- return { ok: true, hits: [], redacted: input };
135
- }
136
-
137
- let processResult: ScannerProcessResult;
138
- try {
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' };
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) };
170
- };
171
-
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
- };
184
- export { escapeRegExp, scanAndRedact };
package/src/main.ts DELETED
@@ -1,31 +0,0 @@
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
- }
@@ -1,337 +0,0 @@
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
- };