@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
@@ -0,0 +1,235 @@
1
+ // Which tool calls name a file they are about to read.
2
+ //
3
+ // A tool's semantics are not knowable from its input, so this reads the tool's
4
+ // name and the shape of its input object: the fields that carry a path, and the
5
+ // names that say the tool writes rather than reads.
6
+
7
+ // Tools that never surface the contents of a file they name. Scanning these
8
+ // would block writing to a file that already holds a secret, which is not a leak.
9
+ export const TOOLS_WITHOUT_FILE_OUTPUT = new Set([
10
+ "Write",
11
+ "Edit",
12
+ "MultiEdit",
13
+ "NotebookEdit",
14
+ "TodoWrite",
15
+ "Glob",
16
+ "WebFetch",
17
+ "WebSearch",
18
+ "ExitPlanMode",
19
+ "AskUserQuestion",
20
+ ]);
21
+
22
+ // A tool whose name says it writes is treated like the built-in Write and Edit:
23
+ // naming a file it does not read is not a leak. Matched on the tool name because
24
+ // an MCP tool's semantics are not otherwise knowable from its input. For MCP
25
+ // tools (`mcp__<server>__<tool>`) only the tool component is matched — a server
26
+ // named "editor" or "readwrite" must not exempt every read tool it offers.
27
+ // The verb has to be the first word of the name, not a substring of it anywhere.
28
+ // As a substring test this would exempt reads: "update" sits inside
29
+ // `get_updates`, and "write" inside `read_and_write_file` — a tool that returns
30
+ // contents read as one that only writes. Word boundaries are the `_`/`-` in snake and
31
+ // kebab names and, in camelCase, a capital that follows a lowercase letter — so
32
+ // `write_file`, `createPage` and `WRITE_FILE` all match while `overwrite_file`
33
+ // and `readwrite` do not.
34
+ //
35
+ // Erring this way costs a false block on a noun-first write tool (`file_write`),
36
+ // which is the direction to fail in. The built-in write tools are named
37
+ // explicitly in TOOLS_WITHOUT_FILE_OUTPUT, so `TodoWrite` and `MultiEdit` do not
38
+ // depend on this at all.
39
+ //
40
+ // What the exemption assumes is that the tool returns no file contents, which is
41
+ // not quite what its name says. `update` and `copy` are where the two come
42
+ // apart: a tool called `update_file` or `copy_file` opens a file to do its work,
43
+ // and one that returned the result would go unscanned. They stay, because the
44
+ // alternative costs more — scanning them blocks writing to a file that already
45
+ // holds a secret, which is not a leak — and the gap that leaves is written up
46
+ // under Known Limitations in the README.
47
+ // Exported so the tests can generate a case per verb rather than list the ones
48
+ // someone remembered: a verb added here without a test is what let `WRITE_FILE`
49
+ // go unexempt for a release.
50
+ export const WRITING_TOOL_VERBS = new Set([
51
+ "write",
52
+ "create",
53
+ "edit",
54
+ "update",
55
+ "append",
56
+ "delete",
57
+ "remove",
58
+ "move",
59
+ "rename",
60
+ "mkdir",
61
+ "copy",
62
+ ]);
63
+
64
+ // The first word of a tool name. Splitting on every capital broke the all-caps
65
+ // spelling: `WRITE_FILE` came apart into single letters and its first word was
66
+ // `W`, so a write tool was scanned as a read. A capital only starts a new word
67
+ // when it follows a lowercase letter or a digit, which is what camelCase means;
68
+ // a run of capitals is one word.
69
+ function firstWord(name: string): string | undefined {
70
+ const [first] = name
71
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
72
+ .split(/[^A-Za-z0-9]+/)
73
+ .filter(Boolean);
74
+ return first;
75
+ }
76
+
77
+ export function isWritingTool(tool: string): boolean {
78
+ const name = tool.startsWith("mcp__")
79
+ ? (tool.split("__").pop() ?? tool)
80
+ : tool;
81
+ const first = firstWord(name);
82
+ return first !== undefined && WRITING_TOOL_VERBS.has(first.toLowerCase());
83
+ }
84
+
85
+ // Input field names that commonly carry a filesystem path, compared with
86
+ // separators and case removed. Listing the spellings instead meant the same
87
+ // field was missed under a different one: `file_path` and `filePath` were both
88
+ // here, but `filepath` was not, and neither was `filename` or `source_path`.
89
+ // Normalising is a rule where a list of spellings is a list of the ones someone
90
+ // happened to think of.
91
+ // A field name with the punctuation taken out, so `file_path`, `file-path`,
92
+ // `filePath`, `file.path` and `file path` are one name. Both collectors here
93
+ // share it: they had a regex each, and the one used for command fields dropped
94
+ // only `-` and `_`, so a field called `command.line` was walked past.
95
+ export function normalizeFieldName(key: string): string {
96
+ return key.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
97
+ }
98
+
99
+ export const PATH_FIELD_NAMES = new Set([
100
+ "filepath",
101
+ "filename",
102
+ "filenames",
103
+ "path",
104
+ "paths",
105
+ "file",
106
+ "files",
107
+ "absolutepath",
108
+ "notebookpath",
109
+ "sourcepath",
110
+ ]);
111
+
112
+ // `file_path`, `filePath`, `FILE_PATH` and `filepath` are one name here.
113
+ function isPathFieldName(key: string): boolean {
114
+ return PATH_FIELD_NAMES.has(normalizeFieldName(key));
115
+ }
116
+
117
+ // A value that names a path whatever its field is called. The list above can
118
+ // only hold names someone thought of, so a tool carrying its path under `target`
119
+ // or `document` went unscanned; this is the second way in.
120
+ //
121
+ // It is deliberately not "any string". Collecting every string would read a
122
+ // search pattern as a path: `grep` for the literal `.env` arrives as
123
+ // `{ pattern: ".env" }`, `.env` exists, and the name guard would then block a
124
+ // search for that text as though it were a read of the file. A separator is the
125
+ // cheapest test that tells a path from a word, and the cost of using it is that
126
+ // a bare filename under an unlisted field name is still missed.
127
+ function looksLikePath(value: string): boolean {
128
+ return value.includes("/");
129
+ }
130
+
131
+ // Whether a value is worth statting: either its field name says path, or the
132
+ // value is shaped like one.
133
+ function isPathCandidate(key: string, value: string): boolean {
134
+ return isPathFieldName(key) || looksLikePath(value);
135
+ }
136
+
137
+ // Depth to which a tool's input object is searched for path-bearing fields. Two
138
+ // levels left `{ a: { b: { c: { path } } } }` unscanned, which is not a shape a
139
+ // tool has to be perverse to use; four costs nothing on inputs this size, and
140
+ // the bound is here at all so a deeply nested input cannot make the hook walk
141
+ // an arbitrary tree before a tool call.
142
+ const MAX_PATH_FIELD_DEPTH = 4;
143
+
144
+ // Input field names that carry something to run rather than something to read.
145
+ // Compared with separators and case removed, the way path field names are.
146
+ export const COMMAND_FIELD_NAMES = new Set([
147
+ "command",
148
+ "commands",
149
+ "cmd",
150
+ "script",
151
+ "code",
152
+ "shellcommand",
153
+ "commandline",
154
+ ]);
155
+
156
+ // How far into a nested input a command field is looked for. The same depth the
157
+ // path fields use, and for the same reason: a tool wraps its arguments.
158
+ const MAX_COMMAND_FIELD_DEPTH = 4;
159
+
160
+ export function collectPathFields(
161
+ input: Record<string, unknown>,
162
+ depth = 0,
163
+ ): string[] {
164
+ if (depth > MAX_PATH_FIELD_DEPTH) return [];
165
+ const found: string[] = [];
166
+
167
+ for (const [key, value] of Object.entries(input)) {
168
+ if (typeof value === "string") {
169
+ if (isPathCandidate(key, value)) found.push(value);
170
+ } else if (Array.isArray(value)) {
171
+ for (const item of value) {
172
+ if (typeof item === "string") {
173
+ // An element inherits the array's field name: `{ paths: ["…"] }`.
174
+ if (isPathCandidate(key, item)) found.push(item);
175
+ } else if (Array.isArray(item)) {
176
+ // An array inside an array: `{ paths: [["…"]] }`. The element is not a
177
+ // string and was not an object either, so it fell through and the path
178
+ // in it was never looked at. Re-entered under the same key, so the
179
+ // name rule still applies to what is inside.
180
+ found.push(...collectPathFields({ [key]: item }, depth + 1));
181
+ } else if (
182
+ // Paths also arrive as objects inside an array, e.g.
183
+ // `{ paths: [{ path: "…" }] }` — recurse into those elements too.
184
+ item !== null &&
185
+ typeof item === "object"
186
+ ) {
187
+ found.push(
188
+ ...collectPathFields(item as Record<string, unknown>, depth + 1),
189
+ );
190
+ }
191
+ }
192
+ } else if (value !== null && typeof value === "object") {
193
+ found.push(
194
+ ...collectPathFields(value as Record<string, unknown>, depth + 1),
195
+ );
196
+ }
197
+ }
198
+
199
+ return found;
200
+ }
201
+
202
+ // Every command an input carries, whatever shape it arrives in.
203
+ //
204
+ // Reading only top-level strings left two shapes through, and both reach the
205
+ // `.env` name guard by a name with no slash in it, which the path rules do not
206
+ // collect: an argv array (`{"command":["cat",".env"]}`) and a command nested
207
+ // under another key (`{"args":{"command":"cat .env"}}`). Depth-limited the way
208
+ // path fields are, for the same reason.
209
+ //
210
+ // Beside `collectPathFields` rather than in the hook: the two walk the same tree
211
+ // to the same depth and differ only in which field names count, and that
212
+ // question — along with `normalizeFieldName` and both name sets — belongs in one
213
+ // module rather than split across two.
214
+ export function collectCommandFields(
215
+ input: Record<string, unknown>,
216
+ depth = 0,
217
+ ): string[] {
218
+ if (depth > MAX_COMMAND_FIELD_DEPTH) return [];
219
+ const found: string[] = [];
220
+ for (const [key, value] of Object.entries(input)) {
221
+ const named = COMMAND_FIELD_NAMES.has(normalizeFieldName(key));
222
+ if (named && typeof value === "string") {
223
+ found.push(value);
224
+ } else if (named && Array.isArray(value)) {
225
+ // An argv array is one command line with the spaces taken out.
226
+ const argv = value.filter((v): v is string => typeof v === "string");
227
+ if (argv.length > 0) found.push(argv.join(" "));
228
+ } else if (value !== null && typeof value === "object") {
229
+ found.push(
230
+ ...collectCommandFields(value as Record<string, unknown>, depth + 1),
231
+ );
232
+ }
233
+ }
234
+ return found;
235
+ }
@@ -0,0 +1,142 @@
1
+ // Which line of the transcript is the user speaking, and what tags they wrote.
2
+ //
3
+ // A tag lifts the checks, so the question this file answers is the most
4
+ // dangerous one in the product: everything the runtime writes under the user's
5
+ // role — a compaction summary, a skill body, the output of a `!` command, a
6
+ // background task reporting back — has to be told apart from someone typing.
7
+
8
+ import fs from "node:fs";
9
+ import {
10
+ type Message,
11
+ resolveTagPriority,
12
+ userTypedText,
13
+ } from "./inspector.ts";
14
+
15
+ // Maximum bytes to read from the tail of a transcript file.
16
+ const MAX_TRANSCRIPT_TAIL_BYTES = 65_536; // 64 KB
17
+
18
+ export interface TranscriptLine {
19
+ type?: unknown;
20
+ // Runtime-written lines that carry the user's role without the user having
21
+ // typed them: a compaction summary, and a meta line such as a skill body.
22
+ isCompactSummary?: unknown;
23
+ isMeta?: unknown;
24
+ // Where the line came from. `human` is someone at a keyboard; the other
25
+ // values name the runtime writing under the user's role.
26
+ origin?: { kind?: unknown } | null;
27
+ message?: Message;
28
+ }
29
+
30
+ // Whether a transcript line records something a person typed.
31
+ //
32
+ // The field is only present on lines that have one, so a line without it is
33
+ // left to the other tests rather than rejected: most user lines carry tool
34
+ // results and have no origin, and an older runtime writes none at all.
35
+ export function wasTypedByAHuman(line: TranscriptLine): boolean {
36
+ const kind = line.origin?.kind;
37
+ return kind === undefined || kind === null || kind === "human";
38
+ }
39
+
40
+ // Returns true when the message carries text the user typed. A message that is
41
+ // only tool results, or only the machinery above, is not user input.
42
+ function hasTextContent(msg: Message): boolean {
43
+ if (
44
+ typeof msg.content !== "string" &&
45
+ !msg.content.some((b) => b.type === "text")
46
+ )
47
+ return false;
48
+ return userTypedText(msg).trim().length > 0;
49
+ }
50
+
51
+ // Load allow tags from the Claude Code session transcript.
52
+ // Transcript format (JSONL): { "type": "user"|"assistant", "message": { role, content }, … }
53
+ // Only the most recent user *text* message is consulted, and only if no tool_result
54
+ // entries have been recorded after it. This means allow tags are consumed by the first
55
+ // tool call — subsequent tool calls in the same AI turn will be blocked.
56
+ export function loadAllowTagsFromTranscript(
57
+ transcriptPath: string,
58
+ ): Set<string> {
59
+ let raw: string;
60
+ try {
61
+ const stat = fs.statSync(transcriptPath);
62
+ // A FIFO here would block the read until something wrote to it, and a hook
63
+ // that never returns is killed by the timeout, which does not block.
64
+ if (!stat.isFile()) return new Set();
65
+ if (stat.size <= MAX_TRANSCRIPT_TAIL_BYTES) {
66
+ raw = fs.readFileSync(transcriptPath, "utf8");
67
+ } else {
68
+ const buf = Buffer.alloc(MAX_TRANSCRIPT_TAIL_BYTES);
69
+ const fd = fs.openSync(transcriptPath, "r");
70
+ try {
71
+ const bytesRead = fs.readSync(
72
+ fd,
73
+ buf,
74
+ 0,
75
+ MAX_TRANSCRIPT_TAIL_BYTES,
76
+ stat.size - MAX_TRANSCRIPT_TAIL_BYTES,
77
+ );
78
+ raw = buf.subarray(0, bytesRead).toString("utf8");
79
+ } finally {
80
+ fs.closeSync(fd);
81
+ }
82
+ }
83
+ } catch {
84
+ return new Set();
85
+ }
86
+
87
+ let lastUserMessage: Message | null = null;
88
+ let toolResultAfterLastText = false;
89
+ for (const line of raw.split("\n")) {
90
+ const trimmed = line.trim();
91
+ if (!trimmed) continue;
92
+ try {
93
+ const parsed = JSON.parse(trimmed) as TranscriptLine;
94
+ const msg = parsed.message;
95
+ // A line the runtime wrote as an assistant turn is not user input,
96
+ // whatever the message inside it says its role is. Absent rather than
97
+ // contradictory is fine: the field is rejected only when it names some
98
+ // other kind of line.
99
+ //
100
+ // `isCompactSummary` and `isMeta` are two the runtime writes as the user
101
+ // without the user having typed them. A compaction summary is a
102
+ // re-injection of earlier turns, so a tag anyone discussed at any point in
103
+ // the conversation comes back armed; a meta line carries skill bodies and
104
+ // other file content, so writing a `SKILL.md` would be enough to lift
105
+ // every check. Neither is someone asking for anything.
106
+ //
107
+ // `origin.kind` says outright which lines those are, and it is asked
108
+ // before any of the rest: a background task reporting back arrives as
109
+ // `task-notification`, carrying an agent's free-form prose under the
110
+ // user's role. Prose about these very tags is enough, so a report that
111
+ // quotes the documentation arms the guard it is describing.
112
+ //
113
+ // Only lines that carry the field are judged by it. Most do not — a tool
114
+ // result has no origin — and treating absent as non-human would ignore
115
+ // every transcript written by a runtime that predates it.
116
+ if (
117
+ (parsed.type === undefined || parsed.type === "user") &&
118
+ parsed.isCompactSummary !== true &&
119
+ parsed.isMeta !== true &&
120
+ wasTypedByAHuman(parsed) &&
121
+ msg?.role === "user" &&
122
+ msg.content !== undefined
123
+ ) {
124
+ if (hasTextContent(msg)) {
125
+ lastUserMessage = msg;
126
+ toolResultAfterLastText = false;
127
+ } else {
128
+ toolResultAfterLastText = true;
129
+ }
130
+ }
131
+ } catch {
132
+ // skip malformed lines
133
+ }
134
+ }
135
+
136
+ if (!lastUserMessage || toolResultAfterLastText) return new Set();
137
+ // Through the same resolution the prompt hook uses, over the typed text
138
+ // rather than the raw content. Collecting every tag instead meant this hook
139
+ // did not see mask tags at all, so `[mask-secret] [allow-secret]` stopped the
140
+ // prompt and then allowed the tool call it was stopping.
141
+ return resolveTagPriority(userTypedText(lastUserMessage)).effectiveAllow;
142
+ }