@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
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jqntn/agentdoctor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lint and grade your AI coding-agent configuration: permissions, hooks, MCP servers, skills, subagents and CLAUDE.md. Catches silent failures, security holes and context waste, with fixes. Zero dependencies, no telemetry.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"agent-config",
|
|
8
|
+
"agents-md",
|
|
9
|
+
"ai-agent",
|
|
10
|
+
"audit",
|
|
11
|
+
"ci",
|
|
12
|
+
"claude",
|
|
13
|
+
"claude-code",
|
|
14
|
+
"claude-md",
|
|
15
|
+
"cli",
|
|
16
|
+
"config-audit",
|
|
17
|
+
"developer-experience",
|
|
18
|
+
"devtools",
|
|
19
|
+
"grade",
|
|
20
|
+
"hooks",
|
|
21
|
+
"lint",
|
|
22
|
+
"linter",
|
|
23
|
+
"llms-txt",
|
|
24
|
+
"mcp",
|
|
25
|
+
"model-context-protocol",
|
|
26
|
+
"permissions",
|
|
27
|
+
"sarif",
|
|
28
|
+
"security",
|
|
29
|
+
"static-analysis"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"author": "Julien QUENTIN <jqntn88@gmail.com>",
|
|
33
|
+
"homepage": "https://jqntn.github.io/agentdoctor/",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/jqntn/agentdoctor.git"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/jqntn/agentdoctor/issues"
|
|
40
|
+
},
|
|
41
|
+
"type": "module",
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20"
|
|
44
|
+
},
|
|
45
|
+
"bin": {
|
|
46
|
+
"agentdoctor": "bin/agentdoctor.js"
|
|
47
|
+
},
|
|
48
|
+
"main": "src/index.js",
|
|
49
|
+
"exports": {
|
|
50
|
+
".": "./src/index.js",
|
|
51
|
+
"./rules": "./src/rules/index.js"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"bin",
|
|
55
|
+
"src",
|
|
56
|
+
"README.md",
|
|
57
|
+
"LICENSE",
|
|
58
|
+
"docs",
|
|
59
|
+
"schemas",
|
|
60
|
+
"skills"
|
|
61
|
+
],
|
|
62
|
+
"scripts": {
|
|
63
|
+
"test": "node --test",
|
|
64
|
+
"selfcheck": "node bin/agentdoctor.js --version",
|
|
65
|
+
"test:coverage": "node --test --experimental-test-coverage",
|
|
66
|
+
"docs": "node tools/gen-docs.mjs && node tools/build-site.mjs",
|
|
67
|
+
"commits": "node tools/check-commits.mjs HEAD"
|
|
68
|
+
},
|
|
69
|
+
"dependencies": {},
|
|
70
|
+
"devDependencies": {}
|
|
71
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://jqntn.github.io/agentdoctor/schemas/policy.schema.json",
|
|
4
|
+
"title": "agentdoctor team policy",
|
|
5
|
+
"description": "Org-wide standards for AI coding-agent configuration, enforced by the policy/* rules when this file is committed at the repo root as agentdoctor.policy.json.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"requiredDeny": {
|
|
9
|
+
"type": "array", "items": { "type": "string" },
|
|
10
|
+
"description": "Permission deny rules every repo must carry, e.g. \"Read(./.env*)\". A single * is literal; ** is the wildcard."
|
|
11
|
+
},
|
|
12
|
+
"forbiddenAllow": {
|
|
13
|
+
"type": "array", "items": { "type": "string" },
|
|
14
|
+
"description": "Permission allow rules no repo may carry, e.g. \"Bash(*)\" (that exact rule) or \"Bash(**sudo**)\" (any Bash rule mentioning sudo)."
|
|
15
|
+
},
|
|
16
|
+
"allowedMcpServers": {
|
|
17
|
+
"type": "array", "items": { "type": "string" },
|
|
18
|
+
"description": "Approved MCP server names (** wildcards allowed). If present, unlisted servers are errors. Omit the field to skip the check; an empty array approves none."
|
|
19
|
+
},
|
|
20
|
+
"requiredHooks": {
|
|
21
|
+
"type": "array",
|
|
22
|
+
"items": { "enum": ["PreToolUse", "PostToolUse", "UserPromptSubmit", "Notification", "Stop", "SubagentStop", "PreCompact", "SessionStart", "SessionEnd"] },
|
|
23
|
+
"description": "Hook events that must be configured in every repo."
|
|
24
|
+
},
|
|
25
|
+
"maxMemoryTokens": {
|
|
26
|
+
"type": "number", "exclusiveMinimum": 0,
|
|
27
|
+
"description": "Ceiling on the estimated token size of project memory files (CLAUDE.md et al., user scope excluded)."
|
|
28
|
+
},
|
|
29
|
+
"forbiddenPermissionModes": {
|
|
30
|
+
"type": "array",
|
|
31
|
+
"items": { "enum": ["default", "acceptEdits", "plan", "bypassPermissions"] },
|
|
32
|
+
"description": "Values of permissions.defaultMode that no repo may set. Usually [\"bypassPermissions\"]."
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"additionalProperties": true
|
|
36
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://jqntn.github.io/agentdoctor/schemas/report.schema.json",
|
|
4
|
+
"title": "agentdoctor JSON report (format version 1)",
|
|
5
|
+
"description": "Shape of `agentdoctor --json`. Additions are the only change ever made to version 1.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["version", "tool", "toolVersion", "root", "scannedFiles", "skippedFiles", "rulesRun", "suppressed", "summary", "findings"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"version": { "const": 1 },
|
|
10
|
+
"tool": { "const": "agentdoctor" },
|
|
11
|
+
"toolVersion": { "type": "string" },
|
|
12
|
+
"root": { "type": "string" },
|
|
13
|
+
"scannedFiles": {
|
|
14
|
+
"type": "array",
|
|
15
|
+
"items": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["path", "kind", "scope", "bytes"],
|
|
18
|
+
"properties": {
|
|
19
|
+
"path": { "type": "string" },
|
|
20
|
+
"kind": { "enum": ["settings", "mcp", "memory", "agent", "skill", "command", "hook", "keybindings"] },
|
|
21
|
+
"scope": { "enum": ["project", "local", "user", "enterprise"] },
|
|
22
|
+
"bytes": { "type": "integer" }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"skippedFiles": { "type": "array", "items": { "type": "string" } },
|
|
27
|
+
"rulesRun": { "type": "array", "items": { "type": "string" } },
|
|
28
|
+
"suppressed": { "type": "integer" },
|
|
29
|
+
"grade": { "enum": ["A+", "A", "B", "C", "D", "F"], "description": "Health grade. A+ zero findings; A info only; B 1-2 warnings; C 3+ warnings; D 1-2 errors; F 3+ errors." },
|
|
30
|
+
"summary": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"required": ["error", "warning", "info"],
|
|
33
|
+
"properties": {
|
|
34
|
+
"error": { "type": "integer" }, "warning": { "type": "integer" }, "info": { "type": "integer" }
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"findings": {
|
|
38
|
+
"type": "array",
|
|
39
|
+
"items": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"required": ["ruleId", "severity", "category", "message", "file", "absolutePath", "line"],
|
|
42
|
+
"properties": {
|
|
43
|
+
"ruleId": { "type": "string", "pattern": "^[a-z]+/[a-z0-9-]+$" },
|
|
44
|
+
"severity": { "enum": ["error", "warning", "info"] },
|
|
45
|
+
"category": { "enum": ["correctness", "security", "cost", "hygiene", "policy", "internal"] },
|
|
46
|
+
"message": { "type": "string" },
|
|
47
|
+
"help": { "type": ["string", "null"] },
|
|
48
|
+
"file": { "type": "string", "description": "Display path (repo-relative or ~-prefixed)" },
|
|
49
|
+
"absolutePath": { "type": "string" },
|
|
50
|
+
"line": { "type": "integer", "minimum": 1 },
|
|
51
|
+
"column": { "type": ["integer", "null"] },
|
|
52
|
+
"configPath": { "type": ["string", "null"] },
|
|
53
|
+
"snippet": { "type": ["string", "null"], "description": "Offending value; secrets are redacted" }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: config-audit
|
|
3
|
+
description: Audit and fix AI coding-agent configuration with agentdoctor. Use whenever the user asks to audit, lint, review, secure, or fix their agent config, .claude directory, permissions, hooks, MCP servers, skills, subagents, or CLAUDE.md; after any edit to those files; when a hook "isn't firing" or a permission rule "isn't working"; or when the user asks for their config grade. Requires only npx.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Audit agent configuration with agentdoctor
|
|
7
|
+
|
|
8
|
+
agentdoctor is a zero-dependency linter for agent config (72 rules: security, correctness,
|
|
9
|
+
cost, hygiene, team policy). It makes no network calls and never opens credential files, so it
|
|
10
|
+
is safe to run unattended in any project.
|
|
11
|
+
|
|
12
|
+
## Run the audit
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npx @jqntn/agentdoctor . --no-user --json
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Nothing is installed by this skill itself: `npx` uses the project's own `agentdoctor` if one
|
|
19
|
+
is in `node_modules`, and otherwise fetches it on first use (then caches it). If the user
|
|
20
|
+
wants it permanent and versioned, offer `npm install -D @jqntn/agentdoctor`; if `npx` cannot fetch
|
|
21
|
+
(offline/registry-blocked environment) and there is no local install, say so and stop rather
|
|
22
|
+
than improvising an audit by hand.
|
|
23
|
+
|
|
24
|
+
Exit codes: `0` clean, `1` findings with severity error exist, `2` usage error. **Exit 1 is a
|
|
25
|
+
successful audit** — valid JSON is still on stdout; only exit 2 means the run itself failed.
|
|
26
|
+
|
|
27
|
+
Key fields per finding (already sorted most-severe-first):
|
|
28
|
+
|
|
29
|
+
| Field | Use it to |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `severity` | `error` > `warning` > `info` |
|
|
32
|
+
| `message` | explain the problem to the user in one line |
|
|
33
|
+
| `help` | apply the fix — it states what to change and why |
|
|
34
|
+
| `file` + `line` | locate the edit |
|
|
35
|
+
| `configPath` | the exact config key, e.g. `permissions.allow[2]` |
|
|
36
|
+
| `snippet` | the offending value (secrets arrive redacted) |
|
|
37
|
+
| top-level `grade` | `A+` (clean) through `F` (3+ errors) — report it to the user |
|
|
38
|
+
|
|
39
|
+
## Fix loop
|
|
40
|
+
|
|
41
|
+
For each finding, most severe first:
|
|
42
|
+
|
|
43
|
+
1. State the problem in one line (use `message`).
|
|
44
|
+
2. Edit `file` at `line`, guided by `help`; `configPath` names the exact key. Typical fixes
|
|
45
|
+
are in `references/fix-recipes.md` — read it when a fix is not obvious from `help`.
|
|
46
|
+
3. If the finding is clearly intentional for this project, do NOT silently skip it: add a
|
|
47
|
+
suppression comment to that file and tell the user why:
|
|
48
|
+
`agentdoctor-disable <ruleId>`
|
|
49
|
+
|
|
50
|
+
Then verify:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npx @jqntn/agentdoctor . --no-user --quiet
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Exit 0 means clean. Re-run the JSON audit if it is still 1 and continue.
|
|
57
|
+
|
|
58
|
+
## Hard rules
|
|
59
|
+
|
|
60
|
+
- **Never delete or weaken a `deny` rule to silence a finding.** Fix the rule (usually a
|
|
61
|
+
casing or tool-name typo) so it actually blocks what it names.
|
|
62
|
+
- **Never widen an allow rule as a "fix".** Findings about broad rules are fixed by
|
|
63
|
+
narrowing, moving to `permissions.ask`, or adding deny rules.
|
|
64
|
+
- **Never echo secrets.** Findings arrive redacted; keep them that way in your summary.
|
|
65
|
+
- If more than ~10 findings exist, fix all errors, then ask the user whether to continue
|
|
66
|
+
with warnings or record a baseline instead (see `references/fix-recipes.md`).
|
|
67
|
+
|
|
68
|
+
## Report back
|
|
69
|
+
|
|
70
|
+
End with: the grade before and after, what you fixed (rule ids), what you suppressed and why,
|
|
71
|
+
and anything needing a human decision. If the user wants to track this in CI or show the
|
|
72
|
+
grade, the one-command options are `npx @jqntn/agentdoctor --init-ci` and `npx @jqntn/agentdoctor --badge`.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Fix recipes
|
|
2
|
+
|
|
3
|
+
Read this when a finding's `help` text is not enough to act on. Recipes are grouped by rule
|
|
4
|
+
id prefix. Full rationale for any rule: `npx @jqntn/agentdoctor --explain <rule-id>`.
|
|
5
|
+
|
|
6
|
+
## security/unrestricted-bash, security/unrestricted-egress
|
|
7
|
+
|
|
8
|
+
Replace the wildcard with the narrowest set of commands the project genuinely runs
|
|
9
|
+
unattended. Derive them from the project itself (package.json scripts, Makefile, CI):
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
"allow": ["Bash(npm test:*)", "Bash(npm run lint)", "Bash(git status)", "Bash(git diff:*)"]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For WebFetch, scope to domains: `"WebFetch(domain:docs.example.com)"`. If the user insists on
|
|
16
|
+
a blanket rule, move it to `permissions.ask` instead of `allow` so a prompt survives.
|
|
17
|
+
|
|
18
|
+
## security/destructive-allow
|
|
19
|
+
|
|
20
|
+
Move the rule from `allow` to `ask`. If only part of the command is dangerous, split it:
|
|
21
|
+
allow the safe subset (`Bash(git push origin main)`), ask for the rest (`Bash(git push:*)`).
|
|
22
|
+
|
|
23
|
+
## security/sensitive-read-allowed, security/missing-secret-denies
|
|
24
|
+
|
|
25
|
+
Remove the offending allow rule, then ensure the deny list contains at least:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
"deny": ["Read(./.env*)", "Read(**/.ssh/**)", "Read(**/*.pem)", "Read(**/.aws/credentials)"]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## security/secret-in-config
|
|
32
|
+
|
|
33
|
+
Move the value into the environment or a secret manager, reference it indirectly, and tell
|
|
34
|
+
the user to **rotate the credential** — it must be treated as leaked once committed. Do not
|
|
35
|
+
just delete the line; the value remains in git history.
|
|
36
|
+
|
|
37
|
+
## security/hook-remote-code
|
|
38
|
+
|
|
39
|
+
Download the remote script once, commit it under `.claude/hooks/`, make it executable, and
|
|
40
|
+
point the hook at `$CLAUDE_PROJECT_DIR/.claude/hooks/<name>.sh`. Show the user the script
|
|
41
|
+
content before committing it.
|
|
42
|
+
|
|
43
|
+
## security/mcp-unpinned-package
|
|
44
|
+
|
|
45
|
+
Pin the exact version currently in use: run `npm view <pkg> version` (or check the lockfile)
|
|
46
|
+
and change `@latest`/no-version to `@<that version>`.
|
|
47
|
+
|
|
48
|
+
## correctness/* typos (unknown-settings-key, unknown-hook-event, permission-unknown-tool, hook-matcher-unknown-tool, invalid-model)
|
|
49
|
+
|
|
50
|
+
The message contains a `Did you mean "X"` suggestion — apply exactly that casing. These are
|
|
51
|
+
the highest-value fixes in the catalogue: each one turns dead config back on.
|
|
52
|
+
|
|
53
|
+
## correctness/invalid-json
|
|
54
|
+
|
|
55
|
+
Fix the syntax error at the reported line/column (common: trailing comma before `}`, missing
|
|
56
|
+
comma, unquoted key). The whole file is ignored until this parses, so fix it first — other
|
|
57
|
+
findings in that file only appear on the next run.
|
|
58
|
+
|
|
59
|
+
## correctness/agent-* and skill-* mismatches
|
|
60
|
+
|
|
61
|
+
Rename to agree: frontmatter `name` should match the filename (agents) or directory (skills).
|
|
62
|
+
Prefer renaming the frontmatter unless other config references the old name.
|
|
63
|
+
|
|
64
|
+
## cost/memory-file-too-large, cost/memory-contains-generated-content
|
|
65
|
+
|
|
66
|
+
Move reference material (pasted code, long command lists, API docs) out of CLAUDE.md into a
|
|
67
|
+
regular file, and replace it with one line: `See docs/<topic>.md for <thing>.` Keep in
|
|
68
|
+
CLAUDE.md only what must apply to every session: build/test commands, conventions, hard
|
|
69
|
+
invariants.
|
|
70
|
+
|
|
71
|
+
## cost/duplicated-memory-instructions
|
|
72
|
+
|
|
73
|
+
Keep the instruction in the more general file (usually the repo root CLAUDE.md); delete the
|
|
74
|
+
copy. Never keep both — they drift until they contradict.
|
|
75
|
+
|
|
76
|
+
## Adopting on a repo with many findings (baseline)
|
|
77
|
+
|
|
78
|
+
If the user wants CI green today without fixing the backlog:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
npx @jqntn/agentdoctor --no-user --write-baseline .agentdoctor-baseline.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Commit the baseline. CI then runs with `--baseline .agentdoctor-baseline.json` and fails only
|
|
85
|
+
on new findings. Fix errors before baselining if at all possible — baselines are for debt,
|
|
86
|
+
not for hazards.
|
|
87
|
+
|
|
88
|
+
## Setting up a team policy
|
|
89
|
+
|
|
90
|
+
If the user manages multiple repos, offer:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
npx @jqntn/agentdoctor --init-policy
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Then edit `agentdoctor.policy.json` with them. Wildcards: a single `*` is literal (matches
|
|
97
|
+
exactly that rule text); `**` is the wildcard (`Bash(**sudo**)` = any Bash rule mentioning
|
|
98
|
+
sudo).
|
|
99
|
+
|
|
100
|
+
## Permanent installation
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
npx @jqntn/agentdoctor --init-ci # GitHub Actions: SARIF PR annotations + error gate
|
|
104
|
+
npx @jqntn/agentdoctor --badge # README badge showing the current grade
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Both are overwrite-safe (they refuse if the file exists).
|
package/src/adopt.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adoption commands: the one-command paths from "ran it once" to "installed".
|
|
3
|
+
*
|
|
4
|
+
* Each writes exactly one well-known file, refuses to overwrite, and prints
|
|
5
|
+
* what it did plus the next step. They exist because the gap between a
|
|
6
|
+
* drive-by `npx @jqntn/agentdoctor` and a permanent CI check is where most tools
|
|
7
|
+
* lose people (and agents) - these close that gap in a single action that is
|
|
8
|
+
* safe to run unattended.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, writeFileSync, readFileSync, mkdirSync, cpSync } from 'node:fs';
|
|
11
|
+
import { join, dirname } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { computeGrade, GRADE_COLORS } from './grade.js';
|
|
14
|
+
import { REPO_URL, BADGE_BASE_URL } from './links.js';
|
|
15
|
+
|
|
16
|
+
export const CI_WORKFLOW_PATH = '.github/workflows/agentdoctor.yml';
|
|
17
|
+
|
|
18
|
+
const CI_WORKFLOW = `name: agentdoctor
|
|
19
|
+
|
|
20
|
+
on:
|
|
21
|
+
pull_request:
|
|
22
|
+
push:
|
|
23
|
+
branches: [main]
|
|
24
|
+
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
security-events: write
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
audit:
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v4
|
|
34
|
+
- uses: actions/setup-node@v4
|
|
35
|
+
with:
|
|
36
|
+
node-version: 22
|
|
37
|
+
|
|
38
|
+
# Findings annotate the PR diff via code scanning.
|
|
39
|
+
- run: npx @jqntn/agentdoctor --no-user --sarif > agentdoctor.sarif
|
|
40
|
+
continue-on-error: true
|
|
41
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
42
|
+
with:
|
|
43
|
+
sarif_file: agentdoctor.sarif
|
|
44
|
+
continue-on-error: true
|
|
45
|
+
|
|
46
|
+
# The actual gate: exit 1 on errors.
|
|
47
|
+
# Adopting on a repo with existing findings? Commit a baseline first:
|
|
48
|
+
# npx @jqntn/agentdoctor --no-user --write-baseline .agentdoctor-baseline.json
|
|
49
|
+
# then change the line below to:
|
|
50
|
+
# npx @jqntn/agentdoctor --no-user --baseline .agentdoctor-baseline.json --quiet
|
|
51
|
+
- run: npx @jqntn/agentdoctor --no-user --quiet
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
export const SKILL_PATH = '.claude/skills/config-audit';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The canonical skill ships inside the package at skills/config-audit/ - the
|
|
58
|
+
* same files served by the plugin marketplace - so an installed skill can
|
|
59
|
+
* never drift from the published one.
|
|
60
|
+
*/
|
|
61
|
+
const PACKAGED_SKILL = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'config-audit');
|
|
62
|
+
|
|
63
|
+
/** Writes a file if absent. Returns a { written, path, message } outcome. */
|
|
64
|
+
function writeOnce(root, relative, contents) {
|
|
65
|
+
const target = join(root, relative);
|
|
66
|
+
if (existsSync(target)) {
|
|
67
|
+
return { written: false, path: relative, message: `${relative} already exists; not overwriting.` };
|
|
68
|
+
}
|
|
69
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
70
|
+
writeFileSync(target, contents);
|
|
71
|
+
return { written: true, path: relative, message: `Wrote ${relative}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function initCi(root) {
|
|
75
|
+
return writeOnce(root, CI_WORKFLOW_PATH, CI_WORKFLOW);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const AGENTS_PATH = 'AGENTS.md';
|
|
79
|
+
const AGENTS_MARKER_START = '<!-- agentdoctor:start -->';
|
|
80
|
+
const AGENTS_MARKER_END = '<!-- agentdoctor:end -->';
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The section is deliberately short: AGENTS.md rides along in every session's
|
|
84
|
+
* context for the agents that read it, so this must not become the kind of
|
|
85
|
+
* memory bloat agentdoctor's own cost rules exist to flag.
|
|
86
|
+
*/
|
|
87
|
+
const AGENTS_SECTION = `${AGENTS_MARKER_START}
|
|
88
|
+
## Agent configuration hygiene (agentdoctor)
|
|
89
|
+
|
|
90
|
+
After editing any agent config (.claude/ settings, hooks, permissions, .mcp.json, skills,
|
|
91
|
+
subagents, CLAUDE.md/AGENTS.md), audit it:
|
|
92
|
+
|
|
93
|
+
\`\`\`sh
|
|
94
|
+
npx @jqntn/agentdoctor . --no-user --json
|
|
95
|
+
\`\`\`
|
|
96
|
+
|
|
97
|
+
Exit 1 means findings exist (the JSON on stdout is still valid); exit 2 means the run itself
|
|
98
|
+
failed. Findings arrive most-severe-first; fix each by editing \`file\` at \`line\` as its
|
|
99
|
+
\`help\` field describes (\`configPath\` names the exact key), then verify with
|
|
100
|
+
\`npx @jqntn/agentdoctor . --no-user --quiet\` (exit 0 = clean). Suppress an intentional finding with
|
|
101
|
+
an \`agentdoctor-disable <rule-id>\` comment in that file and state why. Never delete or
|
|
102
|
+
weaken a \`deny\` rule, never widen an \`allow\` rule, never echo unredacted secrets.
|
|
103
|
+
${AGENTS_MARKER_END}`;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Installs the audit instructions where Codex, Cursor, Gemini CLI, Jules and
|
|
107
|
+
* every other AGENTS.md-reading tool will see them. Creates the file if
|
|
108
|
+
* missing, appends if present, refuses if the marker already exists.
|
|
109
|
+
*/
|
|
110
|
+
export function initAgents(root) {
|
|
111
|
+
const target = join(root, AGENTS_PATH);
|
|
112
|
+
if (existsSync(target)) {
|
|
113
|
+
const current = readFileSync(target, 'utf8');
|
|
114
|
+
if (current.includes(AGENTS_MARKER_START)) {
|
|
115
|
+
return { written: false, path: AGENTS_PATH, message: `${AGENTS_PATH} already has an agentdoctor section; not duplicating it.` };
|
|
116
|
+
}
|
|
117
|
+
writeFileSync(target, `${current.replace(/\n*$/, '')}\n\n${AGENTS_SECTION}\n`);
|
|
118
|
+
return { written: true, path: AGENTS_PATH, message: `Appended the agentdoctor section to ${AGENTS_PATH}` };
|
|
119
|
+
}
|
|
120
|
+
writeFileSync(target, `# AGENTS.md\n\n${AGENTS_SECTION}\n`);
|
|
121
|
+
return { written: true, path: AGENTS_PATH, message: `Wrote ${AGENTS_PATH} with the agentdoctor section` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function initSkill(root) {
|
|
125
|
+
const target = join(root, SKILL_PATH);
|
|
126
|
+
if (existsSync(target)) {
|
|
127
|
+
return { written: false, path: SKILL_PATH, message: `${SKILL_PATH} already exists; not overwriting.` };
|
|
128
|
+
}
|
|
129
|
+
cpSync(PACKAGED_SKILL, target, { recursive: true });
|
|
130
|
+
return { written: true, path: SKILL_PATH, message: `Wrote ${SKILL_PATH}/ (SKILL.md + fix recipes)` };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A paste-ready score card for chat, issues, or a launch thread.
|
|
135
|
+
*
|
|
136
|
+
* Contains only rule ids and counts - never messages, paths, or snippets - so
|
|
137
|
+
* it is safe to share from a private repo without reviewing it first.
|
|
138
|
+
*/
|
|
139
|
+
export function shareCard(result) {
|
|
140
|
+
const { findings, workspace } = result;
|
|
141
|
+
const grade = computeGrade(findings);
|
|
142
|
+
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
143
|
+
const warnings = findings.filter((f) => f.severity === 'warning').length;
|
|
144
|
+
const info = findings.filter((f) => f.severity === 'info').length;
|
|
145
|
+
const files = workspace.files.length;
|
|
146
|
+
|
|
147
|
+
const counts = [
|
|
148
|
+
errors && `${errors} error${errors === 1 ? '' : 's'}`,
|
|
149
|
+
warnings && `${warnings} warning${warnings === 1 ? '' : 's'}`,
|
|
150
|
+
info && `${info} info`,
|
|
151
|
+
].filter(Boolean).join(', ') || 'no findings';
|
|
152
|
+
|
|
153
|
+
const topRules = [...new Set(findings.map((f) => f.ruleId))].slice(0, 3);
|
|
154
|
+
|
|
155
|
+
const lines = [
|
|
156
|
+
`## agentdoctor grade: ${grade}`,
|
|
157
|
+
'',
|
|
158
|
+
`${files} agent config file${files === 1 ? '' : 's'} scanned - ${counts}.`,
|
|
159
|
+
];
|
|
160
|
+
if (topRules.length) {
|
|
161
|
+
lines.push('', ...topRules.map((id) => `- \`${id}\``));
|
|
162
|
+
}
|
|
163
|
+
lines.push('', 'Check your own agent config:', '', '```', 'npx @jqntn/agentdoctor', '```', '', REPO_URL);
|
|
164
|
+
return `${lines.join('\n')}\n`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Markdown for a README badge that states the current grade. */
|
|
168
|
+
export function badgeMarkdown(result) {
|
|
169
|
+
const grade = computeGrade(result.findings);
|
|
170
|
+
const color = GRADE_COLORS[grade] ?? '64748B';
|
|
171
|
+
const img = `${BADGE_BASE_URL}/agentdoctor-${encodeURIComponent(grade)}-${color}`;
|
|
172
|
+
return [
|
|
173
|
+
`[](${REPO_URL})`,
|
|
174
|
+
'',
|
|
175
|
+
'<!-- Keep it honest: regenerate after config changes with `npx @jqntn/agentdoctor --badge`,',
|
|
176
|
+
' or let CI gate on the real thing: `npx @jqntn/agentdoctor --init-ci` -->',
|
|
177
|
+
].join('\n') + '\n';
|
|
178
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known-good vocabulary for agent harness config.
|
|
3
|
+
*
|
|
4
|
+
* These lists drive typo detection, so they are intentionally permissive:
|
|
5
|
+
* anything unrecognised is reported at `info` unless it is a near-miss for a
|
|
6
|
+
* real key, which is reported as a likely typo. New harness releases add keys,
|
|
7
|
+
* and a linter that shouts about every unknown field is a linter people delete.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const SETTINGS_KEYS = new Set([
|
|
11
|
+
'permissions', 'env', 'model', 'hooks', 'apiKeyHelper', 'cleanupPeriodDays',
|
|
12
|
+
'includeCoAuthoredBy', 'enableAllProjectMcpServers', 'enabledMcpjsonServers',
|
|
13
|
+
'disabledMcpjsonServers', 'statusLine', 'forceLoginMethod', 'outputStyle',
|
|
14
|
+
'disableAllHooks', 'awsAuthRefresh', 'awsCredentialExport', 'otelHeadersHelper',
|
|
15
|
+
'sandbox', 'agents', 'alwaysThinkingEnabled', 'spinnerTipsEnabled',
|
|
16
|
+
'attributionSuffix', 'autoUpdates', 'installMethod', 'mcpServers',
|
|
17
|
+
'extraKnownMarkdownFiles', 'schema', '$schema',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export const PERMISSION_KEYS = new Set([
|
|
21
|
+
'allow', 'deny', 'ask', 'defaultMode', 'additionalDirectories',
|
|
22
|
+
'disableBypassPermissionsMode',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const PERMISSION_MODES = new Set([
|
|
26
|
+
'default', 'acceptEdits', 'plan', 'bypassPermissions',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export const HOOK_EVENTS = new Set([
|
|
30
|
+
'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Notification', 'Stop',
|
|
31
|
+
'SubagentStop', 'PreCompact', 'SessionStart', 'SessionEnd',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** Hook events that receive a tool matcher; the rest ignore `matcher`. */
|
|
35
|
+
export const MATCHER_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PreCompact']);
|
|
36
|
+
|
|
37
|
+
export const TOOL_NAMES = new Set([
|
|
38
|
+
'Agent', 'Task', 'Bash', 'BashOutput', 'KillBash', 'KillShell', 'Glob', 'Grep',
|
|
39
|
+
'Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'NotebookRead',
|
|
40
|
+
'WebFetch', 'WebSearch', 'TodoWrite', 'ExitPlanMode', 'SlashCommand', 'Skill',
|
|
41
|
+
'ListMcpResources', 'ReadMcpResource',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export const MODEL_ALIASES = new Set([
|
|
45
|
+
'opus', 'sonnet', 'haiku', 'opusplan', 'inherit', 'default', 'fable',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** Tools that can read arbitrary local content. */
|
|
49
|
+
export const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'NotebookRead']);
|
|
50
|
+
|
|
51
|
+
/** Tools that can move data off the machine. */
|
|
52
|
+
export const EGRESS_TOOLS = new Set(['WebFetch', 'WebSearch', 'Bash']);
|
|
53
|
+
|
|
54
|
+
/** Tools that mutate the working tree. */
|
|
55
|
+
export const WRITE_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Command fragments that are destructive or irreversible enough that a blanket
|
|
59
|
+
* allow rule is very likely a mistake.
|
|
60
|
+
*/
|
|
61
|
+
export const DESTRUCTIVE_PATTERNS = [
|
|
62
|
+
{ pattern: /\brm\s+(-[a-zA-Z]*[rRf][a-zA-Z]*\s+)+/, label: 'recursive/forced delete (rm -rf)' },
|
|
63
|
+
{ pattern: /\bgit\s+push\s+.*(--force\b|-f\b)/, label: 'force push' },
|
|
64
|
+
{ pattern: /\bgit\s+reset\s+--hard\b/, label: 'hard reset (discards local work)' },
|
|
65
|
+
{ pattern: /\bgit\s+clean\s+-[a-zA-Z]*[fd]/, label: 'git clean (deletes untracked files)' },
|
|
66
|
+
{ pattern: /\bdd\s+.*\bof=/, label: 'raw disk write (dd)' },
|
|
67
|
+
{ pattern: /\bmkfs(\.|\s)/, label: 'filesystem format' },
|
|
68
|
+
{ pattern: /\bchmod\s+(-R\s+)?777\b/, label: 'chmod 777' },
|
|
69
|
+
{ pattern: /\bsudo\b/, label: 'privilege escalation (sudo)' },
|
|
70
|
+
{ pattern: /\bshutdown\b|\breboot\b|\bhalt\b/, label: 'host shutdown' },
|
|
71
|
+
{ pattern: /\bkubectl\s+delete\b/, label: 'kubectl delete' },
|
|
72
|
+
{ pattern: /\bterraform\s+(destroy|apply)\b/, label: 'terraform destroy/apply' },
|
|
73
|
+
{ pattern: /\baws\s+s3\s+rb\b|\baws\s+s3\s+rm\b.*--recursive/, label: 'recursive S3 delete' },
|
|
74
|
+
{ pattern: /\bdrop\s+(table|database)\b/i, label: 'SQL DROP' },
|
|
75
|
+
{ pattern: /\btruncate\s+table\b/i, label: 'SQL TRUNCATE' },
|
|
76
|
+
{ pattern: /\bnpm\s+publish\b|\bcargo\s+publish\b|\btwine\s+upload\b/, label: 'package publish' },
|
|
77
|
+
{ pattern: /\bgh\s+(pr|release)\s+(merge|create|delete)\b/, label: 'GitHub state change' },
|
|
78
|
+
{ pattern: /:\(\)\s*\{.*\}\s*;\s*:/, label: 'fork bomb' },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Shell fragments that download and execute code in one step. These turn any
|
|
83
|
+
* allowed hook or command into a remote-code-execution channel.
|
|
84
|
+
*/
|
|
85
|
+
export const REMOTE_EXEC_PATTERNS = [
|
|
86
|
+
{ pattern: /\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(ba|z|k|da)?sh\b/, label: 'curl | sh' },
|
|
87
|
+
{ pattern: /\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(python3?|node|perl|ruby)\b/, label: 'download piped to interpreter' },
|
|
88
|
+
{ pattern: /\bIEX\s*\(|\bInvoke-Expression\b/i, label: 'PowerShell Invoke-Expression' },
|
|
89
|
+
{ pattern: /\bNet\.WebClient\b/i, label: 'PowerShell remote download' },
|
|
90
|
+
{ pattern: /\beval\s+"?\$\((curl|wget)/, label: 'eval of remote output' },
|
|
91
|
+
{ pattern: /\bbash\s+<\(\s*(curl|wget)/, label: 'process substitution of remote script' },
|
|
92
|
+
{ pattern: /\bsource\s+<\(\s*(curl|wget)/, label: 'sourcing a remote script' },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Secret shapes worth flagging. Each entry is anchored enough to avoid firing
|
|
97
|
+
* on ordinary prose or placeholder text.
|
|
98
|
+
*/
|
|
99
|
+
export const SECRET_PATTERNS = [
|
|
100
|
+
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{16,}/, label: 'Anthropic API key' },
|
|
101
|
+
{ pattern: /\bsk-(proj-)?[A-Za-z0-9]{32,}/, label: 'OpenAI-style API key' },
|
|
102
|
+
{ pattern: /\bgh[pousr]_[A-Za-z0-9]{30,}/, label: 'GitHub token' },
|
|
103
|
+
{ pattern: /\bgithub_pat_[A-Za-z0-9_]{30,}/, label: 'GitHub fine-grained PAT' },
|
|
104
|
+
{ pattern: /\bAKIA[0-9A-Z]{16}\b/, label: 'AWS access key id' },
|
|
105
|
+
{ pattern: /\bASIA[0-9A-Z]{16}\b/, label: 'AWS temporary access key id' },
|
|
106
|
+
{ pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/, label: 'Slack token' },
|
|
107
|
+
{ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/, label: 'Google API key' },
|
|
108
|
+
{ pattern: /\bglpat-[A-Za-z0-9_-]{20,}/, label: 'GitLab personal access token' },
|
|
109
|
+
{ pattern: /-----BEGIN (RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/, label: 'private key' },
|
|
110
|
+
{ pattern: /\bey[A-Za-z0-9_-]{10,}\.ey[A-Za-z0-9_-]{10,}\./, label: 'JWT' },
|
|
111
|
+
{ pattern: /\bsk_live_[A-Za-z0-9]{20,}/, label: 'Stripe live secret key' },
|
|
112
|
+
{ pattern: /\bnpm_[A-Za-z0-9]{36}\b/, label: 'npm access token' },
|
|
113
|
+
{ pattern: /\bhf_[A-Za-z0-9]{30,}/, label: 'Hugging Face token' },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
/** Environment variables that change how child processes load code. */
|
|
117
|
+
export const DANGEROUS_ENV_VARS = new Map([
|
|
118
|
+
['LD_PRELOAD', 'injects a shared library into every child process'],
|
|
119
|
+
['LD_LIBRARY_PATH', 'redirects dynamic library resolution'],
|
|
120
|
+
['DYLD_INSERT_LIBRARIES', 'injects a dylib into every child process (macOS)'],
|
|
121
|
+
['NODE_OPTIONS', 'can preload arbitrary JavaScript into every Node process'],
|
|
122
|
+
['PYTHONSTARTUP', 'executes a file on every interactive Python start'],
|
|
123
|
+
['PATH', 'changes which binaries commands resolve to'],
|
|
124
|
+
['GIT_SSH_COMMAND', 'replaces the transport used for every git operation'],
|
|
125
|
+
['BASH_ENV', 'executes a file on every non-interactive bash start'],
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
/** Paths that should generally be unreadable to an agent. */
|
|
129
|
+
export const SENSITIVE_READ_TARGETS = [
|
|
130
|
+
'.env', '.env.*', '**/.ssh/**', '**/*.pem', '**/*.key',
|
|
131
|
+
'**/.aws/credentials', '**/.kube/config', '**/.npmrc', '**/.netrc',
|
|
132
|
+
'**/id_rsa', '**/.git-credentials',
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
/** Rough per-turn context budget before a memory file starts costing real money. */
|
|
136
|
+
export const MEMORY_TOKEN_WARN = 4000;
|
|
137
|
+
export const MEMORY_TOKEN_ERROR = 12000;
|
|
138
|
+
export const TOTAL_MEMORY_TOKEN_WARN = 10000;
|
|
139
|
+
export const MCP_SERVER_WARN_COUNT = 8;
|