@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 the agentdoctor authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jqntn/agentdoctor/main/assets/logo-dark.svg">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/jqntn/agentdoctor/main/assets/logo-light.svg" alt="agentdoctor" width="420">
|
|
5
|
+
</picture>
|
|
6
|
+
|
|
7
|
+
<p><strong>Lint your AI coding agent's configuration before it bites.</strong></p>
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<a href="https://github.com/jqntn/agentdoctor/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/jqntn/agentdoctor/actions/workflows/ci.yml/badge.svg"></a>
|
|
11
|
+
<a href="https://www.npmjs.com/package/@jqntn/agentdoctor"><img alt="npm" src="https://img.shields.io/npm/v/%40jqntn%2Fagentdoctor"></a>
|
|
12
|
+
<img alt="zero dependencies" src="https://img.shields.io/badge/dependencies-0-34D399">
|
|
13
|
+
<img alt="node >=20" src="https://img.shields.io/badge/node-%3E%3D20-64748B">
|
|
14
|
+
<a href="LICENSE"><img alt="MIT" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p>
|
|
18
|
+
<a href="https://jqntn.github.io/agentdoctor/">Website</a> ·
|
|
19
|
+
<a href="docs/getting-started.md">Getting started</a> ·
|
|
20
|
+
<a href="docs/rules.md">Rule reference</a> ·
|
|
21
|
+
<a href="docs/ci.md">CI setup</a> ·
|
|
22
|
+
<a href="docs/agents.md">For agents</a>
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
Agent harnesses read a surprising amount of config: permission rules, hooks that execute
|
|
29
|
+
automatically, MCP servers that run third-party code, and memory files re-sent on every
|
|
30
|
+
request. Almost none of it is validated, and the failures are **silent**. A misspelled hook
|
|
31
|
+
event never fires. A deny rule naming a tool that does not exist blocks nothing — while
|
|
32
|
+
looking exactly like a guardrail. `Bash(*)` in an allow list means every command the model
|
|
33
|
+
proposes runs without asking you.
|
|
34
|
+
|
|
35
|
+
`agentdoctor` reads that config and tells you what is actually wrong with it:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npx @jqntn/agentdoctor
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
agentdoctor scanned 8 config files in /work/api
|
|
43
|
+
|
|
44
|
+
.claude/settings.json
|
|
45
|
+
4:7 error "Bash(*)" auto-approves every shell command, including ones you have not seen.
|
|
46
|
+
| Bash(*)
|
|
47
|
+
-> Replace the wildcard with the specific commands you actually want
|
|
48
|
+
unattended, e.g. "Bash(npm test:*)" or "Bash(git status)".
|
|
49
|
+
security/unrestricted-bash
|
|
50
|
+
|
|
51
|
+
27:43 error PreToolUse hook uses curl | sh; the remote content is executed unreviewed on
|
|
52
|
+
every trigger.
|
|
53
|
+
| curl -sSL https://example.com/guard.sh | bash
|
|
54
|
+
-> Vendor the script into the repo and run it from a pinned path.
|
|
55
|
+
security/hook-remote-code
|
|
56
|
+
|
|
57
|
+
35:21 error "PostToolUsee" is not a hook event. Did you mean "PostToolUse"? As written,
|
|
58
|
+
this hook never runs.
|
|
59
|
+
correctness/unknown-hook-event
|
|
60
|
+
|
|
61
|
+
CLAUDE.md
|
|
62
|
+
1 warn CLAUDE.md is ~14,200 tokens of always-on context, sent with every request
|
|
63
|
+
(~$25/month at 3,300 requests, assuming it stays prompt-cached).
|
|
64
|
+
cost/memory-file-too-large
|
|
65
|
+
|
|
66
|
+
Summary Grade F 3 errors, 1 warning - 72 rules in 41ms
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Zero dependencies. No network calls. Credential files are never opened. MIT — all of it.
|
|
70
|
+
|
|
71
|
+
## What it checks
|
|
72
|
+
|
|
73
|
+
**72 rules across five categories.** Full reasoning for every rule:
|
|
74
|
+
[rule reference](docs/rules.md), or `agentdoctor --explain <rule-id>`.
|
|
75
|
+
|
|
76
|
+
### Security (22 rules)
|
|
77
|
+
|
|
78
|
+
The config surface is an execution surface. Blanket `Bash(*)` allows; destructive commands
|
|
79
|
+
pre-approved without confirmation (`sudo`, `rm -rf`, force push, `terraform destroy`,
|
|
80
|
+
`DROP TABLE`, …); hooks piping remote content into a shell (`curl | sh`,
|
|
81
|
+
`eval "$(curl …)"`); live credentials committed in config (Anthropic, OpenAI, GitHub, AWS,
|
|
82
|
+
Slack, Stripe keys, JWTs, private keys — always reported **redacted**); MCP servers running
|
|
83
|
+
unpinned packages or carrying tokens in URLs; `bypassPermissions` committed to shared repos;
|
|
84
|
+
loader-hijacking env vars; world-writable config.
|
|
85
|
+
|
|
86
|
+
### Correctness (26 rules)
|
|
87
|
+
|
|
88
|
+
Config that is silently ignored is worse than config that errors, because you believe it is
|
|
89
|
+
working. Invalid JSON (which voids the whole file, permission rules included); misspelled
|
|
90
|
+
settings keys, hook events, tool names, and models — each with a did-you-mean; deny rules
|
|
91
|
+
that block nothing (an **error**, because it is a guardrail that only looks like one);
|
|
92
|
+
malformed hooks and invalid matcher regexes; duplicate agent/skill names; MCP servers with no
|
|
93
|
+
way to start.
|
|
94
|
+
|
|
95
|
+
### Cost (8 rules)
|
|
96
|
+
|
|
97
|
+
Memory files and MCP tool schemas ride along on every request. Token counts for every memory
|
|
98
|
+
file with an estimated monthly cost — the estimate assumes the file stays prompt-cached
|
|
99
|
+
(that is exactly the content that caches) and states its assumptions; the same instruction
|
|
100
|
+
duplicated across files; pasted code blocks that belong behind a file path; skill
|
|
101
|
+
descriptions too vague for the model to ever load them.
|
|
102
|
+
|
|
103
|
+
### Hygiene (8 rules)
|
|
104
|
+
|
|
105
|
+
`settings.local.json` not gitignored; machine-specific absolute paths in committed config;
|
|
106
|
+
local settings silently shadowing project settings; empty skill/agent bodies; duplicate
|
|
107
|
+
keybindings.
|
|
108
|
+
|
|
109
|
+
### Policy (8 rules)
|
|
110
|
+
|
|
111
|
+
Team standards, enforced mechanically across every repo. Commit an
|
|
112
|
+
`agentdoctor.policy.json` and these activate — no flag, no account:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
agentdoctor --init-policy
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`requiredDeny`, `forbiddenAllow`, `allowedMcpServers`, `requiredHooks`, `maxMemoryTokens`,
|
|
119
|
+
`forbiddenPermissionModes`, plus drift detection when local settings quietly widen the
|
|
120
|
+
committed permission set. [Policy guide](docs/policy.md).
|
|
121
|
+
|
|
122
|
+
## Adopt it in one command
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
npx @jqntn/agentdoctor --init-ci # GitHub Actions: SARIF annotations on PRs + error gate
|
|
126
|
+
npx @jqntn/agentdoctor --init-skill # Claude Code skill: findings -> fixes, automatically
|
|
127
|
+
npx @jqntn/agentdoctor --init-agents # AGENTS.md section: same loop for Codex, Cursor, Gemini CLI
|
|
128
|
+
npx @jqntn/agentdoctor --badge # README badge with your current grade
|
|
129
|
+
npx @jqntn/agentdoctor --share # paste-ready score card (rule ids + counts only)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Every audit ends in a grade - `A+` down to `F`, formula stated in the docs. The badge and the
|
|
133
|
+
share card contain rule ids and counts only, never messages, paths, or snippets, so they are
|
|
134
|
+
safe to post from private repos.
|
|
135
|
+
|
|
136
|
+
[](https://jqntn.github.io/agentdoctor/)
|
|
137
|
+
|
|
138
|
+
## CI
|
|
139
|
+
|
|
140
|
+
```yaml
|
|
141
|
+
- run: npx @jqntn/agentdoctor --no-user --sarif > agentdoctor.sarif
|
|
142
|
+
continue-on-error: true
|
|
143
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
144
|
+
with: { sarif_file: agentdoctor.sarif }
|
|
145
|
+
- run: npx @jqntn/agentdoctor --no-user --quiet # exit 1 on errors
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Findings annotate the PR diff via SARIF. Adopting on a repo with existing findings? Record
|
|
149
|
+
them once and fail only on new ones — baselines are anchored to content, not line numbers, so
|
|
150
|
+
unrelated edits never invalidate them:
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
agentdoctor --write-baseline .agentdoctor-baseline.json # once
|
|
154
|
+
agentdoctor --baseline .agentdoctor-baseline.json # in CI
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
[CI guide](docs/ci.md) · [Baselines](docs/baselines.md)
|
|
158
|
+
|
|
159
|
+
## Built for agents, audited by agents
|
|
160
|
+
|
|
161
|
+
Every capability is non-interactive and machine-readable: `--json` (stable schema, shipped as
|
|
162
|
+
[JSON Schema](schemas/report.schema.json)), `--sarif`, `--explain`, `--list-rules --json`,
|
|
163
|
+
deterministic ordering, redacted secrets safe for model context, pipe-safe output. The docs
|
|
164
|
+
site serves `llms.txt` and raw markdown.
|
|
165
|
+
|
|
166
|
+
The repo is also a **Claude Code plugin**: it ships the
|
|
167
|
+
[config-audit skill](skills/config-audit/SKILL.md) (audit -> fix loop, with fix recipes) and
|
|
168
|
+
an `/agentdoctor:audit` command. Install it any of three ways:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
/plugin marketplace add jqntn/agentdoctor # in Claude Code, then: /plugin install agentdoctor
|
|
172
|
+
npx @jqntn/agentdoctor --init-skill # copies the skill into this project
|
|
173
|
+
cp -r node_modules/@jqntn/agentdoctor/skills/config-audit .claude/skills/ # manual
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
[Agent guide](docs/agents.md)
|
|
177
|
+
|
|
178
|
+
## What it will not do
|
|
179
|
+
|
|
180
|
+
- **Never reads credential files.** `.credentials.json`, `.netrc`, private keys are excluded
|
|
181
|
+
by path before anything opens them — asserted by tests.
|
|
182
|
+
- **No network calls.** No telemetry, no update checks, nothing leaves the machine.
|
|
183
|
+
- **No dependencies.** A tool that warns about supply-chain risk should not install one.
|
|
184
|
+
- **Never edits your config.** Findings say what to change and why; the change is yours.
|
|
185
|
+
|
|
186
|
+
[Architecture and design principles](docs/architecture.md) · [FAQ](docs/faq.md) ·
|
|
187
|
+
[Security policy](SECURITY.md)
|
|
188
|
+
|
|
189
|
+
## Documentation
|
|
190
|
+
|
|
191
|
+
| | |
|
|
192
|
+
|---|---|
|
|
193
|
+
| [Getting started](docs/getting-started.md) | Install, first run, reading findings, exit codes |
|
|
194
|
+
| [Configuration](docs/configuration.md) | Every flag, suppression, disabling rules |
|
|
195
|
+
| [Rule reference](docs/rules.md) | All 72 rules with reasoning |
|
|
196
|
+
| [CI setup](docs/ci.md) | GitHub Actions, SARIF, exit-code gating |
|
|
197
|
+
| [Baselines](docs/baselines.md) | Adopting on an existing repo |
|
|
198
|
+
| [Team policy](docs/policy.md) | One standard across many repos |
|
|
199
|
+
| [Output formats](docs/output.md) | JSON, SARIF, terminal |
|
|
200
|
+
| [Programmatic API](docs/api.md) | `run()`, custom rules, embedding |
|
|
201
|
+
| [For agents](docs/agents.md) | The machine-readable contract + fix loop |
|
|
202
|
+
| [Architecture](docs/architecture.md) | How it works, design principles |
|
|
203
|
+
| [FAQ](docs/faq.md) | |
|
|
204
|
+
|
|
205
|
+
## Contributing
|
|
206
|
+
|
|
207
|
+
False positives are treated as more severe than missed findings — if a rule fires on your
|
|
208
|
+
legitimate config, [that is a bug](.github/ISSUE_TEMPLATE/false-positive.yml). Adding a rule
|
|
209
|
+
takes one object and two tests: see [CONTRIBUTING.md](CONTRIBUTING.md). The suite enforces the
|
|
210
|
+
invariants (every rule tested, zero findings on the clean fixture, zero dependencies), so if
|
|
211
|
+
`npm test` is green, the PR is reviewable.
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
[MIT](LICENSE). All 72 rules, every output format, no accounts, no telemetry, no paid tier.
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { run, VERSION, allRules, CATEGORIES } from '../src/index.js';
|
|
5
|
+
import { fingerprint } from '../src/engine.js';
|
|
6
|
+
import { renderTerminal, shouldUseColor } from '../src/report/terminal.js';
|
|
7
|
+
import { renderJson } from '../src/report/json.js';
|
|
8
|
+
import { renderSarif } from '../src/report/sarif.js';
|
|
9
|
+
import { initCi, initSkill, initAgents, shareCard, badgeMarkdown } from '../src/adopt.js';
|
|
10
|
+
|
|
11
|
+
const HELP = `agentdoctor ${VERSION} - lint your AI coding-agent configuration
|
|
12
|
+
|
|
13
|
+
Usage
|
|
14
|
+
agentdoctor [path] Audit a project (defaults to the current directory)
|
|
15
|
+
agentdoctor --explain <rule-id> Show what a rule checks and why
|
|
16
|
+
agentdoctor --list-rules List every rule
|
|
17
|
+
|
|
18
|
+
Output
|
|
19
|
+
--json Machine-readable findings on stdout
|
|
20
|
+
--sarif SARIF 2.1.0, for GitHub code scanning and other CI
|
|
21
|
+
--quiet Print nothing; rely on the exit code
|
|
22
|
+
--no-color Disable ANSI colour (also honours NO_COLOR)
|
|
23
|
+
|
|
24
|
+
Scope
|
|
25
|
+
--no-user Skip user-level config in ~/.claude
|
|
26
|
+
--only <cat,...> Run only these categories (${CATEGORIES.join(', ')})
|
|
27
|
+
--disable <id,...> Skip specific rules or whole categories
|
|
28
|
+
--min-severity <level> error | warning | info (default: info)
|
|
29
|
+
|
|
30
|
+
CI
|
|
31
|
+
--max-warnings <n> Fail if more than n warnings are found
|
|
32
|
+
--baseline <file> Ignore findings recorded in this file
|
|
33
|
+
--write-baseline <file> Record current findings as the accepted baseline
|
|
34
|
+
|
|
35
|
+
Team policy
|
|
36
|
+
--policy <file> Team policy file (default: agentdoctor.policy.json)
|
|
37
|
+
--init-policy Write a starter agentdoctor.policy.json
|
|
38
|
+
|
|
39
|
+
Adopt & share
|
|
40
|
+
--init-ci Write a ready-made GitHub Actions workflow (SARIF + gate)
|
|
41
|
+
--init-skill Write a Claude Code skill that audits and fixes config
|
|
42
|
+
--init-agents Add an audit section to AGENTS.md (Codex, Cursor, Gemini CLI,
|
|
43
|
+
and every other tool that reads it)
|
|
44
|
+
--badge Print a README badge showing this project's current grade
|
|
45
|
+
--share Print a paste-ready score card (rule ids and counts only,
|
|
46
|
+
never messages or paths - safe to share from private repos)
|
|
47
|
+
|
|
48
|
+
Exit codes
|
|
49
|
+
0 no errors
|
|
50
|
+
1 at least one error (or warnings over --max-warnings)
|
|
51
|
+
2 bad usage
|
|
52
|
+
|
|
53
|
+
Suppress a rule for one file by adding a comment: agentdoctor-disable <rule-id>
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
const flags = {
|
|
58
|
+
positional: [],
|
|
59
|
+
json: false, sarif: false, quiet: false, color: null,
|
|
60
|
+
noUser: false, only: [], disable: [], minSeverity: 'info',
|
|
61
|
+
maxWarnings: null, baseline: null, writeBaseline: null,
|
|
62
|
+
policy: null, explain: null, listRules: false,
|
|
63
|
+
initPolicy: false, initCi: false, initSkill: false, initAgents: false,
|
|
64
|
+
share: false, badge: false, help: false, version: false,
|
|
65
|
+
};
|
|
66
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
67
|
+
const arg = argv[i];
|
|
68
|
+
const next = () => {
|
|
69
|
+
const value = argv[i + 1];
|
|
70
|
+
if (value === undefined || value.startsWith('--')) {
|
|
71
|
+
throw new UsageError(`${arg} requires a value`);
|
|
72
|
+
}
|
|
73
|
+
i += 1;
|
|
74
|
+
return value;
|
|
75
|
+
};
|
|
76
|
+
switch (arg) {
|
|
77
|
+
case '--json': flags.json = true; break;
|
|
78
|
+
case '--sarif': flags.sarif = true; break;
|
|
79
|
+
case '--quiet': case '-q': flags.quiet = true; break;
|
|
80
|
+
case '--no-color': flags.color = false; break;
|
|
81
|
+
case '--color': flags.color = true; break;
|
|
82
|
+
case '--no-user': flags.noUser = true; break;
|
|
83
|
+
case '--only': flags.only.push(...next().split(',').map((s) => s.trim()).filter(Boolean)); break;
|
|
84
|
+
case '--disable': flags.disable.push(...next().split(',').map((s) => s.trim()).filter(Boolean)); break;
|
|
85
|
+
case '--min-severity': flags.minSeverity = next(); break;
|
|
86
|
+
case '--max-warnings': flags.maxWarnings = Number(next()); break;
|
|
87
|
+
case '--baseline': flags.baseline = next(); break;
|
|
88
|
+
case '--write-baseline': flags.writeBaseline = next(); break;
|
|
89
|
+
case '--policy': flags.policy = next(); break;
|
|
90
|
+
case '--explain': flags.explain = next(); break;
|
|
91
|
+
case '--list-rules': flags.listRules = true; break;
|
|
92
|
+
case '--init-policy': flags.initPolicy = true; break;
|
|
93
|
+
case '--init-ci': flags.initCi = true; break;
|
|
94
|
+
case '--init-skill': flags.initSkill = true; break;
|
|
95
|
+
case '--init-agents': flags.initAgents = true; break;
|
|
96
|
+
case '--share': flags.share = true; break;
|
|
97
|
+
case '--badge': flags.badge = true; break;
|
|
98
|
+
case '--help': case '-h': flags.help = true; break;
|
|
99
|
+
case '--version': case '-v': flags.version = true; break;
|
|
100
|
+
default:
|
|
101
|
+
if (arg.startsWith('-')) throw new UsageError(`Unknown option "${arg}"`);
|
|
102
|
+
flags.positional.push(arg);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return flags;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class UsageError extends Error {}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Piping into `head`, `less` or a closed pager is ordinary usage. Without this,
|
|
112
|
+
* the closed pipe surfaces as an unhandled EPIPE and a Node stack trace.
|
|
113
|
+
*/
|
|
114
|
+
function ignoreBrokenPipe() {
|
|
115
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
116
|
+
stream.on('error', (error) => {
|
|
117
|
+
if (error.code === 'EPIPE') process.exit(0);
|
|
118
|
+
throw error;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const STARTER_POLICY = {
|
|
124
|
+
$comment: 'agentdoctor team policy. Commit this next to your repo root.',
|
|
125
|
+
$wildcards: 'A single * is literal. Use ** to mean "anything here".',
|
|
126
|
+
requiredDeny: ['Read(./.env*)', 'Read(**/.ssh/**)', 'Read(**/*.pem)', 'Read(**/.aws/credentials)'],
|
|
127
|
+
forbiddenAllow: ['Bash(*)', 'Bash(:*)', 'Bash()', 'WebFetch(*)', 'Bash(**sudo**)', 'Bash(**rm -rf**)'],
|
|
128
|
+
forbiddenPermissionModes: ['bypassPermissions'],
|
|
129
|
+
allowedMcpServers: [],
|
|
130
|
+
requiredHooks: [],
|
|
131
|
+
maxMemoryTokens: 6000,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
function main() {
|
|
135
|
+
let flags;
|
|
136
|
+
try {
|
|
137
|
+
flags = parseArgs(process.argv.slice(2));
|
|
138
|
+
} catch (error) {
|
|
139
|
+
process.stderr.write(`${error.message}\n\nRun agentdoctor --help\n`);
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (flags.help) {
|
|
144
|
+
process.stdout.write(HELP);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
if (flags.version) {
|
|
148
|
+
process.stdout.write(`${VERSION}\n`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
if (flags.listRules) return listRules(flags);
|
|
152
|
+
if (flags.explain) return explainRule(flags.explain);
|
|
153
|
+
|
|
154
|
+
const root = resolve(flags.positional[0] ?? process.cwd());
|
|
155
|
+
if (!existsSync(root)) {
|
|
156
|
+
process.stderr.write(`No such directory: ${root}\n`);
|
|
157
|
+
return 2;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (flags.initPolicy) return initPolicy(root);
|
|
161
|
+
if (flags.initCi || flags.initSkill || flags.initAgents) {
|
|
162
|
+
let failed = false;
|
|
163
|
+
for (const [enabled, init, next] of [
|
|
164
|
+
[flags.initCi, initCi, 'Findings will annotate PRs and errors will fail the build on the next push.'],
|
|
165
|
+
[flags.initSkill, initSkill, 'Claude Code will now offer config audits; try asking it to "audit my agent config".'],
|
|
166
|
+
[flags.initAgents, initAgents, 'Codex, Cursor, Gemini CLI and other AGENTS.md readers will now audit config after editing it.'],
|
|
167
|
+
]) {
|
|
168
|
+
if (!enabled) continue;
|
|
169
|
+
const outcome = init(root);
|
|
170
|
+
process[outcome.written ? 'stdout' : 'stderr'].write(`${outcome.message}\n`);
|
|
171
|
+
if (outcome.written) process.stdout.write(`${next}\n`);
|
|
172
|
+
else failed = true;
|
|
173
|
+
}
|
|
174
|
+
return failed ? 2 : 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!['error', 'warning', 'info'].includes(flags.minSeverity)) {
|
|
178
|
+
process.stderr.write(`--min-severity must be error, warning or info\n`);
|
|
179
|
+
return 2;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let baseline = new Set();
|
|
183
|
+
if (flags.baseline) {
|
|
184
|
+
if (!existsSync(flags.baseline)) {
|
|
185
|
+
process.stderr.write(`Baseline file not found: ${flags.baseline}\n`);
|
|
186
|
+
return 2;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(readFileSync(flags.baseline, 'utf8'));
|
|
190
|
+
baseline = new Set(Array.isArray(parsed.fingerprints) ? parsed.fingerprints : []);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
process.stderr.write(`Baseline file is not readable: ${error.message}\n`);
|
|
193
|
+
return 2;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const result = run(root, {
|
|
198
|
+
includeUserScope: !flags.noUser,
|
|
199
|
+
policyPath: flags.policy,
|
|
200
|
+
disabled: flags.disable,
|
|
201
|
+
minSeverity: flags.minSeverity,
|
|
202
|
+
only: flags.only,
|
|
203
|
+
baseline,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (flags.writeBaseline) {
|
|
207
|
+
const payload = {
|
|
208
|
+
version: 1,
|
|
209
|
+
generatedBy: `agentdoctor ${VERSION}`,
|
|
210
|
+
fingerprints: result.findings.map((f) => fingerprint(f)),
|
|
211
|
+
};
|
|
212
|
+
writeFileSync(flags.writeBaseline, `${JSON.stringify(payload, null, 2)}\n`);
|
|
213
|
+
if (!flags.quiet) {
|
|
214
|
+
process.stdout.write(`Wrote ${payload.fingerprints.length} accepted findings to ${flags.writeBaseline}\n`);
|
|
215
|
+
}
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const payload = { ...result, version: VERSION };
|
|
220
|
+
if (flags.share) {
|
|
221
|
+
process.stdout.write(shareCard(result));
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
if (flags.badge) {
|
|
225
|
+
process.stdout.write(badgeMarkdown(result));
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
if (flags.json) {
|
|
229
|
+
process.stdout.write(`${renderJson(payload)}\n`);
|
|
230
|
+
} else if (flags.sarif) {
|
|
231
|
+
process.stdout.write(`${renderSarif(payload)}\n`);
|
|
232
|
+
} else if (!flags.quiet) {
|
|
233
|
+
const color = flags.color ?? shouldUseColor(process.stdout, process.env);
|
|
234
|
+
process.stdout.write(`${renderTerminal({ ...payload, color })}\n`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const errors = result.findings.filter((f) => f.severity === 'error').length;
|
|
238
|
+
const warnings = result.findings.filter((f) => f.severity === 'warning').length;
|
|
239
|
+
if (errors > 0) return 1;
|
|
240
|
+
if (flags.maxWarnings !== null && Number.isFinite(flags.maxWarnings) && warnings > flags.maxWarnings) return 1;
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function listRules(flags) {
|
|
245
|
+
const wanted = flags.only.length ? new Set(flags.only) : null;
|
|
246
|
+
const rows = allRules
|
|
247
|
+
.filter((rule) => !wanted || wanted.has(rule.category) || wanted.has(rule.id))
|
|
248
|
+
.map((rule) => ({
|
|
249
|
+
id: rule.id,
|
|
250
|
+
severity: rule.severity,
|
|
251
|
+
title: rule.title,
|
|
252
|
+
}));
|
|
253
|
+
if (flags.json) {
|
|
254
|
+
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
const width = Math.max(...rows.map((r) => r.id.length));
|
|
258
|
+
for (const row of rows) {
|
|
259
|
+
process.stdout.write(`${row.id.padEnd(width)} ${row.severity.padEnd(7)} ${row.title}\n`);
|
|
260
|
+
}
|
|
261
|
+
process.stdout.write(`\n${rows.length} rules\n`);
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function explainRule(id) {
|
|
266
|
+
const rule = allRules.find((r) => r.id === id);
|
|
267
|
+
if (!rule) {
|
|
268
|
+
const near = allRules.filter((r) => r.id.includes(id) || r.category === id).map((r) => r.id);
|
|
269
|
+
process.stderr.write(`No rule "${id}".${near.length ? `\n\nDid you mean:\n ${near.join('\n ')}\n` : '\n'}`);
|
|
270
|
+
return 2;
|
|
271
|
+
}
|
|
272
|
+
process.stdout.write([
|
|
273
|
+
rule.id,
|
|
274
|
+
'',
|
|
275
|
+
` Title ${rule.title}`,
|
|
276
|
+
` Category ${rule.category}`,
|
|
277
|
+
` Severity ${rule.severity}`,
|
|
278
|
+
'',
|
|
279
|
+
' Why it matters',
|
|
280
|
+
...wrap(rule.help ?? '', 72).map((line) => ` ${line}`),
|
|
281
|
+
'',
|
|
282
|
+
` Suppress with a comment in the offending file:`,
|
|
283
|
+
` agentdoctor-disable ${rule.id}`,
|
|
284
|
+
'',
|
|
285
|
+
].join('\n'));
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function initPolicy(root) {
|
|
290
|
+
const target = resolve(root, 'agentdoctor.policy.json');
|
|
291
|
+
if (existsSync(target)) {
|
|
292
|
+
process.stderr.write(`${target} already exists; not overwriting.\n`);
|
|
293
|
+
return 2;
|
|
294
|
+
}
|
|
295
|
+
writeFileSync(target, `${JSON.stringify(STARTER_POLICY, null, 2)}\n`);
|
|
296
|
+
process.stdout.write(`Wrote ${target}\nEdit it, commit it, then run agentdoctor in CI.\n`);
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function wrap(text, width) {
|
|
301
|
+
const words = String(text).split(/\s+/).filter(Boolean);
|
|
302
|
+
const lines = [];
|
|
303
|
+
let current = '';
|
|
304
|
+
for (const word of words) {
|
|
305
|
+
if (current === '') current = word;
|
|
306
|
+
else if (current.length + 1 + word.length > width) { lines.push(current); current = word; }
|
|
307
|
+
else current += ` ${word}`;
|
|
308
|
+
}
|
|
309
|
+
if (current) lines.push(current);
|
|
310
|
+
return lines.length ? lines : [''];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
ignoreBrokenPipe();
|
|
314
|
+
process.exitCode = main();
|
package/docs/agents.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Using agentdoctor with AI agents
|
|
2
|
+
|
|
3
|
+
agentdoctor is built to be operated *by* agents, not just to audit their config. Every
|
|
4
|
+
capability is reachable non-interactively, every output has a machine-readable form, and every
|
|
5
|
+
finding carries enough context to act on without a human in the loop.
|
|
6
|
+
|
|
7
|
+
## The contract, in one table
|
|
8
|
+
|
|
9
|
+
| Need | Command | Output |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| Audit a project | `agentdoctor <path> --no-user --json` | Findings JSON ([shape](output.md)) |
|
|
12
|
+
| Gate a change | `agentdoctor <path> --no-user --quiet` | Exit code only: 0 clean, 1 errors, 2 usage |
|
|
13
|
+
| Understand a rule | `agentdoctor --explain <rule-id>` | Rationale + suppression syntax, plain text |
|
|
14
|
+
| Enumerate rules | `agentdoctor --list-rules --json` | `[{ id, severity, title }]` |
|
|
15
|
+
| Accept a backlog | `agentdoctor --write-baseline <file>` | Fingerprint list, plain JSON |
|
|
16
|
+
| CI annotations | `agentdoctor --sarif` | SARIF 2.1.0 |
|
|
17
|
+
|
|
18
|
+
Guarantees an agent can rely on:
|
|
19
|
+
|
|
20
|
+
- **Deterministic**: same input tree → same findings, same order (severity, file, line).
|
|
21
|
+
- **Non-interactive**: no prompts, no TTY requirements, no network. Safe in any sandbox.
|
|
22
|
+
- **Stable JSON**: shape version `1` only ever gains fields. A JSON Schema ships in the
|
|
23
|
+
package at `schemas/report.schema.json`.
|
|
24
|
+
- **Redaction**: detected secrets never appear unredacted in any output format, so findings
|
|
25
|
+
are safe to pass through logs and model context.
|
|
26
|
+
- **Pipe-safe**: output through `head`, `less`, or a closed pipe exits cleanly.
|
|
27
|
+
|
|
28
|
+
## The fix loop
|
|
29
|
+
|
|
30
|
+
The intended agentic workflow:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
1. agentdoctor . --no-user --json -> findings
|
|
34
|
+
2. for each finding (already sorted most-severe-first):
|
|
35
|
+
read finding.help -> the fix, in prose
|
|
36
|
+
edit finding.file at finding.line -> configPath names the exact key
|
|
37
|
+
3. agentdoctor . --no-user --quiet -> exit 0 = verified fixed
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`configPath` (e.g. `permissions.allow[2]`) plus `snippet` (the offending value) identify the
|
|
41
|
+
exact edit target without re-parsing the report message. If a finding is intentional, write
|
|
42
|
+
the file-scoped suppression comment instead of deleting the rule:
|
|
43
|
+
`agentdoctor-disable <rule-id>` ([syntax](configuration.md)).
|
|
44
|
+
|
|
45
|
+
## Adopting it for a user, in three commands
|
|
46
|
+
|
|
47
|
+
When an agent decides this tool belongs in a project (for example after an audit surfaces
|
|
48
|
+
findings), the full adoption is three non-interactive, idempotent-safe commands:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
npx @jqntn/agentdoctor --init-ci # writes .github/workflows/agentdoctor.yml (SARIF + gate)
|
|
52
|
+
npx @jqntn/agentdoctor --init-skill # writes .claude/skills/config-audit/SKILL.md
|
|
53
|
+
npx @jqntn/agentdoctor --write-baseline .agentdoctor-baseline.json # only if findings exist today
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Each `--init-*` command writes exactly one well-known file, refuses to overwrite, and exits 2
|
|
57
|
+
if the file already exists - so re-running is always safe. `--badge` prints README markdown
|
|
58
|
+
showing the current grade if the user wants it displayed.
|
|
59
|
+
|
|
60
|
+
## Works with every coding agent
|
|
61
|
+
|
|
62
|
+
The CLI contract above is vendor-neutral - plain commands, JSON out, exit codes - so any
|
|
63
|
+
agent that can run a shell command can use agentdoctor. What differs per tool is where the
|
|
64
|
+
*instructions* live:
|
|
65
|
+
|
|
66
|
+
| Agent | Mechanism | Install |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| Claude Code | Skill + plugin (`/agentdoctor:audit`) | `/plugin marketplace add jqntn/agentdoctor` or `npx @jqntn/agentdoctor --init-skill` |
|
|
69
|
+
| OpenAI Codex | `AGENTS.md` | `npx @jqntn/agentdoctor --init-agents` |
|
|
70
|
+
| Cursor | `AGENTS.md` | `npx @jqntn/agentdoctor --init-agents` |
|
|
71
|
+
| Gemini CLI / Jules | `AGENTS.md` | `npx @jqntn/agentdoctor --init-agents` |
|
|
72
|
+
| Anything else | `AGENTS.md`, or just the CLI contract | `npx @jqntn/agentdoctor --init-agents` |
|
|
73
|
+
|
|
74
|
+
`--init-agents` writes a short marked section (`<!-- agentdoctor:start -->` ...
|
|
75
|
+
`<!-- agentdoctor:end -->`) into `AGENTS.md` - creating the file if absent, appending if
|
|
76
|
+
present, refusing if the section already exists - telling the agent to audit after any config
|
|
77
|
+
edit and how to run the fix loop. It is deliberately ~15 lines: AGENTS.md is always-on
|
|
78
|
+
context for these tools, and bloating it is exactly what agentdoctor's cost rules exist to
|
|
79
|
+
prevent.
|
|
80
|
+
|
|
81
|
+
Note the skill and the AGENTS.md section install *instructions*, not the binary: both invoke
|
|
82
|
+
`npx @jqntn/agentdoctor`, which prefers a project-local install and otherwise fetches on demand. Pin
|
|
83
|
+
it permanently with `npm install -D @jqntn/agentdoctor`.
|
|
84
|
+
|
|
85
|
+
For Codex specifically, a reusable custom prompt is one copy away (user-scope, so it works
|
|
86
|
+
across projects):
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
mkdir -p ~/.codex/prompts
|
|
90
|
+
npx @jqntn/agentdoctor --init-agents # project instructions
|
|
91
|
+
cp node_modules/@jqntn/agentdoctor/skills/config-audit/SKILL.md ~/.codex/prompts/audit-config.md # optional /audit-config
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## The standalone skill and plugin
|
|
95
|
+
|
|
96
|
+
The canonical skill lives at [`skills/config-audit/`](https://github.com/jqntn/agentdoctor/tree/main/skills/config-audit)
|
|
97
|
+
in the repo and inside the npm package. It contains the audit -> fix workflow plus
|
|
98
|
+
`references/fix-recipes.md` with per-rule fix patterns, and its `description` frontmatter is
|
|
99
|
+
written to trigger on config-audit requests, edits to `.claude/` files, and "my hook isn't
|
|
100
|
+
firing" symptoms.
|
|
101
|
+
|
|
102
|
+
Three ways to install it:
|
|
103
|
+
|
|
104
|
+
| Method | Command | Scope |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| Claude Code plugin | `/plugin marketplace add jqntn/agentdoctor` then `/plugin install agentdoctor` | everywhere (also adds `/agentdoctor:audit`) |
|
|
107
|
+
| CLI | `npx @jqntn/agentdoctor --init-skill` | this project |
|
|
108
|
+
| Manual | `cp -r node_modules/@jqntn/agentdoctor/skills/config-audit .claude/skills/` | anywhere |
|
|
109
|
+
|
|
110
|
+
All three install the same files - `--init-skill` copies them out of the package, so the
|
|
111
|
+
installed skill cannot drift from the published one (test-enforced).
|
|
112
|
+
|
|
113
|
+
## For agents working on this repository
|
|
114
|
+
|
|
115
|
+
The repo root carries an `AGENTS.md` (mirrored by `CLAUDE.md`) with the build/test commands,
|
|
116
|
+
the architectural invariants, and the rules for adding rules. The docs site serves
|
|
117
|
+
[`llms.txt`](https://jqntn.github.io/agentdoctor/llms.txt) and a concatenated
|
|
118
|
+
`llms-full.txt`, and every docs page is also available as raw markdown at the same URL with
|
|
119
|
+
`.md` — agents should prefer those over scraping HTML.
|