@coo-quack/sensitive-canary 0.7.0 → 0.8.1

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 (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +798 -0
  3. package/README.md +142 -45
  4. package/dist/lib/bash-commands.js +405 -0
  5. package/dist/lib/command-tables.js +462 -0
  6. package/dist/lib/default-config.json +570 -0
  7. package/dist/lib/encoding.js +123 -0
  8. package/dist/lib/fail-closed.js +31 -0
  9. package/dist/lib/inspector.js +0 -0
  10. package/dist/lib/rules.js +399 -0
  11. package/dist/lib/shapes.js +161 -0
  12. package/dist/lib/shell.js +436 -0
  13. package/dist/lib/tool-inputs.js +217 -0
  14. package/dist/lib/transcript.js +115 -0
  15. package/dist/lib/validators.js +435 -0
  16. package/dist/pre-tool-use-hook.js +773 -0
  17. package/dist/user-prompt-submit-hook.js +105 -0
  18. package/hooks/hooks.json +1 -1
  19. package/package.json +25 -11
  20. package/src/lib/bash-commands.ts +455 -0
  21. package/src/lib/command-tables.ts +518 -0
  22. package/src/lib/default-config.json +155 -46
  23. package/src/lib/encoding.ts +135 -0
  24. package/src/lib/fail-closed.ts +36 -0
  25. package/src/lib/inspector.ts +0 -0
  26. package/src/lib/rules.ts +202 -365
  27. package/src/lib/shapes.ts +175 -0
  28. package/src/lib/shell.ts +512 -0
  29. package/src/lib/tool-inputs.ts +235 -0
  30. package/src/lib/transcript.ts +142 -0
  31. package/src/lib/validators.ts +435 -0
  32. package/src/pre-tool-use-hook.ts +774 -198
  33. package/src/user-prompt-submit-hook.ts +60 -18
  34. package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
  35. package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
  36. package/src/lib/__tests__/inspector.test.ts +0 -289
  37. package/src/lib/__tests__/rules.test.ts +0 -1370
@@ -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 { enabledCategoriesFromEnv, scan } from "./lib/rules.ts";
14
+ import {
15
+ beginScanBudget,
16
+ enabledCategoriesFromEnv,
17
+ scan,
18
+ } from "./lib/rules.ts";
19
+
20
+ blockOnUnhandledError();
12
21
 
13
22
  interface HookInput {
14
- prompt?: string;
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
- data = JSON.parse(raw) as HookInput;
26
- } catch {
27
- process.exit(0);
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
- const prompt = data.prompt ?? "";
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
- const { effectiveAllow, effectiveMask } = resolveTagPriority(prompt);
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
- ...(hasSecret ? [" [allow-secret] — allow secrets"] : []),
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
- ...(hasSecret ? [" [allow-secret] — allow secrets"] : []),
92
- ...(hasPii ? [" [allow-pii] — allow PII"] : []),
93
- " [allow-all] — bypass all sensitive-canary checks",
135
+ ...allowTagLines(afterAllow),
94
136
  "",
95
137
  ];
96
138