@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/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
+ `[![agentdoctor: ${grade}](${img})](${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
+ }
@@ -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;