@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,17 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { blockOnUnhandledError, failClosed } from "./lib/fail-closed.ts";
|
|
3
4
|
import {
|
|
5
|
+
allowTagLines,
|
|
4
6
|
applyAllowTags,
|
|
5
7
|
dedupeFindings,
|
|
6
8
|
type Finding,
|
|
7
9
|
findingsToLines,
|
|
8
10
|
randomBird,
|
|
9
11
|
resolveTagPriority,
|
|
12
|
+
typedTextOf,
|
|
10
13
|
} from "./lib/inspector.ts";
|
|
11
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
beginScanBudget,
|
|
16
|
+
enabledCategoriesFromEnv,
|
|
17
|
+
scan,
|
|
18
|
+
} from "./lib/rules.ts";
|
|
19
|
+
|
|
20
|
+
blockOnUnhandledError();
|
|
12
21
|
|
|
13
22
|
interface HookInput {
|
|
14
|
-
prompt?:
|
|
23
|
+
prompt?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Depth at which a prompt stops being searched. The bound is here so a deeply
|
|
27
|
+
// nested value cannot make the hook walk an arbitrary tree before every prompt.
|
|
28
|
+
const MAX_PROMPT_DEPTH = 4;
|
|
29
|
+
|
|
30
|
+
function collectStrings(value: unknown, depth = 0): string[] {
|
|
31
|
+
if (typeof value === "string") return [value];
|
|
32
|
+
if (depth >= MAX_PROMPT_DEPTH) return [];
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
return value.flatMap((item) => collectStrings(item, depth + 1));
|
|
35
|
+
if (value !== null && typeof value === "object")
|
|
36
|
+
return Object.values(value as Record<string, unknown>).flatMap((item) =>
|
|
37
|
+
collectStrings(item, depth + 1),
|
|
38
|
+
);
|
|
39
|
+
return [];
|
|
15
40
|
}
|
|
16
41
|
|
|
17
42
|
const ENABLED_CATEGORIES = enabledCategoriesFromEnv();
|
|
@@ -20,20 +45,46 @@ let raw = "";
|
|
|
20
45
|
process.stdin.setEncoding("utf8");
|
|
21
46
|
process.stdin.on("data", (chunk: string) => (raw += chunk));
|
|
22
47
|
process.stdin.on("end", () => {
|
|
48
|
+
// Started here rather than at module load: the wait for stdin belongs to the
|
|
49
|
+
// runtime, and counting it against the scan let a slow handover spend the
|
|
50
|
+
// whole allowance before anything was read.
|
|
51
|
+
beginScanBudget();
|
|
23
52
|
let data: HookInput;
|
|
24
53
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
54
|
+
// Empty stdin is nothing to check. Bytes that do not parse are a check that
|
|
55
|
+
// could not read its input, which is not the same as safe: two characters
|
|
56
|
+
// missing from the end of a payload are enough to hide a key.
|
|
57
|
+
if (raw.trim().length === 0) process.exit(0);
|
|
58
|
+
const parsed: unknown = JSON.parse(raw);
|
|
59
|
+
// `JSON.parse("null")` succeeds and returns null, which then threw on the
|
|
60
|
+
// first field read. A payload that is not an object carries no prompt.
|
|
61
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
62
|
+
process.exit(0);
|
|
63
|
+
data = parsed as HookInput;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// The check never started, so it vouches for nothing. Everything else that
|
|
66
|
+
// cannot finish stops the call; input that will not parse is the same case.
|
|
67
|
+
failClosed(error);
|
|
28
68
|
}
|
|
29
69
|
|
|
30
|
-
|
|
70
|
+
// Whatever the runtime sends. Not throwing on a prompt that is not a string
|
|
71
|
+
// was only half of it: coercing to "" made the hook exit 0 on the shapes it
|
|
72
|
+
// could not read, which is the same silence as never running. Every string
|
|
73
|
+
// inside the value is collected instead, to a bounded depth, so
|
|
74
|
+
// `{"prompt":{"text":"…"}}` and `{"prompt":["…"]}` are read like a prompt.
|
|
75
|
+
const prompt = collectStrings(data.prompt).join("\n");
|
|
31
76
|
|
|
32
77
|
const allFindings = scan(prompt, ENABLED_CATEGORIES);
|
|
33
78
|
|
|
34
79
|
if (allFindings.length === 0) process.exit(0);
|
|
35
80
|
|
|
36
|
-
|
|
81
|
+
// From what the user typed, not from what they pasted: a fenced log or a
|
|
82
|
+
// README quoting `[allow-secret]` would otherwise lift the guard on the key in
|
|
83
|
+
// the same message. Both hooks read tags this way, so the same text gets the
|
|
84
|
+
// same answer whichever one sees it.
|
|
85
|
+
const { effectiveAllow, effectiveMask } = resolveTagPriority(
|
|
86
|
+
typedTextOf(prompt),
|
|
87
|
+
);
|
|
37
88
|
|
|
38
89
|
const afterAllow: Finding[] = dedupeFindings(
|
|
39
90
|
applyAllowTags(allFindings, effectiveAllow),
|
|
@@ -48,8 +99,6 @@ process.stdin.on("end", () => {
|
|
|
48
99
|
);
|
|
49
100
|
|
|
50
101
|
if (maskableFindings.length > 0) {
|
|
51
|
-
const hasSecret = maskableFindings.some((f) => f.category === "secret");
|
|
52
|
-
const hasPii = maskableFindings.some((f) => f.category === "pii");
|
|
53
102
|
const usedTags = effectiveMask.has("all")
|
|
54
103
|
? "[mask-all]"
|
|
55
104
|
: (["secret", "pii"] as const)
|
|
@@ -69,18 +118,13 @@ process.stdin.on("end", () => {
|
|
|
69
118
|
"",
|
|
70
119
|
" 1. Manually redact the values above and resubmit",
|
|
71
120
|
" 2. To send as-is, add an allow tag to your prompt:",
|
|
72
|
-
...(
|
|
73
|
-
...(hasPii ? [" [allow-pii] — allow PII"] : []),
|
|
74
|
-
" [allow-all] — bypass all sensitive-canary checks",
|
|
121
|
+
...allowTagLines(maskableFindings, { indent: " " }),
|
|
75
122
|
"",
|
|
76
123
|
];
|
|
77
124
|
process.stderr.write(maskBlockLines.join("\n"));
|
|
78
125
|
process.exit(2);
|
|
79
126
|
}
|
|
80
127
|
|
|
81
|
-
const hasSecret = afterAllow.some((f) => f.category === "secret");
|
|
82
|
-
const hasPii = afterAllow.some((f) => f.category === "pii");
|
|
83
|
-
|
|
84
128
|
const blockLines = [
|
|
85
129
|
"",
|
|
86
130
|
`${randomBird()} sensitive-canary: sensitive data detected — blocked`,
|
|
@@ -88,9 +132,7 @@ process.stdin.on("end", () => {
|
|
|
88
132
|
...findingsToLines(afterAllow),
|
|
89
133
|
"",
|
|
90
134
|
"To allow, add a tag to your prompt:",
|
|
91
|
-
...(
|
|
92
|
-
...(hasPii ? [" [allow-pii] — allow PII"] : []),
|
|
93
|
-
" [allow-all] — bypass all sensitive-canary checks",
|
|
135
|
+
...allowTagLines(afterAllow),
|
|
94
136
|
"",
|
|
95
137
|
];
|
|
96
138
|
|