@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/LICENSE +21 -0
- package/README.md +152 -141
- package/dist/tripwire +0 -0
- package/dist/tripwire-pi.js +4 -0
- package/package.json +50 -22
- 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/lib/log.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
|
|
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
|
}
|
package/src/lib/secrets.ts
CHANGED
|
@@ -1,121 +1,184 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
counts.set(
|
|
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 = (
|
|
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
|
|
115
|
+
let output = input;
|
|
53
116
|
// Sort by length descending so shorter matches that are substrings of
|
|
54
|
-
// Longer ones
|
|
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
|
|
57
|
-
if (
|
|
119
|
+
for (const finding of sorted) {
|
|
120
|
+
if (finding.Secret === '') {
|
|
58
121
|
continue;
|
|
59
122
|
}
|
|
60
|
-
|
|
123
|
+
output = output.replaceAll(finding.Secret, `[REDACTED:${finding.RuleID}]`);
|
|
61
124
|
}
|
|
62
|
-
return
|
|
125
|
+
return output;
|
|
63
126
|
};
|
|
64
127
|
|
|
65
|
-
const scanAndRedact = (
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
const reportPath = join(dir, 'report.json');
|
|
136
|
+
|
|
137
|
+
let processResult: ScannerProcessResult;
|
|
72
138
|
try {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
119
|
-
// Redacted output without
|
|
120
|
-
export type {
|
|
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
|
+
};
|
package/src/rules/bash-deny.ts
CHANGED
|
@@ -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
|
|
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',
|