@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +798 -0
- package/README.md +142 -45
- 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 +155 -46
- 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 +202 -365
- 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 -289
- 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
|
+
}
|