@jqntn/agentdoctor 0.1.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/LICENSE +21 -0
- package/README.md +215 -0
- package/bin/agentdoctor.js +314 -0
- package/docs/agents.md +119 -0
- package/docs/api.md +100 -0
- package/docs/architecture.md +90 -0
- package/docs/baselines.md +56 -0
- package/docs/ci.md +87 -0
- package/docs/configuration.md +115 -0
- package/docs/faq.md +83 -0
- package/docs/getting-started.md +99 -0
- package/docs/output.md +97 -0
- package/docs/policy.md +94 -0
- package/docs/rules.md +463 -0
- package/package.json +71 -0
- package/schemas/policy.schema.json +36 -0
- package/schemas/report.schema.json +58 -0
- package/skills/config-audit/SKILL.md +72 -0
- package/skills/config-audit/references/fix-recipes.md +107 -0
- package/src/adopt.js +178 -0
- package/src/constants.js +139 -0
- package/src/discover.js +235 -0
- package/src/engine.js +218 -0
- package/src/grade.js +39 -0
- package/src/index.js +42 -0
- package/src/links.js +9 -0
- package/src/parse.js +318 -0
- package/src/report/json.js +36 -0
- package/src/report/sarif.js +68 -0
- package/src/report/terminal.js +135 -0
- package/src/rules/correctness.js +849 -0
- package/src/rules/cost.js +282 -0
- package/src/rules/hygiene.js +199 -0
- package/src/rules/index.js +18 -0
- package/src/rules/policy.js +288 -0
- package/src/rules/security.js +690 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DESTRUCTIVE_PATTERNS, REMOTE_EXEC_PATTERNS, SECRET_PATTERNS,
|
|
3
|
+
DANGEROUS_ENV_VARS, PERMISSION_MODES, EGRESS_TOOLS,
|
|
4
|
+
} from '../constants.js';
|
|
5
|
+
import { existsSync, statSync } from 'node:fs';
|
|
6
|
+
import { isAbsolute, join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
/** Matchers that grant a tool with no argument restriction at all. */
|
|
9
|
+
const WILDCARD_ARGS = new Set(['*', '**', ':*', '', '.*', '*:*']);
|
|
10
|
+
|
|
11
|
+
const isWildcard = (argument) =>
|
|
12
|
+
argument === null || WILDCARD_ARGS.has(String(argument).trim());
|
|
13
|
+
|
|
14
|
+
/** Walks every permission rule in every settings file. */
|
|
15
|
+
function eachPermission(files, helpers, callback) {
|
|
16
|
+
for (const file of files) {
|
|
17
|
+
if (file.kind !== 'settings' || !file.data) continue;
|
|
18
|
+
for (const bucket of ['allow', 'deny', 'ask']) {
|
|
19
|
+
const rules = file.data?.permissions?.[bucket];
|
|
20
|
+
if (!Array.isArray(rules)) continue;
|
|
21
|
+
rules.forEach((rule, index) => {
|
|
22
|
+
const configPath = `permissions.${bucket}[${index}]`;
|
|
23
|
+
callback({
|
|
24
|
+
file,
|
|
25
|
+
bucket,
|
|
26
|
+
rule,
|
|
27
|
+
index,
|
|
28
|
+
configPath,
|
|
29
|
+
position: helpers.at(file, configPath),
|
|
30
|
+
parsed: helpers.parsePermission(rule),
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Collects every shell command a hook would run. */
|
|
38
|
+
function eachHookCommand(files, helpers, callback) {
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
if (file.kind !== 'settings' || !file.data?.hooks) continue;
|
|
41
|
+
const hooks = file.data.hooks;
|
|
42
|
+
if (typeof hooks !== 'object' || Array.isArray(hooks)) continue;
|
|
43
|
+
for (const [event, matchers] of Object.entries(hooks)) {
|
|
44
|
+
if (!Array.isArray(matchers)) continue;
|
|
45
|
+
matchers.forEach((entry, matcherIndex) => {
|
|
46
|
+
const list = entry?.hooks;
|
|
47
|
+
if (!Array.isArray(list)) return;
|
|
48
|
+
list.forEach((hook, hookIndex) => {
|
|
49
|
+
const configPath = `hooks.${event}[${matcherIndex}].hooks[${hookIndex}].command`;
|
|
50
|
+
callback({
|
|
51
|
+
file,
|
|
52
|
+
event,
|
|
53
|
+
hook,
|
|
54
|
+
command: typeof hook?.command === 'string' ? hook.command : null,
|
|
55
|
+
configPath,
|
|
56
|
+
position: helpers.at(file, configPath),
|
|
57
|
+
matcher: entry?.matcher,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const securityRules = [
|
|
66
|
+
{
|
|
67
|
+
id: 'security/unrestricted-bash',
|
|
68
|
+
category: 'security',
|
|
69
|
+
severity: 'error',
|
|
70
|
+
title: 'Blanket Bash allow rule',
|
|
71
|
+
help: 'Replace the wildcard with the specific commands you actually want unattended, e.g. "Bash(npm test:*)" or "Bash(git status)". A blanket allow means any command the model proposes runs without asking you.',
|
|
72
|
+
check({ files, report, helpers }) {
|
|
73
|
+
eachPermission(files, helpers, ({ file, bucket, parsed, configPath, position, rule }) => {
|
|
74
|
+
if (bucket !== 'allow') return;
|
|
75
|
+
if (parsed.tool !== 'Bash') return;
|
|
76
|
+
if (!isWildcard(parsed.argument)) return;
|
|
77
|
+
report({
|
|
78
|
+
file,
|
|
79
|
+
line: position.line,
|
|
80
|
+
column: position.column,
|
|
81
|
+
configPath,
|
|
82
|
+
snippet: String(rule),
|
|
83
|
+
message: `"${rule}" auto-approves every shell command, including ones you have not seen.`,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
id: 'security/destructive-allow',
|
|
91
|
+
category: 'security',
|
|
92
|
+
severity: 'error',
|
|
93
|
+
title: 'Destructive command pre-approved',
|
|
94
|
+
help: 'Move this rule to permissions.ask so you still get a prompt, or narrow it to the safe subset of the command.',
|
|
95
|
+
check({ files, report, helpers }) {
|
|
96
|
+
eachPermission(files, helpers, ({ file, bucket, parsed, configPath, position, rule }) => {
|
|
97
|
+
if (bucket !== 'allow') return;
|
|
98
|
+
if (parsed.argument == null) return;
|
|
99
|
+
for (const { pattern, label } of DESTRUCTIVE_PATTERNS) {
|
|
100
|
+
if (pattern.test(parsed.argument)) {
|
|
101
|
+
report({
|
|
102
|
+
file,
|
|
103
|
+
line: position.line,
|
|
104
|
+
column: position.column,
|
|
105
|
+
configPath,
|
|
106
|
+
snippet: String(rule),
|
|
107
|
+
message: `"${rule}" pre-approves ${label} with no confirmation.`,
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
id: 'security/bypass-permissions-default',
|
|
118
|
+
category: 'security',
|
|
119
|
+
severity: 'error',
|
|
120
|
+
title: 'Permission checks disabled by default',
|
|
121
|
+
help: 'Use "default" or "acceptEdits" for day-to-day work and opt into bypass explicitly per session. Committing bypassPermissions applies it to everyone who checks out the repo.',
|
|
122
|
+
check({ files, report, helpers }) {
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
if (file.kind !== 'settings' || !file.data) continue;
|
|
125
|
+
const mode = file.data?.permissions?.defaultMode;
|
|
126
|
+
if (mode !== 'bypassPermissions') continue;
|
|
127
|
+
const position = helpers.at(file, 'permissions.defaultMode');
|
|
128
|
+
report({
|
|
129
|
+
file,
|
|
130
|
+
line: position.line,
|
|
131
|
+
column: position.column,
|
|
132
|
+
configPath: 'permissions.defaultMode',
|
|
133
|
+
message: file.scope === 'project'
|
|
134
|
+
? 'defaultMode "bypassPermissions" is committed to the repo, so every contributor runs with permission checks off.'
|
|
135
|
+
: 'defaultMode "bypassPermissions" turns off permission checks for every session.',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
{
|
|
142
|
+
id: 'security/hooks-globally-disabled',
|
|
143
|
+
category: 'security',
|
|
144
|
+
severity: 'warning',
|
|
145
|
+
title: 'All hooks disabled',
|
|
146
|
+
help: 'If hooks were disabled to work around one noisy hook, remove that hook instead. disableAllHooks also silences hooks your team relies on for guardrails.',
|
|
147
|
+
check({ files, report, helpers }) {
|
|
148
|
+
for (const file of files) {
|
|
149
|
+
if (file.kind !== 'settings' || file.data?.disableAllHooks !== true) continue;
|
|
150
|
+
const position = helpers.at(file, 'disableAllHooks');
|
|
151
|
+
report({
|
|
152
|
+
file,
|
|
153
|
+
line: position.line,
|
|
154
|
+
column: position.column,
|
|
155
|
+
configPath: 'disableAllHooks',
|
|
156
|
+
message: 'disableAllHooks is true, so every configured guardrail hook is inert.',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
id: 'security/hook-remote-code',
|
|
164
|
+
category: 'security',
|
|
165
|
+
severity: 'error',
|
|
166
|
+
title: 'Hook downloads and executes remote code',
|
|
167
|
+
help: 'Vendor the script into the repo and run it from a pinned path. Hooks run automatically with your full user privileges and no confirmation, so whoever controls that URL controls your machine.',
|
|
168
|
+
check({ files, report, helpers }) {
|
|
169
|
+
eachHookCommand(files, helpers, ({ file, command, configPath, position, event }) => {
|
|
170
|
+
if (!command) return;
|
|
171
|
+
for (const { pattern, label } of REMOTE_EXEC_PATTERNS) {
|
|
172
|
+
if (pattern.test(command)) {
|
|
173
|
+
report({
|
|
174
|
+
file,
|
|
175
|
+
line: position.line,
|
|
176
|
+
column: position.column,
|
|
177
|
+
configPath,
|
|
178
|
+
snippet: command.slice(0, 120),
|
|
179
|
+
message: `${event} hook uses ${label}; the remote content is executed unreviewed on every trigger.`,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
{
|
|
189
|
+
id: 'security/hook-unpinned-path',
|
|
190
|
+
category: 'security',
|
|
191
|
+
severity: 'warning',
|
|
192
|
+
title: 'Hook command resolves through PATH or cwd',
|
|
193
|
+
help: 'Use an absolute path or "$CLAUDE_PROJECT_DIR/.claude/hooks/name.sh". A bare name resolves via PATH, so a same-named file earlier in PATH — or in a repo you clone — runs instead.',
|
|
194
|
+
check({ files, report, helpers }) {
|
|
195
|
+
eachHookCommand(files, helpers, ({ file, command, configPath, position }) => {
|
|
196
|
+
if (!command) return;
|
|
197
|
+
const first = command.trim().split(/\s+/)[0];
|
|
198
|
+
if (!first) return;
|
|
199
|
+
// Only flag things that look like a script the user wrote, not shell builtins
|
|
200
|
+
// or well-known binaries which are expected to come from PATH.
|
|
201
|
+
if (!/\.(sh|bash|zsh|py|js|mjs|cjs|ts|rb|pl)$/.test(first)) return;
|
|
202
|
+
if (isAbsolute(first)) return;
|
|
203
|
+
if (first.startsWith('$') || first.startsWith('~')) return;
|
|
204
|
+
report({
|
|
205
|
+
file,
|
|
206
|
+
line: position.line,
|
|
207
|
+
column: position.column,
|
|
208
|
+
configPath,
|
|
209
|
+
snippet: command.slice(0, 120),
|
|
210
|
+
message: `Hook runs "${first}" without an absolute path, so which file executes depends on PATH and the current directory.`,
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
id: 'security/hook-dangerous-command',
|
|
218
|
+
category: 'security',
|
|
219
|
+
severity: 'warning',
|
|
220
|
+
title: 'Hook runs a destructive command',
|
|
221
|
+
help: 'Hooks fire automatically with no confirmation step. Anything irreversible belongs in a command you invoke deliberately, not in a hook.',
|
|
222
|
+
check({ files, report, helpers }) {
|
|
223
|
+
eachHookCommand(files, helpers, ({ file, command, configPath, position, event }) => {
|
|
224
|
+
if (!command) return;
|
|
225
|
+
for (const { pattern, label } of DESTRUCTIVE_PATTERNS) {
|
|
226
|
+
if (pattern.test(command)) {
|
|
227
|
+
report({
|
|
228
|
+
file,
|
|
229
|
+
line: position.line,
|
|
230
|
+
column: position.column,
|
|
231
|
+
configPath,
|
|
232
|
+
snippet: command.slice(0, 120),
|
|
233
|
+
message: `${event} hook performs ${label} automatically on every trigger.`,
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
{
|
|
243
|
+
id: 'security/secret-in-config',
|
|
244
|
+
category: 'security',
|
|
245
|
+
severity: 'error',
|
|
246
|
+
title: 'Credential hardcoded in agent config',
|
|
247
|
+
help: 'Move the value to a secret manager or an untracked env file and reference it indirectly. Config files are committed, synced and shared far more often than people expect.',
|
|
248
|
+
check({ files, report, helpers }) {
|
|
249
|
+
for (const file of files) {
|
|
250
|
+
if (file.kind === 'hook') continue; // scripts are scanned by their own rule below
|
|
251
|
+
const lines = file.text.split(/\r?\n/);
|
|
252
|
+
lines.forEach((line, index) => {
|
|
253
|
+
if (/agentdoctor-allow-secret/.test(line)) return;
|
|
254
|
+
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
255
|
+
const match = pattern.exec(line);
|
|
256
|
+
if (!match) continue;
|
|
257
|
+
report({
|
|
258
|
+
file,
|
|
259
|
+
line: index + 1,
|
|
260
|
+
column: match.index + 1,
|
|
261
|
+
snippet: redact(match[0]),
|
|
262
|
+
message: `Looks like a live ${label} committed in ${file.display}.`,
|
|
263
|
+
});
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
{
|
|
272
|
+
id: 'security/dangerous-env-var',
|
|
273
|
+
category: 'security',
|
|
274
|
+
severity: 'warning',
|
|
275
|
+
title: 'Loader-influencing environment variable set',
|
|
276
|
+
help: 'Set these per-command instead of session-wide. Anything defined in settings.env applies to every process the agent spawns for the whole session.',
|
|
277
|
+
check({ files, report, helpers }) {
|
|
278
|
+
for (const file of files) {
|
|
279
|
+
if (file.kind !== 'settings') continue;
|
|
280
|
+
const env = file.data?.env;
|
|
281
|
+
if (!env || typeof env !== 'object' || Array.isArray(env)) continue;
|
|
282
|
+
for (const key of Object.keys(env)) {
|
|
283
|
+
const reason = DANGEROUS_ENV_VARS.get(key);
|
|
284
|
+
if (!reason) continue;
|
|
285
|
+
const position = helpers.at(file, `env.${key}`);
|
|
286
|
+
report({
|
|
287
|
+
file,
|
|
288
|
+
line: position.line,
|
|
289
|
+
column: position.column,
|
|
290
|
+
configPath: `env.${key}`,
|
|
291
|
+
message: `env.${key} ${reason}.`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
{
|
|
299
|
+
id: 'security/broad-additional-directory',
|
|
300
|
+
category: 'security',
|
|
301
|
+
severity: 'error',
|
|
302
|
+
title: 'Filesystem root granted as a working directory',
|
|
303
|
+
help: 'List only the specific sibling directories the agent needs. Granting "/" or your home directory hands it every SSH key, browser profile and other project on the machine.',
|
|
304
|
+
check({ files, report, helpers }) {
|
|
305
|
+
const broad = new Set(['/', '~', '~/', '/home', '/Users', '/etc', 'C:\\', '/var', '/usr']);
|
|
306
|
+
for (const file of files) {
|
|
307
|
+
if (file.kind !== 'settings') continue;
|
|
308
|
+
const dirs = file.data?.permissions?.additionalDirectories;
|
|
309
|
+
if (!Array.isArray(dirs)) continue;
|
|
310
|
+
dirs.forEach((dir, index) => {
|
|
311
|
+
if (typeof dir !== 'string') return;
|
|
312
|
+
const normalized = dir.trim().replace(/\/+$/, '') || '/';
|
|
313
|
+
if (!broad.has(dir.trim()) && !broad.has(normalized)) return;
|
|
314
|
+
const configPath = `permissions.additionalDirectories[${index}]`;
|
|
315
|
+
const position = helpers.at(file, configPath);
|
|
316
|
+
report({
|
|
317
|
+
file,
|
|
318
|
+
line: position.line,
|
|
319
|
+
column: position.column,
|
|
320
|
+
configPath,
|
|
321
|
+
snippet: dir,
|
|
322
|
+
message: `additionalDirectories includes "${dir}", which exposes the entire machine to file tools.`,
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
{
|
|
330
|
+
id: 'security/unrestricted-egress',
|
|
331
|
+
category: 'security',
|
|
332
|
+
severity: 'warning',
|
|
333
|
+
title: 'Unrestricted network egress pre-approved',
|
|
334
|
+
help: 'Scope WebFetch to the domains you actually need, e.g. "WebFetch(domain:docs.example.com)". An open fetch rule is a one-step path for anything in your context to leave the machine.',
|
|
335
|
+
check({ files, report, helpers }) {
|
|
336
|
+
eachPermission(files, helpers, ({ file, bucket, parsed, configPath, position, rule }) => {
|
|
337
|
+
if (bucket !== 'allow') return;
|
|
338
|
+
if (parsed.tool !== 'WebFetch') return;
|
|
339
|
+
if (!isWildcard(parsed.argument)) return;
|
|
340
|
+
report({
|
|
341
|
+
file,
|
|
342
|
+
line: position.line,
|
|
343
|
+
column: position.column,
|
|
344
|
+
configPath,
|
|
345
|
+
snippet: String(rule),
|
|
346
|
+
message: `"${rule}" allows fetching any URL without asking, which doubles as an exfiltration channel.`,
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
{
|
|
353
|
+
id: 'security/sensitive-read-allowed',
|
|
354
|
+
category: 'security',
|
|
355
|
+
severity: 'error',
|
|
356
|
+
title: 'Credential file explicitly readable',
|
|
357
|
+
help: 'Remove the rule and add the path to permissions.deny instead. Secrets read into context end up in transcripts, logs and any tool call the model makes next.',
|
|
358
|
+
check({ files, report, helpers }) {
|
|
359
|
+
// Two separate shapes: sensitive *paths*, which need a separator or start
|
|
360
|
+
// anchor so "development.env-notes" does not match, and sensitive
|
|
361
|
+
// *extensions*, which are meaningful wherever they end a path.
|
|
362
|
+
const sensitivePath = /(^|[\/\\.])(\.env(\.|$)|\.ssh[\/\\]|\.aws[\/\\]credentials|\.kube[\/\\]config|\.npmrc|\.netrc|id_rsa|id_ed25519|\.git-credentials|secrets?\.(ya?ml|json))/i;
|
|
363
|
+
const sensitiveExtension = /\.(pem|p12|pfx|key|jks|keystore)$/i;
|
|
364
|
+
const sensitive = (value) => sensitivePath.test(value) || sensitiveExtension.test(value);
|
|
365
|
+
eachPermission(files, helpers, ({ file, bucket, parsed, configPath, position, rule }) => {
|
|
366
|
+
if (bucket !== 'allow') return;
|
|
367
|
+
if (parsed.argument == null) return;
|
|
368
|
+
if (!sensitive(parsed.argument)) return;
|
|
369
|
+
report({
|
|
370
|
+
file,
|
|
371
|
+
line: position.line,
|
|
372
|
+
column: position.column,
|
|
373
|
+
configPath,
|
|
374
|
+
snippet: String(rule),
|
|
375
|
+
message: `"${rule}" pre-approves reading a credential file.`,
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
{
|
|
382
|
+
id: 'security/missing-secret-denies',
|
|
383
|
+
category: 'security',
|
|
384
|
+
severity: 'info',
|
|
385
|
+
title: 'No deny rules protecting secrets',
|
|
386
|
+
help: 'Add a deny list such as ["Read(./.env*)", "Read(**/.ssh/**)", "Read(**/*.pem)", "Read(**/.aws/credentials)"]. Deny rules are the only guardrail that survives an accepted prompt, since they are checked before anything runs.',
|
|
387
|
+
check({ files, report, helpers }) {
|
|
388
|
+
const settings = files.filter((f) => f.kind === 'settings' && f.data);
|
|
389
|
+
if (settings.length === 0) return;
|
|
390
|
+
const hasAnyDeny = settings.some((file) => {
|
|
391
|
+
const deny = file.data?.permissions?.deny;
|
|
392
|
+
if (!Array.isArray(deny)) return false;
|
|
393
|
+
return deny.some((rule) => typeof rule === 'string' && /\.env|ssh|pem|credential|secret|\.key/i.test(rule));
|
|
394
|
+
});
|
|
395
|
+
if (hasAnyDeny) return;
|
|
396
|
+
// Only worth mentioning if the project actually has secrets lying around.
|
|
397
|
+
const target = settings.find((f) => f.scope === 'project') ?? settings[0];
|
|
398
|
+
report({
|
|
399
|
+
file: target,
|
|
400
|
+
line: 1,
|
|
401
|
+
message: 'No deny rules cover .env, SSH keys or cloud credentials.',
|
|
402
|
+
});
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
{
|
|
407
|
+
id: 'security/mcp-unpinned-package',
|
|
408
|
+
category: 'security',
|
|
409
|
+
severity: 'warning',
|
|
410
|
+
title: 'MCP server runs an unpinned remote package',
|
|
411
|
+
help: 'Pin the exact version, e.g. "@scope/server@1.4.2". With "@latest" or no version, every session silently installs whatever was published most recently, including a compromised release.',
|
|
412
|
+
check({ files, report, helpers }) {
|
|
413
|
+
eachMcpServer(files, helpers, ({ file, name, server, basePath }) => {
|
|
414
|
+
const args = Array.isArray(server?.args) ? server.args : [];
|
|
415
|
+
const command = typeof server?.command === 'string' ? server.command : '';
|
|
416
|
+
const runner = /\b(npx|bunx|pnpx|uvx|pipx)\b/.test(command) || /\b(npx|bunx|pnpx|uvx|pipx)\b/.test(args[0] ?? '');
|
|
417
|
+
if (!runner) return;
|
|
418
|
+
const pkg = args.find((a) => typeof a === 'string' && !a.startsWith('-') && !/^(npx|bunx|pnpx|uvx|pipx)$/.test(a));
|
|
419
|
+
if (!pkg) return;
|
|
420
|
+
const pinned = /@\d+\.\d+\.\d+/.test(pkg) || /==\d/.test(pkg);
|
|
421
|
+
if (pinned) return;
|
|
422
|
+
const position = helpers.at(file, `${basePath}.args`);
|
|
423
|
+
report({
|
|
424
|
+
file,
|
|
425
|
+
line: position.line,
|
|
426
|
+
column: position.column,
|
|
427
|
+
configPath: `${basePath}.args`,
|
|
428
|
+
snippet: pkg,
|
|
429
|
+
message: `MCP server "${name}" launches "${pkg}" unpinned, so its code can change under you between sessions.`,
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
{
|
|
436
|
+
id: 'security/mcp-auto-enable-all',
|
|
437
|
+
category: 'security',
|
|
438
|
+
severity: 'warning',
|
|
439
|
+
title: 'Project MCP servers auto-enabled without review',
|
|
440
|
+
help: 'Leave this off and enable servers explicitly via enabledMcpjsonServers. Otherwise cloning a repo is enough to run its MCP servers on your machine.',
|
|
441
|
+
check({ files, report, helpers }) {
|
|
442
|
+
for (const file of files) {
|
|
443
|
+
if (file.kind !== 'settings' || file.data?.enableAllProjectMcpServers !== true) continue;
|
|
444
|
+
const position = helpers.at(file, 'enableAllProjectMcpServers');
|
|
445
|
+
report({
|
|
446
|
+
file,
|
|
447
|
+
line: position.line,
|
|
448
|
+
column: position.column,
|
|
449
|
+
configPath: 'enableAllProjectMcpServers',
|
|
450
|
+
message: 'enableAllProjectMcpServers trusts any .mcp.json shipped in a repo you open.',
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
{
|
|
457
|
+
id: 'security/mcp-plaintext-url-credential',
|
|
458
|
+
category: 'security',
|
|
459
|
+
severity: 'error',
|
|
460
|
+
title: 'Credential embedded in MCP server URL',
|
|
461
|
+
help: 'Move the token into a header sourced from the environment. URLs land in logs, crash reports and shell history.',
|
|
462
|
+
check({ files, report, helpers }) {
|
|
463
|
+
eachMcpServer(files, helpers, ({ file, name, server, basePath }) => {
|
|
464
|
+
const url = typeof server?.url === 'string' ? server.url : null;
|
|
465
|
+
if (!url) return;
|
|
466
|
+
if (!/[?&](token|key|api[_-]?key|access[_-]?token|secret|password)=[^&\s]{8,}/i.test(url)) return;
|
|
467
|
+
const position = helpers.at(file, `${basePath}.url`);
|
|
468
|
+
report({
|
|
469
|
+
file,
|
|
470
|
+
line: position.line,
|
|
471
|
+
column: position.column,
|
|
472
|
+
configPath: `${basePath}.url`,
|
|
473
|
+
message: `MCP server "${name}" carries a credential in its URL query string.`,
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
{
|
|
480
|
+
id: 'security/world-writable-config',
|
|
481
|
+
category: 'security',
|
|
482
|
+
severity: 'error',
|
|
483
|
+
title: 'Agent config writable by other users',
|
|
484
|
+
help: 'Run "chmod go-w" on the file. Any user who can write your agent config can add a hook, and hooks execute automatically as you.',
|
|
485
|
+
check({ files, report }) {
|
|
486
|
+
// Windows has no POSIX mode bits; Node synthesizes 0666, which would make
|
|
487
|
+
// this rule fire on every file. File ACLs there are a different model and
|
|
488
|
+
// outside what this rule can speak to.
|
|
489
|
+
if (process.platform === 'win32') return;
|
|
490
|
+
// Group-write is only meaningful when the group is shared. Most Linux
|
|
491
|
+
// distributions give each user a private group and a 002 umask, which
|
|
492
|
+
// makes mode 664 the harmless default rather than a finding.
|
|
493
|
+
const ownGid = typeof process.getgid === 'function' ? process.getgid() : null;
|
|
494
|
+
for (const file of files) {
|
|
495
|
+
const otherWritable = (file.mode & 0o002) !== 0;
|
|
496
|
+
const groupWritable = (file.mode & 0o020) !== 0;
|
|
497
|
+
const sharedGroup = groupWritable && ownGid !== null && file.gid !== ownGid;
|
|
498
|
+
if (!otherWritable && !sharedGroup) continue;
|
|
499
|
+
report({
|
|
500
|
+
file,
|
|
501
|
+
line: 1,
|
|
502
|
+
severity: otherWritable ? 'error' : 'warning',
|
|
503
|
+
message: otherWritable
|
|
504
|
+
? `${file.display} is world-writable (mode ${(file.mode & 0o777).toString(8)}), so any user on this machine can inject commands that run as you.`
|
|
505
|
+
: `${file.display} is writable by group ${file.gid} (mode ${(file.mode & 0o777).toString(8)}), which is not your primary group.`,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
|
|
511
|
+
{
|
|
512
|
+
id: 'security/hook-script-not-executable',
|
|
513
|
+
category: 'security',
|
|
514
|
+
severity: 'warning',
|
|
515
|
+
title: 'Hook script is world-writable or missing',
|
|
516
|
+
help: 'Keep hook scripts inside the repo, owned by you, and not group-writable.',
|
|
517
|
+
check({ files, report, helpers, workspace }) {
|
|
518
|
+
eachHookCommand(files, helpers, ({ file, command, configPath, position }) => {
|
|
519
|
+
if (!command) return;
|
|
520
|
+
const first = command.trim().split(/\s+/)[0]?.replace(/^["']|["']$/g, '');
|
|
521
|
+
if (!first) return;
|
|
522
|
+
if (!/\.(sh|bash|py|js|mjs|cjs)$/.test(first)) return;
|
|
523
|
+
const resolved = first.includes('$')
|
|
524
|
+
? first.replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, workspace.root)
|
|
525
|
+
: (isAbsolute(first) ? first : join(workspace.root, first));
|
|
526
|
+
if (first.includes('$') && /\$(?!\{?CLAUDE_PROJECT_DIR)/.test(first)) return; // unknown variable, can't resolve
|
|
527
|
+
if (!existsSync(resolved)) {
|
|
528
|
+
report({
|
|
529
|
+
file,
|
|
530
|
+
line: position.line,
|
|
531
|
+
column: position.column,
|
|
532
|
+
configPath,
|
|
533
|
+
severity: 'error',
|
|
534
|
+
message: `Hook script "${first}" does not exist at ${resolved}; the hook will fail every time it fires.`,
|
|
535
|
+
help: 'Fix the path, or remove the hook. A hook whose command is missing produces an error on every matching tool call.',
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// Same ownership logic as security/world-writable-config: group-write
|
|
540
|
+
// only matters when the group is shared. A git checkout under the
|
|
541
|
+
// common umask 002 produces mode 775, so flagging group-write outright
|
|
542
|
+
// would fire on every cloned repository. And Windows synthesizes 0666
|
|
543
|
+
// for every file, so the check is meaningless there.
|
|
544
|
+
if (process.platform === 'win32') return;
|
|
545
|
+
const stats = statSync(resolved);
|
|
546
|
+
const ownGid = typeof process.getgid === 'function' ? process.getgid() : null;
|
|
547
|
+
const otherWritable = (stats.mode & 0o002) !== 0;
|
|
548
|
+
const sharedGroup = (stats.mode & 0o020) !== 0 && ownGid !== null && stats.gid !== ownGid;
|
|
549
|
+
if (otherWritable || sharedGroup) {
|
|
550
|
+
report({
|
|
551
|
+
file,
|
|
552
|
+
line: position.line,
|
|
553
|
+
column: position.column,
|
|
554
|
+
configPath,
|
|
555
|
+
severity: otherWritable ? 'warning' : 'info',
|
|
556
|
+
message: otherWritable
|
|
557
|
+
? `Hook script "${first}" is world-writable (mode ${(stats.mode & 0o777).toString(8)}), so any user on this machine can change what it runs.`
|
|
558
|
+
: `Hook script "${first}" is writable by group ${stats.gid} (mode ${(stats.mode & 0o777).toString(8)}), which is not your primary group.`,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
{
|
|
566
|
+
id: 'security/apikeyhelper-inline-secret',
|
|
567
|
+
category: 'security',
|
|
568
|
+
severity: 'error',
|
|
569
|
+
title: 'apiKeyHelper echoes a literal key',
|
|
570
|
+
help: 'Point apiKeyHelper at a script that reads from your OS keychain or secret manager, rather than embedding the key in the command.',
|
|
571
|
+
check({ files, report, helpers }) {
|
|
572
|
+
for (const file of files) {
|
|
573
|
+
if (file.kind !== 'settings') continue;
|
|
574
|
+
const helper = file.data?.apiKeyHelper;
|
|
575
|
+
if (typeof helper !== 'string') continue;
|
|
576
|
+
const looksLiteral = SECRET_PATTERNS.some(({ pattern }) => pattern.test(helper))
|
|
577
|
+
|| /\becho\s+["']?[A-Za-z0-9_-]{24,}/.test(helper);
|
|
578
|
+
if (!looksLiteral) continue;
|
|
579
|
+
const position = helpers.at(file, 'apiKeyHelper');
|
|
580
|
+
report({
|
|
581
|
+
file,
|
|
582
|
+
line: position.line,
|
|
583
|
+
column: position.column,
|
|
584
|
+
configPath: 'apiKeyHelper',
|
|
585
|
+
message: 'apiKeyHelper appears to contain the key itself rather than a lookup command.',
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
{
|
|
592
|
+
id: 'security/deny-bucket-empty-with-broad-allow',
|
|
593
|
+
category: 'security',
|
|
594
|
+
severity: 'warning',
|
|
595
|
+
title: 'Broad allow list with no deny list',
|
|
596
|
+
help: 'Pair permissive allow rules with explicit denies. Deny is evaluated first and is the only rule class the model cannot talk its way past.',
|
|
597
|
+
check({ files, report, helpers }) {
|
|
598
|
+
for (const file of files) {
|
|
599
|
+
if (file.kind !== 'settings' || !file.data?.permissions) continue;
|
|
600
|
+
const perms = file.data.permissions;
|
|
601
|
+
const allow = Array.isArray(perms.allow) ? perms.allow : [];
|
|
602
|
+
const deny = Array.isArray(perms.deny) ? perms.deny : [];
|
|
603
|
+
if (deny.length > 0) continue;
|
|
604
|
+
const broadCount = allow.filter((rule) => {
|
|
605
|
+
const parsed = helpers.parsePermission(rule);
|
|
606
|
+
return isWildcard(parsed.argument) && (EGRESS_TOOLS.has(parsed.tool) || parsed.tool === 'Write' || parsed.tool === 'Edit');
|
|
607
|
+
}).length;
|
|
608
|
+
if (broadCount === 0) continue;
|
|
609
|
+
const position = helpers.at(file, 'permissions');
|
|
610
|
+
report({
|
|
611
|
+
file,
|
|
612
|
+
line: position.line,
|
|
613
|
+
column: position.column,
|
|
614
|
+
configPath: 'permissions',
|
|
615
|
+
message: `${broadCount} unrestricted allow rule(s) for tools that write files or reach the network, and permissions.deny is empty.`,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
{
|
|
622
|
+
id: 'security/bypass-mode-not-locked',
|
|
623
|
+
category: 'security',
|
|
624
|
+
severity: 'info',
|
|
625
|
+
title: 'Bypass mode not disabled for the project',
|
|
626
|
+
help: 'Set permissions.disableBypassPermissionsMode to "disable" in committed project settings to stop anyone opting out of prompts in this repo.',
|
|
627
|
+
check({ files, report, helpers }) {
|
|
628
|
+
const project = files.find((f) => f.kind === 'settings' && f.scope === 'project' && f.data);
|
|
629
|
+
if (!project) return;
|
|
630
|
+
const perms = project.data?.permissions;
|
|
631
|
+
if (!perms) return;
|
|
632
|
+
if (perms.disableBypassPermissionsMode === 'disable') return;
|
|
633
|
+
const denies = Array.isArray(perms.deny) ? perms.deny.length : 0;
|
|
634
|
+
if (denies === 0) return; // only suggest to teams already writing guardrails
|
|
635
|
+
const position = helpers.at(project, 'permissions');
|
|
636
|
+
report({
|
|
637
|
+
file: project,
|
|
638
|
+
line: position.line,
|
|
639
|
+
column: position.column,
|
|
640
|
+
configPath: 'permissions',
|
|
641
|
+
message: 'This project defines deny rules, but any contributor can still start a session in bypassPermissions mode and skip them.',
|
|
642
|
+
});
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
|
|
646
|
+
{
|
|
647
|
+
id: 'security/invalid-permission-mode',
|
|
648
|
+
category: 'security',
|
|
649
|
+
severity: 'error',
|
|
650
|
+
title: 'Unknown permission mode',
|
|
651
|
+
help: `Use one of: ${[...PERMISSION_MODES].join(', ')}. An unrecognised mode is ignored, so you silently fall back to the default.`,
|
|
652
|
+
check({ files, report, helpers }) {
|
|
653
|
+
for (const file of files) {
|
|
654
|
+
if (file.kind !== 'settings') continue;
|
|
655
|
+
const mode = file.data?.permissions?.defaultMode;
|
|
656
|
+
if (mode === undefined) continue;
|
|
657
|
+
if (typeof mode === 'string' && PERMISSION_MODES.has(mode)) continue;
|
|
658
|
+
const position = helpers.at(file, 'permissions.defaultMode');
|
|
659
|
+
report({
|
|
660
|
+
file,
|
|
661
|
+
line: position.line,
|
|
662
|
+
column: position.column,
|
|
663
|
+
configPath: 'permissions.defaultMode',
|
|
664
|
+
message: `permissions.defaultMode is ${JSON.stringify(mode)}, which is not a valid mode.`,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
];
|
|
670
|
+
|
|
671
|
+
/** Iterates MCP server definitions across .mcp.json and settings files. */
|
|
672
|
+
export function eachMcpServer(files, helpers, callback) {
|
|
673
|
+
for (const file of files) {
|
|
674
|
+
if (!file.data) continue;
|
|
675
|
+
const containers = [];
|
|
676
|
+
if (file.kind === 'mcp') containers.push(['mcpServers', file.data?.mcpServers]);
|
|
677
|
+
if (file.kind === 'settings') containers.push(['mcpServers', file.data?.mcpServers]);
|
|
678
|
+
for (const [key, servers] of containers) {
|
|
679
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) continue;
|
|
680
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
681
|
+
callback({ file, name, server, basePath: `${key}.${name}` });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function redact(value) {
|
|
688
|
+
if (value.length <= 10) return '***';
|
|
689
|
+
return `${value.slice(0, 6)}…${value.slice(-2)} (redacted)`;
|
|
690
|
+
}
|