@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,288 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseJsonWithPositions } from '../parse.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Team policy enforcement.
|
|
7
|
+
*
|
|
8
|
+
* A single repo can be audited by reading it. A fleet of repos needs a written
|
|
9
|
+
* standard that CI can check, which is what these rules do: they compare the
|
|
10
|
+
* discovered config against an `agentdoctor.policy.json` committed by whoever
|
|
11
|
+
* owns the standard. These rules only activate when a policy file exists, so
|
|
12
|
+
* they cost solo users nothing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const POLICY_FILENAMES = [
|
|
16
|
+
'agentdoctor.policy.json',
|
|
17
|
+
'.agentdoctor.policy.json',
|
|
18
|
+
'.claude/agentdoctor.policy.json',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} Policy
|
|
23
|
+
* @property {string[]} [requiredDeny] deny rules every project must carry
|
|
24
|
+
* @property {string[]} [forbiddenAllow] allow rules no project may carry (glob-ish)
|
|
25
|
+
* @property {string[]} [allowedMcpServers] whitelist of MCP server names
|
|
26
|
+
* @property {string[]} [requiredHooks] hook events that must be configured
|
|
27
|
+
* @property {number} [maxMemoryTokens] ceiling on always-on context
|
|
28
|
+
* @property {string[]} [forbiddenPermissionModes]
|
|
29
|
+
* @property {boolean} [requireBypassDisabled]
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Loads the policy for a workspace, if one is committed. */
|
|
33
|
+
export function loadPolicy(root, explicitPath) {
|
|
34
|
+
const candidates = explicitPath ? [explicitPath] : POLICY_FILENAMES.map((name) => join(root, name));
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
if (!existsSync(candidate)) continue;
|
|
37
|
+
try {
|
|
38
|
+
const { value } = parseJsonWithPositions(readFileSync(candidate, 'utf8'));
|
|
39
|
+
return { policy: value, path: candidate };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return { policy: null, path: candidate, error: error.message };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { policy: null, path: null };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Matches a policy pattern against a permission rule.
|
|
49
|
+
*
|
|
50
|
+
* A single `*` is literal, because permission rules contain `*` themselves and
|
|
51
|
+
* a policy entry of "Bash(*)" must mean exactly that rule rather than "every
|
|
52
|
+
* Bash rule". `**` is the wildcard: "Bash(**)" matches any Bash rule and
|
|
53
|
+
* "Bash(**sudo**)" matches any Bash rule mentioning sudo.
|
|
54
|
+
*/
|
|
55
|
+
export function matchesPattern(pattern, value) {
|
|
56
|
+
if (typeof pattern !== 'string' || typeof value !== 'string') return false;
|
|
57
|
+
if (pattern === value) return true;
|
|
58
|
+
if (!pattern.includes('**')) return false;
|
|
59
|
+
const escaped = pattern
|
|
60
|
+
.split('**')
|
|
61
|
+
.map((part) => part.replace(/[.*+^${}()|[\]\\?]/g, '\\$&'))
|
|
62
|
+
.join('[\\s\\S]*');
|
|
63
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function collectSettings(files) {
|
|
67
|
+
return files.filter((f) => f.kind === 'settings' && f.data);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const policyRules = [
|
|
71
|
+
{
|
|
72
|
+
id: 'policy/missing-required-deny',
|
|
73
|
+
category: 'policy',
|
|
74
|
+
severity: 'error',
|
|
75
|
+
title: 'Required deny rule is absent',
|
|
76
|
+
help: 'Add the rule to committed project settings. It is mandated by your agentdoctor.policy.json.',
|
|
77
|
+
check({ workspace, report, helpers }) {
|
|
78
|
+
const { policy } = workspace.policy ?? {};
|
|
79
|
+
const required = policy?.requiredDeny;
|
|
80
|
+
if (!Array.isArray(required) || required.length === 0) return;
|
|
81
|
+
const settings = collectSettings(workspace.files);
|
|
82
|
+
const present = new Set();
|
|
83
|
+
for (const file of settings) {
|
|
84
|
+
const deny = file.data?.permissions?.deny;
|
|
85
|
+
if (!Array.isArray(deny)) continue;
|
|
86
|
+
for (const rule of deny) if (typeof rule === 'string') present.add(rule.trim());
|
|
87
|
+
}
|
|
88
|
+
const target = settings.find((f) => f.scope === 'project')
|
|
89
|
+
?? settings[0]
|
|
90
|
+
?? { path: workspace.root, display: '.' };
|
|
91
|
+
for (const rule of required) {
|
|
92
|
+
if (typeof rule !== 'string') continue;
|
|
93
|
+
if ([...present].some((existing) => matchesPattern(rule, existing))) continue;
|
|
94
|
+
report({
|
|
95
|
+
file: target,
|
|
96
|
+
line: 1,
|
|
97
|
+
snippet: rule,
|
|
98
|
+
message: `Policy requires deny rule "${rule}", which is not configured anywhere.`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
id: 'policy/forbidden-allow',
|
|
106
|
+
category: 'policy',
|
|
107
|
+
severity: 'error',
|
|
108
|
+
title: 'Allow rule forbidden by policy',
|
|
109
|
+
help: 'Remove the rule or get the policy amended. Policy exists so this decision is made once, centrally, instead of per repo.',
|
|
110
|
+
check({ workspace, report, helpers }) {
|
|
111
|
+
const { policy } = workspace.policy ?? {};
|
|
112
|
+
const forbidden = policy?.forbiddenAllow;
|
|
113
|
+
if (!Array.isArray(forbidden) || forbidden.length === 0) return;
|
|
114
|
+
for (const file of collectSettings(workspace.files)) {
|
|
115
|
+
const allow = file.data?.permissions?.allow;
|
|
116
|
+
if (!Array.isArray(allow)) continue;
|
|
117
|
+
allow.forEach((rule, index) => {
|
|
118
|
+
if (typeof rule !== 'string') return;
|
|
119
|
+
const hit = forbidden.find((pattern) => typeof pattern === 'string' && matchesPattern(pattern, rule.trim()));
|
|
120
|
+
if (!hit) return;
|
|
121
|
+
const configPath = `permissions.allow[${index}]`;
|
|
122
|
+
const position = helpers.at(file, configPath);
|
|
123
|
+
report({
|
|
124
|
+
file,
|
|
125
|
+
line: position.line,
|
|
126
|
+
column: position.column,
|
|
127
|
+
configPath,
|
|
128
|
+
snippet: rule,
|
|
129
|
+
message: `"${rule}" is forbidden by policy pattern "${hit}".`,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
id: 'policy/unapproved-mcp-server',
|
|
138
|
+
category: 'policy',
|
|
139
|
+
severity: 'error',
|
|
140
|
+
title: 'MCP server not on the approved list',
|
|
141
|
+
help: 'MCP servers run code and see your context. Add the server to allowedMcpServers in policy once it has been reviewed.',
|
|
142
|
+
check({ workspace, report, helpers }) {
|
|
143
|
+
const { policy } = workspace.policy ?? {};
|
|
144
|
+
const allowed = policy?.allowedMcpServers;
|
|
145
|
+
if (!Array.isArray(allowed)) return;
|
|
146
|
+
const allowedSet = new Set(allowed);
|
|
147
|
+
for (const file of workspace.files) {
|
|
148
|
+
const servers = file.data?.mcpServers;
|
|
149
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) continue;
|
|
150
|
+
for (const name of Object.keys(servers)) {
|
|
151
|
+
if (allowedSet.has(name) || allowed.some((p) => matchesPattern(p, name))) continue;
|
|
152
|
+
const configPath = `mcpServers.${name}`;
|
|
153
|
+
const position = helpers.at(file, configPath);
|
|
154
|
+
report({
|
|
155
|
+
file,
|
|
156
|
+
line: position.line,
|
|
157
|
+
column: position.column,
|
|
158
|
+
configPath,
|
|
159
|
+
snippet: name,
|
|
160
|
+
message: `MCP server "${name}" is not in the approved list.`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
id: 'policy/required-hook-missing',
|
|
169
|
+
category: 'policy',
|
|
170
|
+
severity: 'error',
|
|
171
|
+
title: 'Mandated guardrail hook is missing',
|
|
172
|
+
help: 'Policy requires this hook event to be configured. Copy it from your organisation template.',
|
|
173
|
+
check({ workspace, report }) {
|
|
174
|
+
const { policy } = workspace.policy ?? {};
|
|
175
|
+
const required = policy?.requiredHooks;
|
|
176
|
+
if (!Array.isArray(required) || required.length === 0) return;
|
|
177
|
+
const settings = collectSettings(workspace.files);
|
|
178
|
+
const configured = new Set();
|
|
179
|
+
for (const file of settings) {
|
|
180
|
+
const hooks = file.data?.hooks;
|
|
181
|
+
if (!hooks || typeof hooks !== 'object') continue;
|
|
182
|
+
for (const event of Object.keys(hooks)) configured.add(event);
|
|
183
|
+
}
|
|
184
|
+
const target = settings.find((f) => f.scope === 'project') ?? settings[0] ?? { path: workspace.root, display: '.' };
|
|
185
|
+
for (const event of required) {
|
|
186
|
+
if (configured.has(event)) continue;
|
|
187
|
+
report({ file: target, line: 1, snippet: event, message: `Policy requires a ${event} hook; none is configured.` });
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
{
|
|
193
|
+
id: 'policy/memory-budget-exceeded',
|
|
194
|
+
category: 'policy',
|
|
195
|
+
severity: 'error',
|
|
196
|
+
title: 'Always-on context exceeds the policy budget',
|
|
197
|
+
help: 'Trim the memory files or raise maxMemoryTokens deliberately. A context budget is the only thing that stops CLAUDE.md growing without limit.',
|
|
198
|
+
check({ workspace, report, helpers }) {
|
|
199
|
+
const { policy } = workspace.policy ?? {};
|
|
200
|
+
const max = policy?.maxMemoryTokens;
|
|
201
|
+
if (typeof max !== 'number' || max <= 0) return;
|
|
202
|
+
const memories = workspace.files.filter((f) => f.kind === 'memory' && f.scope !== 'user');
|
|
203
|
+
if (memories.length === 0) return;
|
|
204
|
+
const total = memories.reduce((sum, f) => sum + helpers.estimateTokens(f.text), 0);
|
|
205
|
+
if (total <= max) return;
|
|
206
|
+
const biggest = memories.map((f) => ({ f, t: helpers.estimateTokens(f.text) })).sort((a, b) => b.t - a.t)[0];
|
|
207
|
+
report({
|
|
208
|
+
file: biggest.f,
|
|
209
|
+
line: 1,
|
|
210
|
+
message: `Project memory is ~${total.toLocaleString('en-US')} tokens, over the policy budget of ${max.toLocaleString('en-US')}. Largest contributor: ${biggest.f.display} (~${biggest.t.toLocaleString('en-US')}).`,
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
id: 'policy/forbidden-permission-mode',
|
|
217
|
+
category: 'policy',
|
|
218
|
+
severity: 'error',
|
|
219
|
+
title: 'Permission mode forbidden by policy',
|
|
220
|
+
help: 'Change defaultMode to a mode your policy permits.',
|
|
221
|
+
check({ workspace, report, helpers }) {
|
|
222
|
+
const { policy } = workspace.policy ?? {};
|
|
223
|
+
const forbidden = policy?.forbiddenPermissionModes;
|
|
224
|
+
if (!Array.isArray(forbidden) || forbidden.length === 0) return;
|
|
225
|
+
for (const file of collectSettings(workspace.files)) {
|
|
226
|
+
const mode = file.data?.permissions?.defaultMode;
|
|
227
|
+
if (typeof mode !== 'string' || !forbidden.includes(mode)) continue;
|
|
228
|
+
const position = helpers.at(file, 'permissions.defaultMode');
|
|
229
|
+
report({
|
|
230
|
+
file,
|
|
231
|
+
line: position.line,
|
|
232
|
+
column: position.column,
|
|
233
|
+
configPath: 'permissions.defaultMode',
|
|
234
|
+
message: `defaultMode "${mode}" is forbidden by policy.`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
{
|
|
241
|
+
id: 'policy/permission-drift',
|
|
242
|
+
category: 'policy',
|
|
243
|
+
severity: 'warning',
|
|
244
|
+
title: 'Local overrides widen the committed permission set',
|
|
245
|
+
help: 'Local settings are invisible in review. If a rule is genuinely needed, put it in project settings so the team sees it; if it is personal, keep it narrow.',
|
|
246
|
+
check({ workspace, report, helpers }) {
|
|
247
|
+
const project = workspace.files.find((f) => f.kind === 'settings' && f.scope === 'project' && f.data);
|
|
248
|
+
const local = workspace.files.find((f) => f.kind === 'settings' && f.scope === 'local' && f.data);
|
|
249
|
+
if (!project || !local) return;
|
|
250
|
+
const projectAllow = new Set((project.data?.permissions?.allow ?? []).filter((r) => typeof r === 'string'));
|
|
251
|
+
const localAllow = local.data?.permissions?.allow;
|
|
252
|
+
if (!Array.isArray(localAllow)) return;
|
|
253
|
+
localAllow.forEach((rule, index) => {
|
|
254
|
+
if (typeof rule !== 'string' || projectAllow.has(rule)) return;
|
|
255
|
+
const parsed = helpers.parsePermission(rule);
|
|
256
|
+
const broad = parsed.argument === null || ['*', '**', ':*'].includes(String(parsed.argument).trim());
|
|
257
|
+
if (!broad) return;
|
|
258
|
+
const configPath = `permissions.allow[${index}]`;
|
|
259
|
+
const position = helpers.at(local, configPath);
|
|
260
|
+
report({
|
|
261
|
+
file: local,
|
|
262
|
+
line: position.line,
|
|
263
|
+
column: position.column,
|
|
264
|
+
configPath,
|
|
265
|
+
snippet: rule,
|
|
266
|
+
message: `Local settings add unrestricted rule "${rule}" that the committed project config does not grant.`,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
{
|
|
273
|
+
id: 'policy/file-invalid',
|
|
274
|
+
category: 'policy',
|
|
275
|
+
severity: 'error',
|
|
276
|
+
title: 'Policy file could not be read',
|
|
277
|
+
help: 'A policy that fails to parse enforces nothing, which is the most dangerous state for a guardrail to be in.',
|
|
278
|
+
check({ workspace, report }) {
|
|
279
|
+
const { path, error } = workspace.policy ?? {};
|
|
280
|
+
if (!path || !error) return;
|
|
281
|
+
report({
|
|
282
|
+
file: { path, display: path },
|
|
283
|
+
line: 1,
|
|
284
|
+
message: `Policy file failed to parse: ${error}. No policy rules were enforced.`,
|
|
285
|
+
});
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
];
|