@coo-quack/sensitive-canary 0.6.0 → 0.8.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +830 -0
- package/README.md +269 -43
- package/dist/lib/bash-commands.js +405 -0
- package/dist/lib/command-tables.js +462 -0
- package/dist/lib/default-config.json +570 -0
- package/dist/lib/encoding.js +123 -0
- package/dist/lib/fail-closed.js +31 -0
- package/dist/lib/inspector.js +0 -0
- package/dist/lib/rules.js +399 -0
- package/dist/lib/shapes.js +161 -0
- package/dist/lib/shell.js +436 -0
- package/dist/lib/tool-inputs.js +217 -0
- package/dist/lib/transcript.js +115 -0
- package/dist/lib/validators.js +435 -0
- package/dist/pre-tool-use-hook.js +773 -0
- package/dist/user-prompt-submit-hook.js +105 -0
- package/hooks/hooks.json +1 -1
- package/package.json +25 -11
- package/src/lib/bash-commands.ts +455 -0
- package/src/lib/command-tables.ts +518 -0
- package/src/lib/default-config.json +570 -0
- package/src/lib/encoding.ts +135 -0
- package/src/lib/fail-closed.ts +36 -0
- package/src/lib/inspector.ts +0 -0
- package/src/lib/rules.ts +482 -267
- package/src/lib/shapes.ts +175 -0
- package/src/lib/shell.ts +512 -0
- package/src/lib/tool-inputs.ts +235 -0
- package/src/lib/transcript.ts +142 -0
- package/src/lib/validators.ts +435 -0
- package/src/pre-tool-use-hook.ts +774 -198
- package/src/user-prompt-submit-hook.ts +60 -18
- package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
- package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
- package/src/lib/__tests__/inspector.test.ts +0 -281
- package/src/lib/__tests__/rules.test.ts +0 -448
|
@@ -1,779 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
6
|
-
|
|
7
|
-
const HOOK = new URL("../pre-tool-use-hook.ts", import.meta.url).pathname;
|
|
8
|
-
const NODE_FLAGS = ["--experimental-strip-types"];
|
|
9
|
-
|
|
10
|
-
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
11
|
-
|
|
12
|
-
function parseHookOutput(stdout: string) {
|
|
13
|
-
try {
|
|
14
|
-
const parsed = JSON.parse(stdout) as { decision?: string; reason?: string };
|
|
15
|
-
return { decision: parsed.decision ?? null, reason: parsed.reason ?? null };
|
|
16
|
-
} catch {
|
|
17
|
-
return { decision: null, reason: null };
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
let transcriptSeq = 0;
|
|
22
|
-
|
|
23
|
-
function writeTranscript(userMessages: string[]): string {
|
|
24
|
-
const lines = userMessages.map((content) =>
|
|
25
|
-
JSON.stringify({
|
|
26
|
-
type: "user",
|
|
27
|
-
message: { role: "user", content },
|
|
28
|
-
}),
|
|
29
|
-
);
|
|
30
|
-
const p = join(tmpDir, `transcript-${++transcriptSeq}.jsonl`);
|
|
31
|
-
writeFileSync(p, lines.join("\n"), "utf8");
|
|
32
|
-
return p;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function writeTranscriptWithToolResults(
|
|
36
|
-
entries: Array<{ text: string } | { toolResult: string }>,
|
|
37
|
-
): string {
|
|
38
|
-
const lines = entries.map((entry) => {
|
|
39
|
-
if ("text" in entry) {
|
|
40
|
-
return JSON.stringify({
|
|
41
|
-
type: "user",
|
|
42
|
-
message: { role: "user", content: entry.text },
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
return JSON.stringify({
|
|
46
|
-
type: "user",
|
|
47
|
-
message: {
|
|
48
|
-
role: "user",
|
|
49
|
-
content: [{ type: "tool_result", content: entry.toolResult }],
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
const p = join(tmpDir, `transcript-${++transcriptSeq}.jsonl`);
|
|
54
|
-
writeFileSync(p, lines.join("\n"), "utf8");
|
|
55
|
-
return p;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function runHook(
|
|
59
|
-
toolName: string,
|
|
60
|
-
filePath: string,
|
|
61
|
-
opts?: { env?: Record<string, string>; transcriptPath?: string },
|
|
62
|
-
) {
|
|
63
|
-
const input = JSON.stringify({
|
|
64
|
-
transcript_path: opts?.transcriptPath,
|
|
65
|
-
tool_name: toolName,
|
|
66
|
-
tool_input: { file_path: filePath },
|
|
67
|
-
});
|
|
68
|
-
const result = spawnSync("node", [...NODE_FLAGS, HOOK], {
|
|
69
|
-
input,
|
|
70
|
-
encoding: "utf8",
|
|
71
|
-
env: { ...process.env, ...opts?.env },
|
|
72
|
-
});
|
|
73
|
-
const { decision, reason } = parseHookOutput(result.stdout);
|
|
74
|
-
return {
|
|
75
|
-
exitCode: result.status ?? -1,
|
|
76
|
-
stdout: result.stdout,
|
|
77
|
-
stderr: result.stderr,
|
|
78
|
-
decision,
|
|
79
|
-
reason,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function runBashHook(
|
|
84
|
-
command: string,
|
|
85
|
-
opts?: { env?: Record<string, string>; transcriptPath?: string },
|
|
86
|
-
) {
|
|
87
|
-
const input = JSON.stringify({
|
|
88
|
-
transcript_path: opts?.transcriptPath,
|
|
89
|
-
tool_name: "Bash",
|
|
90
|
-
tool_input: { command },
|
|
91
|
-
});
|
|
92
|
-
const result = spawnSync("node", [...NODE_FLAGS, HOOK], {
|
|
93
|
-
input,
|
|
94
|
-
encoding: "utf8",
|
|
95
|
-
env: { ...process.env, ...opts?.env },
|
|
96
|
-
});
|
|
97
|
-
const { decision, reason } = parseHookOutput(result.stdout);
|
|
98
|
-
return {
|
|
99
|
-
exitCode: result.status ?? -1,
|
|
100
|
-
stdout: result.stdout,
|
|
101
|
-
stderr: result.stderr,
|
|
102
|
-
decision,
|
|
103
|
-
reason,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ── temp directory for fixture files ─────────────────────────────────────────
|
|
108
|
-
|
|
109
|
-
let tmpDir: string;
|
|
110
|
-
|
|
111
|
-
beforeAll(() => {
|
|
112
|
-
tmpDir = mkdtempSync(join(tmpdir(), "sensitive-canary-test-"));
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
afterAll(() => {
|
|
116
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
function writeFixture(name: string, content: string) {
|
|
120
|
-
const p = join(tmpDir, name);
|
|
121
|
-
writeFileSync(p, content, "utf8");
|
|
122
|
-
return p;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ── non-Read/non-Bash tools ───────────────────────────────────────────────────
|
|
126
|
-
|
|
127
|
-
describe("pre-tool-use-hook — non-Read/non-Bash tools", () => {
|
|
128
|
-
it("always allows Write", () => {
|
|
129
|
-
const { exitCode } = runHook("Write", "/some/path");
|
|
130
|
-
expect(exitCode).toBe(0);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("always allows Edit", () => {
|
|
134
|
-
const { exitCode } = runHook("Edit", "/some/path");
|
|
135
|
-
expect(exitCode).toBe(0);
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
// ── .env / .env.* — secret name block ─────────────────────────────────────────
|
|
140
|
-
|
|
141
|
-
describe("pre-tool-use-hook — .env/.env.* name block (secret category)", () => {
|
|
142
|
-
it("blocks .env regardless of content", () => {
|
|
143
|
-
const p = writeFixture(".env", "DEBUG=true\nNODE_ENV=development\n");
|
|
144
|
-
const { exitCode, decision } = runHook("Read", p);
|
|
145
|
-
expect(exitCode).toBe(2);
|
|
146
|
-
expect(decision).toBe("block");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("blocks .env.local regardless of content", () => {
|
|
150
|
-
const p = writeFixture(".env.local", "DEBUG=true\n");
|
|
151
|
-
const { exitCode, decision } = runHook("Read", p);
|
|
152
|
-
expect(exitCode).toBe(2);
|
|
153
|
-
expect(decision).toBe("block");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("blocks .env.production regardless of content", () => {
|
|
157
|
-
const p = writeFixture(".env.production", "DEBUG=true\n");
|
|
158
|
-
const { exitCode, decision } = runHook("Read", p);
|
|
159
|
-
expect(exitCode).toBe(2);
|
|
160
|
-
expect(decision).toBe("block");
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("includes [allow-secret], [allow-pii], and [allow-all] hints in reason", () => {
|
|
164
|
-
const p = writeFixture(".env.hints", "KEY=value");
|
|
165
|
-
const { reason } = runHook("Read", p);
|
|
166
|
-
expect(reason).toContain("[allow-secret]");
|
|
167
|
-
expect(reason).toContain("[allow-pii]");
|
|
168
|
-
expect(reason).toContain("[allow-all]");
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("includes a bird emoji in .env block reason", () => {
|
|
172
|
-
const p = writeFixture(".env.bird", "KEY=value");
|
|
173
|
-
const { reason } = runHook("Read", p);
|
|
174
|
-
expect(reason).toMatch(/[🐦🐧🐤🐔]/u);
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// ── clean file ────────────────────────────────────────────────────────────────
|
|
179
|
-
|
|
180
|
-
describe("pre-tool-use-hook — clean file", () => {
|
|
181
|
-
it("allows a file with no sensitive data", () => {
|
|
182
|
-
const p = writeFixture("clean.txt", "hello world");
|
|
183
|
-
const { exitCode } = runHook("Read", p);
|
|
184
|
-
expect(exitCode).toBe(0);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("allows a non-existent file (let Node handle the error)", () => {
|
|
188
|
-
const { exitCode } = runHook("Read", "/tmp/does-not-exist-xyz.txt");
|
|
189
|
-
expect(exitCode).toBe(0);
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
// ── secrets/PII in file contents ──────────────────────────────────────────────
|
|
194
|
-
|
|
195
|
-
describe("pre-tool-use-hook — sensitive content blocking", () => {
|
|
196
|
-
it("blocks a file containing an AWS key", () => {
|
|
197
|
-
const p = writeFixture("config.txt", "AWS_KEY=AKIAIOSFODNN7EXAMPLE\n");
|
|
198
|
-
const { exitCode, decision, reason } = runHook("Read", p);
|
|
199
|
-
expect(exitCode).toBe(2);
|
|
200
|
-
expect(decision).toBe("block");
|
|
201
|
-
expect(reason).toContain("aws-access-key");
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("blocks a file containing an email address", () => {
|
|
205
|
-
const p = writeFixture("contacts.txt", "Email: user@example.com\n");
|
|
206
|
-
const { exitCode, decision } = runHook("Read", p);
|
|
207
|
-
expect(exitCode).toBe(2);
|
|
208
|
-
expect(decision).toBe("block");
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("blocks a file containing a private IP", () => {
|
|
212
|
-
const p = writeFixture("infra.txt", "server: 192.168.1.100\n");
|
|
213
|
-
const { exitCode, decision } = runHook("Read", p);
|
|
214
|
-
expect(exitCode).toBe(2);
|
|
215
|
-
expect(decision).toBe("block");
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it("includes [allow-secret] and [allow-all] hints in reason for a secret", () => {
|
|
219
|
-
const p = writeFixture("key.txt", "key=AKIAIOSFODNN7EXAMPLE\n");
|
|
220
|
-
const { reason } = runHook("Read", p);
|
|
221
|
-
expect(reason).toContain("[allow-secret]");
|
|
222
|
-
expect(reason).toContain("[allow-all]");
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it("includes a bird emoji in the reason", () => {
|
|
226
|
-
const p = writeFixture("bird.txt", "key=AKIAIOSFODNN7EXAMPLE\n");
|
|
227
|
-
const { reason } = runHook("Read", p);
|
|
228
|
-
expect(reason).toMatch(/[🐦🐧🐤🐔]/u);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("includes [allow-pii] and [allow-all] hints in reason for PII", () => {
|
|
232
|
-
const p = writeFixture("pii.txt", "Email: user@example.com\n");
|
|
233
|
-
const { reason } = runHook("Read", p);
|
|
234
|
-
expect(reason).toContain("[allow-pii]");
|
|
235
|
-
expect(reason).toContain("[allow-all]");
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("deduplicates repeated secrets — finding line appears only once", () => {
|
|
239
|
-
const p = writeFixture(
|
|
240
|
-
"dup.txt",
|
|
241
|
-
"A=AKIAIOSFODNN7EXAMPLE\nB=AKIAIOSFODNN7EXAMPLE\n",
|
|
242
|
-
);
|
|
243
|
-
const { reason } = runHook("Read", p);
|
|
244
|
-
const count = (reason ?? "").split("[Secret]").length - 1;
|
|
245
|
-
expect(count).toBe(1);
|
|
246
|
-
});
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
// ── binary file handling ─────────────────────────────────────────────────────
|
|
250
|
-
|
|
251
|
-
describe("pre-tool-use-hook — binary file handling", () => {
|
|
252
|
-
it("blocks when a secret appears before the first NUL byte", () => {
|
|
253
|
-
const content = Buffer.concat([
|
|
254
|
-
Buffer.from("key=AKIAIOSFODNN7EXAMPLE\n"),
|
|
255
|
-
Buffer.from([0x00]),
|
|
256
|
-
Buffer.from("binary data"),
|
|
257
|
-
]);
|
|
258
|
-
const p = join(tmpDir, "binary-secret-before-nul.bin");
|
|
259
|
-
writeFileSync(p, content);
|
|
260
|
-
const { exitCode, decision } = runHook("Read", p);
|
|
261
|
-
expect(exitCode).toBe(2);
|
|
262
|
-
expect(decision).toBe("block");
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
it("allows a binary file when no secret appears before the first NUL byte", () => {
|
|
266
|
-
const content = Buffer.concat([
|
|
267
|
-
Buffer.from("clean text\n"),
|
|
268
|
-
Buffer.from([0x00]),
|
|
269
|
-
Buffer.from("AKIAIOSFODNN7EXAMPLE"),
|
|
270
|
-
]);
|
|
271
|
-
const p = join(tmpDir, "binary-secret-after-nul.bin");
|
|
272
|
-
writeFileSync(p, content);
|
|
273
|
-
const { exitCode } = runHook("Read", p);
|
|
274
|
-
expect(exitCode).toBe(0);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it("allows a binary file that starts with NUL", () => {
|
|
278
|
-
const content = Buffer.concat([
|
|
279
|
-
Buffer.from([0x00]),
|
|
280
|
-
Buffer.from("AKIAIOSFODNN7EXAMPLE"),
|
|
281
|
-
]);
|
|
282
|
-
const p = join(tmpDir, "binary-nul-start.bin");
|
|
283
|
-
writeFileSync(p, content);
|
|
284
|
-
const { exitCode } = runHook("Read", p);
|
|
285
|
-
expect(exitCode).toBe(0);
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
// ── transcript tail read ────────────────────────────────────────────────────
|
|
290
|
-
|
|
291
|
-
describe("pre-tool-use-hook — transcript tail read (64 KB)", () => {
|
|
292
|
-
it("[allow-all] in a large transcript (>64KB) is respected when near the end", () => {
|
|
293
|
-
// Build a transcript larger than 64KB with the allow tag in the last message
|
|
294
|
-
const filler = JSON.stringify({
|
|
295
|
-
type: "user",
|
|
296
|
-
message: { role: "user", content: "x".repeat(1024) },
|
|
297
|
-
});
|
|
298
|
-
const fillerLines = Array.from({ length: 70 }, () => filler).join("\n");
|
|
299
|
-
const allowLine = JSON.stringify({
|
|
300
|
-
type: "user",
|
|
301
|
-
message: { role: "user", content: "[allow-all] read everything" },
|
|
302
|
-
});
|
|
303
|
-
const transcriptContent = `${fillerLines}\n${allowLine}\n`;
|
|
304
|
-
const tp = join(tmpDir, "large-transcript.jsonl");
|
|
305
|
-
writeFileSync(tp, transcriptContent, "utf8");
|
|
306
|
-
|
|
307
|
-
const p = writeFixture(
|
|
308
|
-
"large-transcript-test.txt",
|
|
309
|
-
"key=AKIAIOSFODNN7EXAMPLE\n",
|
|
310
|
-
);
|
|
311
|
-
const { exitCode } = runHook("Read", p, { transcriptPath: tp });
|
|
312
|
-
expect(exitCode).toBe(0);
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
// ── Bash tool — env var expansion ────────────────────────────────────────────
|
|
317
|
-
|
|
318
|
-
describe("pre-tool-use-hook — Bash tool (env var expansion)", () => {
|
|
319
|
-
it("blocks echo $TOKEN when TOKEN contains an AWS key", () => {
|
|
320
|
-
const { exitCode, decision } = runBashHook("echo $TOKEN", {
|
|
321
|
-
env: { TOKEN: "AKIAIOSFODNN7EXAMPLE" },
|
|
322
|
-
});
|
|
323
|
-
expect(exitCode).toBe(2);
|
|
324
|
-
expect(decision).toBe("block");
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
it("includes the variable name and rule in reason", () => {
|
|
328
|
-
const { reason } = runBashHook("echo $MY_SECRET", {
|
|
329
|
-
env: { MY_SECRET: "AKIAIOSFODNN7EXAMPLE" },
|
|
330
|
-
});
|
|
331
|
-
expect(reason).toContain("$MY_SECRET");
|
|
332
|
-
expect(reason).toContain("aws-access-key");
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — testing ${VAR} bash syntax
|
|
336
|
-
it("blocks ${TOKEN} brace syntax", () => {
|
|
337
|
-
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — the string is passed as a bash command
|
|
338
|
-
const { exitCode } = runBashHook("curl -H 'Auth: ${API_TOKEN}'", {
|
|
339
|
-
env: { API_TOKEN: "AKIAIOSFODNN7EXAMPLE" },
|
|
340
|
-
});
|
|
341
|
-
expect(exitCode).toBe(2);
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it("allows echo $TOKEN when TOKEN value is clean", () => {
|
|
345
|
-
const { exitCode } = runBashHook("echo $TOKEN", {
|
|
346
|
-
env: { TOKEN: "nothing_sensitive_here" },
|
|
347
|
-
});
|
|
348
|
-
expect(exitCode).toBe(0);
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it("allows echo $TOKEN when TOKEN is unset", () => {
|
|
352
|
-
const { exitCode } = runBashHook("echo $TOKEN");
|
|
353
|
-
expect(exitCode).toBe(0);
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
it("ignores special shell variables like $? and $0", () => {
|
|
357
|
-
const { exitCode } = runBashHook("exit $?; echo $0");
|
|
358
|
-
expect(exitCode).toBe(0);
|
|
359
|
-
});
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
// ── Bash tool — command string scanning ──────────────────────────────────────
|
|
363
|
-
|
|
364
|
-
describe("pre-tool-use-hook — Bash tool (command string)", () => {
|
|
365
|
-
it("allows a harmless Bash command", () => {
|
|
366
|
-
const { exitCode } = runBashHook("ls -la /tmp");
|
|
367
|
-
expect(exitCode).toBe(0);
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
it("blocks a Bash command containing an AWS key (e.g. echo)", () => {
|
|
371
|
-
const { exitCode, decision } = runBashHook("echo AKIAIOSFODNN7EXAMPLE");
|
|
372
|
-
expect(exitCode).toBe(2);
|
|
373
|
-
expect(decision).toBe("block");
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
it("includes aws-access-key in reason for inline secret", () => {
|
|
377
|
-
const { reason } = runBashHook("echo AKIAIOSFODNN7EXAMPLE");
|
|
378
|
-
expect(reason).toContain("aws-access-key");
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it("includes a bird emoji in the reason for Bash block", () => {
|
|
382
|
-
const { reason } = runBashHook("echo AKIAIOSFODNN7EXAMPLE");
|
|
383
|
-
expect(reason).toMatch(/[🐦🐧🐤🐔]/u);
|
|
384
|
-
});
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
// ── Bash tool — file-reading command blocking ─────────────────────────────────
|
|
388
|
-
|
|
389
|
-
describe("pre-tool-use-hook — Bash tool (file-reading commands)", () => {
|
|
390
|
-
it.each(["cat", "head", "tail", "less", "more", "bat", "nl"])(
|
|
391
|
-
"blocks %s on a file with secrets",
|
|
392
|
-
(cmd) => {
|
|
393
|
-
const p = writeFixture(
|
|
394
|
-
`creds-${cmd}.txt`,
|
|
395
|
-
"AWS_KEY=AKIAIOSFODNN7EXAMPLE\n",
|
|
396
|
-
);
|
|
397
|
-
const { exitCode, decision } = runBashHook(`${cmd} ${p}`);
|
|
398
|
-
expect(exitCode).toBe(2);
|
|
399
|
-
expect(decision).toBe("block");
|
|
400
|
-
},
|
|
401
|
-
);
|
|
402
|
-
|
|
403
|
-
it("blocks cat on a file with PII", () => {
|
|
404
|
-
const p = writeFixture("contacts-bash.txt", "Email: user@example.com\n");
|
|
405
|
-
const { exitCode, decision } = runBashHook(`cat ${p}`);
|
|
406
|
-
expect(exitCode).toBe(2);
|
|
407
|
-
expect(decision).toBe("block");
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
it("allows cat on a clean file", () => {
|
|
411
|
-
const p = writeFixture("clean-bash.txt", "nothing sensitive here\n");
|
|
412
|
-
const { exitCode } = runBashHook(`cat ${p}`);
|
|
413
|
-
expect(exitCode).toBe(0);
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
it("allows cat on a non-existent file (let shell handle the error)", () => {
|
|
417
|
-
const { exitCode } = runBashHook("cat /tmp/does-not-exist-xyz.txt");
|
|
418
|
-
expect(exitCode).toBe(0);
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
it("blocks cat in a compound command (pipe) on a file with secrets", () => {
|
|
422
|
-
const p = writeFixture("pipe-secret.txt", "AWS_KEY=AKIAIOSFODNN7EXAMPLE\n");
|
|
423
|
-
const { exitCode, decision } = runBashHook(`cat ${p} | grep KEY`);
|
|
424
|
-
expect(exitCode).toBe(2);
|
|
425
|
-
expect(decision).toBe("block");
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
it("allows cat in a compound command (pipe) on a clean file", () => {
|
|
429
|
-
const p = writeFixture("pipe-clean.txt", "nothing sensitive here\n");
|
|
430
|
-
const { exitCode } = runBashHook(`cat ${p} | grep text`);
|
|
431
|
-
expect(exitCode).toBe(0);
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
it("blocks cat on a .env.* file by name", () => {
|
|
435
|
-
const p = writeFixture(".env.bash-name", "DEBUG=true\n");
|
|
436
|
-
const { exitCode, decision } = runBashHook(`cat ${p}`);
|
|
437
|
-
expect(exitCode).toBe(2);
|
|
438
|
-
expect(decision).toBe("block");
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
it("[allow-pii] bypasses cat on a .env.* file", () => {
|
|
442
|
-
const transcript = writeTranscript(["[allow-pii] show me the env"]);
|
|
443
|
-
const p = writeFixture(".env.bash-pii", "DEBUG=true\n");
|
|
444
|
-
const { exitCode } = runBashHook(`cat ${p}`, {
|
|
445
|
-
transcriptPath: transcript,
|
|
446
|
-
});
|
|
447
|
-
expect(exitCode).toBe(0);
|
|
448
|
-
});
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
// ── allow tag bypass (transcript) ────────────────────────────────────────────
|
|
452
|
-
|
|
453
|
-
describe("pre-tool-use-hook — allow tag bypass via transcript", () => {
|
|
454
|
-
// ── Read tool ──────────────────────────────────────────────────────────────
|
|
455
|
-
|
|
456
|
-
it("[allow-all] bypasses .env name block", () => {
|
|
457
|
-
const transcript = writeTranscript([
|
|
458
|
-
"[allow-all] please read the .env file",
|
|
459
|
-
]);
|
|
460
|
-
const p = writeFixture(".env.bypass-all", "KEY=AKIAIOSFODNN7EXAMPLE\n");
|
|
461
|
-
const { exitCode } = runHook("Read", p, { transcriptPath: transcript });
|
|
462
|
-
expect(exitCode).toBe(0);
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
it("[allow-secret] bypasses .env name block", () => {
|
|
466
|
-
const transcript = writeTranscript(["[allow-secret] read the env file"]);
|
|
467
|
-
const p = writeFixture(".env.bypass-secret", "KEY=AKIAIOSFODNN7EXAMPLE\n");
|
|
468
|
-
const { exitCode } = runHook("Read", p, { transcriptPath: transcript });
|
|
469
|
-
expect(exitCode).toBe(0);
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
it("[allow-pii] also bypasses .env name block", () => {
|
|
473
|
-
const transcript = writeTranscript(["[allow-pii] read the env file"]);
|
|
474
|
-
const p = writeFixture(".env.bypass-pii", "KEY=value");
|
|
475
|
-
const { exitCode } = runHook("Read", p, { transcriptPath: transcript });
|
|
476
|
-
expect(exitCode).toBe(0);
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
it("[allow-secret] bypasses secrets in content scan", () => {
|
|
480
|
-
const transcript = writeTranscript(["[allow-secret] check the config"]);
|
|
481
|
-
const p = writeFixture(
|
|
482
|
-
"config-allow-secret2.txt",
|
|
483
|
-
"key=AKIAIOSFODNN7EXAMPLE\n",
|
|
484
|
-
);
|
|
485
|
-
const { exitCode } = runHook("Read", p, { transcriptPath: transcript });
|
|
486
|
-
expect(exitCode).toBe(0);
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
it("[allow-pii] bypasses PII in content scan", () => {
|
|
490
|
-
const transcript = writeTranscript(["[allow-pii] ok"]);
|
|
491
|
-
const p = writeFixture("pii-allow.txt", "email=user@example.com\n");
|
|
492
|
-
const { exitCode } = runHook("Read", p, { transcriptPath: transcript });
|
|
493
|
-
expect(exitCode).toBe(0);
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
it("[allow-pii] bypasses PII but not secrets in content scan", () => {
|
|
497
|
-
const transcript = writeTranscript(["[allow-pii] ok"]);
|
|
498
|
-
const p = writeFixture(
|
|
499
|
-
"mixed-allow-pii.txt",
|
|
500
|
-
"email=user@example.com\nkey=AKIAIOSFODNN7EXAMPLE\n",
|
|
501
|
-
);
|
|
502
|
-
const { exitCode, decision } = runHook("Read", p, {
|
|
503
|
-
transcriptPath: transcript,
|
|
504
|
-
});
|
|
505
|
-
expect(exitCode).toBe(2);
|
|
506
|
-
expect(decision).toBe("block");
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
it("[allow-all] in the latest message is respected even with older messages", () => {
|
|
510
|
-
const transcript = writeTranscript([
|
|
511
|
-
"please help me with the config",
|
|
512
|
-
"[allow-all] yes read everything",
|
|
513
|
-
]);
|
|
514
|
-
const p = writeFixture(
|
|
515
|
-
"config-allow-latest.txt",
|
|
516
|
-
"key=AKIAIOSFODNN7EXAMPLE\n",
|
|
517
|
-
);
|
|
518
|
-
const { exitCode } = runHook("Read", p, { transcriptPath: transcript });
|
|
519
|
-
expect(exitCode).toBe(0);
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
it("old [allow-all] in a past message is NOT respected when latest message has no tag", () => {
|
|
523
|
-
const transcript = writeTranscript([
|
|
524
|
-
"[allow-all] read this file",
|
|
525
|
-
"now do something else",
|
|
526
|
-
]);
|
|
527
|
-
const p = writeFixture(
|
|
528
|
-
"config-old-allow.txt",
|
|
529
|
-
"key=AKIAIOSFODNN7EXAMPLE\n",
|
|
530
|
-
);
|
|
531
|
-
const { exitCode, decision } = runHook("Read", p, {
|
|
532
|
-
transcriptPath: transcript,
|
|
533
|
-
});
|
|
534
|
-
expect(exitCode).toBe(2);
|
|
535
|
-
expect(decision).toBe("block");
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
it("blocks when transcript path is missing (no allow tags)", () => {
|
|
539
|
-
const p = writeFixture(
|
|
540
|
-
"config-no-transcript.txt",
|
|
541
|
-
"key=AKIAIOSFODNN7EXAMPLE\n",
|
|
542
|
-
);
|
|
543
|
-
const { exitCode, decision } = runHook("Read", p);
|
|
544
|
-
expect(exitCode).toBe(2);
|
|
545
|
-
expect(decision).toBe("block");
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
it("blocks when transcript path points to non-existent file", () => {
|
|
549
|
-
const p = writeFixture(
|
|
550
|
-
"config-bad-transcript.txt",
|
|
551
|
-
"key=AKIAIOSFODNN7EXAMPLE\n",
|
|
552
|
-
);
|
|
553
|
-
const { exitCode, decision } = runHook("Read", p, {
|
|
554
|
-
transcriptPath: "/tmp/no-such-transcript.jsonl",
|
|
555
|
-
});
|
|
556
|
-
expect(exitCode).toBe(2);
|
|
557
|
-
expect(decision).toBe("block");
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
// ── Bash tool ──────────────────────────────────────────────────────────────
|
|
561
|
-
|
|
562
|
-
it("[allow-secret] bypasses inline secret block in Bash command", () => {
|
|
563
|
-
const transcript = writeTranscript(["[allow-secret] echo the key"]);
|
|
564
|
-
const { exitCode } = runBashHook("echo AKIAIOSFODNN7EXAMPLE", {
|
|
565
|
-
transcriptPath: transcript,
|
|
566
|
-
});
|
|
567
|
-
expect(exitCode).toBe(0);
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
it("[allow-secret] bypasses env var secret block in Bash command", () => {
|
|
571
|
-
const transcript = writeTranscript(["[allow-secret] ok"]);
|
|
572
|
-
const { exitCode } = runBashHook("echo $TOKEN", {
|
|
573
|
-
env: { TOKEN: "AKIAIOSFODNN7EXAMPLE" },
|
|
574
|
-
transcriptPath: transcript,
|
|
575
|
-
});
|
|
576
|
-
expect(exitCode).toBe(0);
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
it("[allow-all] bypasses env var secret block in Bash command", () => {
|
|
580
|
-
const transcript = writeTranscript(["[allow-all] ok"]);
|
|
581
|
-
const { exitCode } = runBashHook("echo $TOKEN", {
|
|
582
|
-
env: { TOKEN: "AKIAIOSFODNN7EXAMPLE" },
|
|
583
|
-
transcriptPath: transcript,
|
|
584
|
-
});
|
|
585
|
-
expect(exitCode).toBe(0);
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
it("[allow-all] bypasses cat on a file with secrets via Bash", () => {
|
|
589
|
-
const transcript = writeTranscript(["[allow-all] show me the config"]);
|
|
590
|
-
const p = writeFixture(
|
|
591
|
-
"creds-bash-allow.txt",
|
|
592
|
-
"key=AKIAIOSFODNN7EXAMPLE\n",
|
|
593
|
-
);
|
|
594
|
-
const { exitCode } = runBashHook(`cat ${p}`, {
|
|
595
|
-
transcriptPath: transcript,
|
|
596
|
-
});
|
|
597
|
-
expect(exitCode).toBe(0);
|
|
598
|
-
});
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
// ── allow tag consumed after first tool call ──────────────────────────────────
|
|
602
|
-
|
|
603
|
-
describe("pre-tool-use-hook — allow tag single-use (consumed by first tool call)", () => {
|
|
604
|
-
it("[allow-all] works when no tool_result has been recorded yet", () => {
|
|
605
|
-
const transcript = writeTranscriptWithToolResults([
|
|
606
|
-
{ text: "[allow-all] read the config file" },
|
|
607
|
-
]);
|
|
608
|
-
const p = writeFixture(
|
|
609
|
-
"config-first-call.txt",
|
|
610
|
-
"key=AKIAIOSFODNN7EXAMPLE\n",
|
|
611
|
-
);
|
|
612
|
-
const { exitCode } = runHook("Read", p, { transcriptPath: transcript });
|
|
613
|
-
expect(exitCode).toBe(0);
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
it("[allow-all] is consumed after a tool_result — second call is blocked", () => {
|
|
617
|
-
const transcript = writeTranscriptWithToolResults([
|
|
618
|
-
{ text: "[allow-all] read all the config files" },
|
|
619
|
-
{ toolResult: "file contents from first read" },
|
|
620
|
-
]);
|
|
621
|
-
const p = writeFixture(
|
|
622
|
-
"config-second-call.txt",
|
|
623
|
-
"key=AKIAIOSFODNN7EXAMPLE\n",
|
|
624
|
-
);
|
|
625
|
-
const { exitCode, decision } = runHook("Read", p, {
|
|
626
|
-
transcriptPath: transcript,
|
|
627
|
-
});
|
|
628
|
-
expect(exitCode).toBe(2);
|
|
629
|
-
expect(decision).toBe("block");
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
it("[allow-secret] is consumed after a tool_result", () => {
|
|
633
|
-
const transcript = writeTranscriptWithToolResults([
|
|
634
|
-
{ text: "[allow-secret] check these files" },
|
|
635
|
-
{ toolResult: "first tool result" },
|
|
636
|
-
]);
|
|
637
|
-
const p = writeFixture("secret-consumed.txt", "key=AKIAIOSFODNN7EXAMPLE\n");
|
|
638
|
-
const { exitCode, decision } = runHook("Read", p, {
|
|
639
|
-
transcriptPath: transcript,
|
|
640
|
-
});
|
|
641
|
-
expect(exitCode).toBe(2);
|
|
642
|
-
expect(decision).toBe("block");
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
it("[allow-pii] is consumed after a tool_result", () => {
|
|
646
|
-
const transcript = writeTranscriptWithToolResults([
|
|
647
|
-
{ text: "[allow-pii] read the contacts" },
|
|
648
|
-
{ toolResult: "previous tool output" },
|
|
649
|
-
]);
|
|
650
|
-
const p = writeFixture("pii-consumed.txt", "email=user@example.com\n");
|
|
651
|
-
const { exitCode, decision } = runHook("Read", p, {
|
|
652
|
-
transcriptPath: transcript,
|
|
653
|
-
});
|
|
654
|
-
expect(exitCode).toBe(2);
|
|
655
|
-
expect(decision).toBe("block");
|
|
656
|
-
});
|
|
657
|
-
|
|
658
|
-
it("blocks when latest real user message has no allow tag despite earlier allow", () => {
|
|
659
|
-
const transcript = writeTranscriptWithToolResults([
|
|
660
|
-
{ text: "[allow-all] read everything" },
|
|
661
|
-
{ toolResult: "some result" },
|
|
662
|
-
{ text: "now do something else" },
|
|
663
|
-
{ toolResult: "another result" },
|
|
664
|
-
]);
|
|
665
|
-
const p = writeFixture(
|
|
666
|
-
"no-allow-after-new-msg.txt",
|
|
667
|
-
"key=AKIAIOSFODNN7EXAMPLE\n",
|
|
668
|
-
);
|
|
669
|
-
const { exitCode, decision } = runHook("Read", p, {
|
|
670
|
-
transcriptPath: transcript,
|
|
671
|
-
});
|
|
672
|
-
expect(exitCode).toBe(2);
|
|
673
|
-
expect(decision).toBe("block");
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
it("[allow-all] consumed for Bash after tool_result", () => {
|
|
677
|
-
const transcript = writeTranscriptWithToolResults([
|
|
678
|
-
{ text: "[allow-all] run the commands" },
|
|
679
|
-
{ toolResult: "result of first command" },
|
|
680
|
-
]);
|
|
681
|
-
const { exitCode, decision } = runBashHook("echo AKIAIOSFODNN7EXAMPLE", {
|
|
682
|
-
transcriptPath: transcript,
|
|
683
|
-
});
|
|
684
|
-
expect(exitCode).toBe(2);
|
|
685
|
-
expect(decision).toBe("block");
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
it("[allow-all] works for Bash when no tool_result yet", () => {
|
|
689
|
-
const transcript = writeTranscriptWithToolResults([
|
|
690
|
-
{ text: "[allow-all] run the command" },
|
|
691
|
-
]);
|
|
692
|
-
const { exitCode } = runBashHook("echo AKIAIOSFODNN7EXAMPLE", {
|
|
693
|
-
transcriptPath: transcript,
|
|
694
|
-
});
|
|
695
|
-
expect(exitCode).toBe(0);
|
|
696
|
-
});
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
// ── malformed input ───────────────────────────────────────────────────────────
|
|
700
|
-
|
|
701
|
-
describe("pre-tool-use-hook — malformed input", () => {
|
|
702
|
-
it("exits 0 on invalid JSON", () => {
|
|
703
|
-
const result = spawnSync("node", [...NODE_FLAGS, HOOK], {
|
|
704
|
-
input: "not json",
|
|
705
|
-
encoding: "utf8",
|
|
706
|
-
});
|
|
707
|
-
expect(result.status).toBe(0);
|
|
708
|
-
});
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
// ── SENSITIVE_CANARY_CATEGORIES ───────────────────────────────────────────────
|
|
712
|
-
|
|
713
|
-
describe("pre-tool-use-hook — SENSITIVE_CANARY_CATEGORIES", () => {
|
|
714
|
-
it("pii-only: allows reading .env files (secret guard disabled)", () => {
|
|
715
|
-
const dir = join(tmpDir, "pii-only-env");
|
|
716
|
-
mkdirSync(dir, { recursive: true });
|
|
717
|
-
const p = join(dir, ".env");
|
|
718
|
-
writeFileSync(p, "DEBUG=true\n", "utf8");
|
|
719
|
-
const { exitCode } = runHook("Read", p, {
|
|
720
|
-
env: { SENSITIVE_CANARY_CATEGORIES: "pii" },
|
|
721
|
-
});
|
|
722
|
-
expect(exitCode).toBe(0);
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
it("pii-only: allows a file containing only secrets", () => {
|
|
726
|
-
const p = writeFixture("pii-only-secret.txt", "key=AKIAIOSFODNN7EXAMPLE");
|
|
727
|
-
const { exitCode } = runHook("Read", p, {
|
|
728
|
-
env: { SENSITIVE_CANARY_CATEGORIES: "pii" },
|
|
729
|
-
});
|
|
730
|
-
expect(exitCode).toBe(0);
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
it("pii-only: still blocks a file containing PII", () => {
|
|
734
|
-
const p = writeFixture("pii-only-pii.txt", "card: 4111111111111111");
|
|
735
|
-
const { exitCode, decision } = runHook("Read", p, {
|
|
736
|
-
env: { SENSITIVE_CANARY_CATEGORIES: "pii" },
|
|
737
|
-
});
|
|
738
|
-
expect(exitCode).toBe(2);
|
|
739
|
-
expect(decision).toBe("block");
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
it("secret-only: allows a file containing only PII", () => {
|
|
743
|
-
const p = writeFixture("secret-only-pii.txt", "card: 4111111111111111");
|
|
744
|
-
const { exitCode } = runHook("Read", p, {
|
|
745
|
-
env: { SENSITIVE_CANARY_CATEGORIES: "secret" },
|
|
746
|
-
});
|
|
747
|
-
expect(exitCode).toBe(0);
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
it("secret-only: still blocks a file containing secrets", () => {
|
|
751
|
-
const p = writeFixture(
|
|
752
|
-
"secret-only-secret.txt",
|
|
753
|
-
"key=AKIAIOSFODNN7EXAMPLE",
|
|
754
|
-
);
|
|
755
|
-
const { exitCode, decision } = runHook("Read", p, {
|
|
756
|
-
env: { SENSITIVE_CANARY_CATEGORIES: "secret" },
|
|
757
|
-
});
|
|
758
|
-
expect(exitCode).toBe(2);
|
|
759
|
-
expect(decision).toBe("block");
|
|
760
|
-
});
|
|
761
|
-
|
|
762
|
-
it("secret-only: allows a bash command containing only PII", () => {
|
|
763
|
-
const { exitCode } = runBashHook("echo 4111111111111111", {
|
|
764
|
-
env: { SENSITIVE_CANARY_CATEGORIES: "secret" },
|
|
765
|
-
});
|
|
766
|
-
expect(exitCode).toBe(0);
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
it("unset: blocks both secrets and PII (default behavior)", () => {
|
|
770
|
-
const p = writeFixture(
|
|
771
|
-
"default-both.txt",
|
|
772
|
-
"key=AKIAIOSFODNN7EXAMPLE\ncard: 4111111111111111",
|
|
773
|
-
);
|
|
774
|
-
const { exitCode, reason } = runHook("Read", p);
|
|
775
|
-
expect(exitCode).toBe(2);
|
|
776
|
-
expect(reason).toContain("aws-access-key");
|
|
777
|
-
expect(reason).toContain("pii-credit-card");
|
|
778
|
-
});
|
|
779
|
-
});
|