# agentdoctor > A linter for AI coding-agent configuration (.claude settings, hooks, permissions, MCP servers, skills, subagents, memory files). 72 rules across correctness, security, cost, hygiene, policy. Zero dependencies, no network calls, MIT. CLI: `npx @jqntn/agentdoctor`; machine-readable via `--json` (schema: https://jqntn.github.io/agentdoctor/schemas/report.schema.json), `--sarif`, `--list-rules --json`, `--explain `. Exit codes: 0 clean, 1 errors, 2 usage. ## For agents: audit and adopt To audit a project: run `npx @jqntn/agentdoctor . --no-user --json`. Findings are sorted most-severe-first; each has `file`, `line`, `configPath` (the exact config key), `snippet` (the offending value, secrets redacted), and `help` (the fix). Apply fixes by editing the file at that location, then verify with `npx @jqntn/agentdoctor . --no-user --quiet` (exit 0 = clean). Suppress an intentional finding with an `agentdoctor-disable ` comment in that file; never delete a deny rule to silence a finding. To install permanently (each command writes one file, refuses to overwrite, safe unattended): - `npx @jqntn/agentdoctor --init-ci` - GitHub Actions workflow: SARIF PR annotations + error gate - `npx @jqntn/agentdoctor --init-skill` - installs the config-audit skill (audit-and-fix workflow + per-rule fix recipes) into this project - Claude Code plugin (global): `/plugin marketplace add jqntn/agentdoctor` then `/plugin install agentdoctor` - adds the skill everywhere, invokable as /agentdoctor:config-audit - `npx @jqntn/agentdoctor --init-agents` - adds a marked audit section to AGENTS.md, read by OpenAI Codex, Cursor, Gemini CLI and other coding agents - `npx @jqntn/agentdoctor --write-baseline .agentdoctor-baseline.json` - only if findings exist today ## Docs - [Getting started](https://jqntn.github.io/agentdoctor/docs/getting-started.md) - [Configuration](https://jqntn.github.io/agentdoctor/docs/configuration.md) - [Rule reference](https://jqntn.github.io/agentdoctor/docs/rules.md) - [CI setup](https://jqntn.github.io/agentdoctor/docs/ci.md) - [Baselines](https://jqntn.github.io/agentdoctor/docs/baselines.md) - [Team policy](https://jqntn.github.io/agentdoctor/docs/policy.md) - [Output formats](https://jqntn.github.io/agentdoctor/docs/output.md) - [Programmatic API](https://jqntn.github.io/agentdoctor/docs/api.md) - [For agents](https://jqntn.github.io/agentdoctor/docs/agents.md) - [Architecture](https://jqntn.github.io/agentdoctor/docs/architecture.md) - [FAQ](https://jqntn.github.io/agentdoctor/docs/faq.md) - [Privacy](https://jqntn.github.io/agentdoctor/docs/privacy.md) ## Optional - [Full documentation in one file](https://jqntn.github.io/agentdoctor/llms-full.txt) - [Policy file JSON Schema](https://jqntn.github.io/agentdoctor/schemas/policy.schema.json) - [Report JSON Schema](https://jqntn.github.io/agentdoctor/schemas/report.schema.json) - [Repository](https://github.com/jqntn/agentdoctor) --- # Getting started ## Requirements Node 20 or newer. Nothing else — agentdoctor has zero dependencies and installs no transitive packages. ## Run it No install needed: ```sh npx @jqntn/agentdoctor ``` Or install it: ```sh npm install -g @jqntn/agentdoctor # global CLI npm install -D @jqntn/agentdoctor # per-project, for CI ``` By default it audits the current directory plus your user-level config in `~/.claude`. To audit a specific project, pass the path: ```sh agentdoctor path/to/repo agentdoctor --no-user # project config only (use this in CI) ``` ## What gets scanned | File | What it is | |---|---| | `.claude/settings.json` | Project settings: permissions, hooks, env, model | | `.claude/settings.local.json` | Personal overrides (should be gitignored) | | `~/.claude/settings.json` | User-level settings | | `.mcp.json` | MCP server definitions | | `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md` | Memory files, at any depth | | `.claude/agents/*.md` | Subagent definitions | | `.claude/skills/*/SKILL.md` | Skill definitions | | `.claude/commands/*.md` | Slash commands | | `.claude/hooks/*` | Hook scripts (existence and permissions only) | | `.claude/keybindings.json` | Key bindings | **Never scanned:** `.credentials.json`, `.netrc`, private keys. These are skipped by path before anything opens them, and the summary reports how many files were skipped. agentdoctor also makes no network calls — nothing leaves your machine. ## Reading a finding ``` .claude/settings.json 4:7 error "Bash(*)" auto-approves every shell command, including ones you have not seen. | Bash(*) -> Replace the wildcard with the specific commands you actually want unattended, e.g. "Bash(npm test:*)". security/unrestricted-bash ``` Top to bottom: file, `line:column`, severity, what is wrong, the offending value, what to do instead, and the rule id. Every rule id works with `--explain`: ```sh agentdoctor --explain security/unrestricted-bash ``` ## The grade Every report ends with a health grade - `A+` (zero findings), `A` (info only), `B`/`C` (warnings), `D`/`F` (errors). It is computed from what is actionable today, so fixing or deliberately suppressing findings raises it. `agentdoctor --share` prints a paste-ready score card (rule ids and counts only - safe to share from private repos), and `agentdoctor --badge` emits README markdown for it. ## Severities | Severity | Meaning | |---|---| | `error` | Broken or dangerous. A guardrail that does not work, a pre-approved destructive command, a committed credential. | | `warning` | Very likely a problem, occasionally intentional. | | `info` | Worth knowing; act on it or ignore it. | ## Exit codes | Code | Meaning | |---|---| | 0 | No errors (and warnings within `--max-warnings`, if set) | | 1 | At least one error, or too many warnings | | 2 | Bad usage: unknown flag, missing path, unreadable baseline | ## Next steps - [Configuration](configuration.md) — every flag, suppression, disabling rules - [CI setup](ci.md) — SARIF annotations, exit-code gating - [Baselines](baselines.md) — adopting agentdoctor on a repo that already has findings - [Team policy](policy.md) — holding many repos to one standard - [Rule reference](rules.md) — every rule and the reasoning behind it --- # Configuration agentdoctor needs no config file to run. Everything is a CLI flag, an inline comment, or (for team standards) an `agentdoctor.policy.json`. ## CLI reference ### Output | Flag | Effect | |---|---| | *(default)* | Human-readable report, colored when stdout is a TTY | | `--json` | Machine-readable findings on stdout ([format](output.md)) | | `--sarif` | SARIF 2.1.0 for GitHub code scanning and other CI | | `--quiet`, `-q` | Print nothing; rely on the exit code | | `--no-color` / `--color` | Force color off/on (also honours `NO_COLOR` and `FORCE_COLOR`) | ### Scope | Flag | Effect | |---|---| | `[path]` | Project root to audit (default: current directory) | | `--no-user` | Skip `~/.claude`. Recommended in CI, where user scope does not exist | | `--only ` | Run only these categories or rule ids | | `--disable ` | Skip specific rules or whole categories | | `--min-severity ` | `error`, `warning`, or `info` (default) | Categories: `correctness`, `security`, `cost`, `hygiene`, `policy`. ```sh agentdoctor --only security,correctness agentdoctor --disable cost/no-cleanup-period,hygiene agentdoctor --min-severity warning ``` ### CI | Flag | Effect | |---|---| | `--max-warnings ` | Exit 1 if more than n warnings (errors always exit 1) | | `--baseline ` | Suppress findings recorded in the baseline ([guide](baselines.md)) | | `--write-baseline ` | Record current findings as accepted | ### Team policy | Flag | Effect | |---|---| | `--policy ` | Policy file path (default: `agentdoctor.policy.json` at the root) | | `--init-policy` | Write a starter policy file ([guide](policy.md)) | ### Adopt & share | Flag | Effect | |---|---| | `--init-ci` | Write `.github/workflows/agentdoctor.yml`: SARIF annotations + exit-code gate. Refuses to overwrite. | | `--init-skill` | Install the config-audit skill (SKILL.md + fix recipes) for Claude Code. Refuses to overwrite. | | `--init-agents` | Add a marked audit section to `AGENTS.md` for Codex, Cursor, Gemini CLI and every other tool that reads it. Creates or appends; refuses to duplicate. | | `--badge` | Print README markdown for a badge showing the current grade | | `--share` | Print a paste-ready score card: grade, counts, top rule ids. Never includes messages, paths, or snippets, so it is safe to share from private repos. Always exits 0. | ### Introspection | Flag | Effect | |---|---| | `--list-rules` | The full catalogue (add `--json` for machine-readable) | | `--explain ` | What a rule checks, why it matters, how to suppress it | | `--version`, `--help` | The usual | ## Suppressing a rule for one file Put a comment anywhere in the offending file: ``` // agentdoctor-disable security/hook-unpinned-path ``` In JSON config, a comment works (agentdoctor's parser tolerates comments) — or use a string key that contains the directive: ```json { "// agentdoctor-disable security/hook-unpinned-path": "hooks come from vendored bin/", "hooks": { } } ``` Accepted forms: - `agentdoctor-disable ` — one rule - `agentdoctor-disable ` — a whole category - `agentdoctor-disable all` — everything, for this file - Multiple ids separated by commas or spaces Suppressions are file-scoped by design: a suppression you can see next to the code it affects is one a reviewer can question. To mark a credential-looking string as a deliberate placeholder: ``` agentdoctor-allow-secret ``` on the same line as the value. ## Precedence 1. `permissions.deny`-style hard skips: credential files are never read, regardless of flags. 2. `--only` narrows the rule set first. 3. `--disable` removes rules or categories from whatever `--only` left. 4. Inline `agentdoctor-disable` comments suppress findings per file. 5. `--baseline` suppresses previously accepted findings. 6. `--min-severity` filters what is left. Suppressed counts are always reported in the summary, so a silenced finding is visible as a number even when its detail is not. --- # Rule reference Every rule, with the reasoning behind it - 72 in total. `agentdoctor --explain ` prints any of these from the CLI, and `--list-rules` prints the catalogue. ## Correctness Config the harness is silently ignoring. These are the findings where you believe something is configured and it is not. ### `correctness/invalid-json` **Config file is not valid JSON**  ·  `error` The harness cannot read this file, so every setting in it is silently ignored — including any permission rules you thought were protecting you. ### `correctness/unknown-settings-key` **Unrecognised settings key**  ·  `warning` Unknown keys are ignored without warning, so a typo means the setting never applies. ### `correctness/unknown-permission-key` **Unrecognised key under permissions**  ·  `warning` Valid keys are: allow, deny, ask, defaultMode, additionalDirectories, disableBypassPermissionsMode. ### `correctness/permissions-wrong-type` **Permission bucket is not an array**  ·  `error` allow, deny and ask must each be an array of rule strings. A string or object here means the rules never load. ### `correctness/permission-unknown-tool` **Permission rule names an unknown tool**  ·  `warning` Tool names are case-sensitive. A rule naming a tool that does not exist never matches anything, so a deny rule written this way protects nothing. ### `correctness/permission-non-string` **Permission rule is not a string**  ·  `error` Each entry must be a string like "Bash(npm test:*)". ### `correctness/duplicate-permission` **Duplicate permission rule**  ·  `info` Harmless, but usually a sign of a merge that went wrong or a rule that was meant to be edited rather than added. ### `correctness/allow-deny-conflict` **Same rule in both allow and deny**  ·  `warning` Deny wins, so the allow entry is dead config. Remove it so the intent is unambiguous to the next reader. ### `correctness/unknown-hook-event` **Unknown hook event**  ·  `error` Valid events: PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact, SessionStart, SessionEnd. Events are case-sensitive and a misspelled one never fires. ### `correctness/hook-malformed` **Hook entry has the wrong shape**  ·  `error` Each event maps to an array of { matcher, hooks: [{ type: "command", command: "..." }] }. A near-miss shape is dropped silently. ### `correctness/hook-matcher-ignored` **Matcher set on an event that has no tool**  ·  `info` Only PreToolUse, PostToolUse, PreCompact use a matcher. Elsewhere it is ignored, which can look like the hook is scoped when it is not. ### `correctness/hook-matcher-invalid-regex` **Hook matcher is not a valid pattern**  ·  `error` Matchers are treated as regular expressions. An invalid pattern means the hook silently never matches. ### `correctness/hook-matcher-unknown-tool` **Hook matcher names no existing tool**  ·  `warning` Check the spelling and casing of the tool name. A matcher that matches nothing is a hook that never fires. ### `correctness/invalid-model` **Unrecognised model name**  ·  `warning` Use an alias (opus, sonnet, haiku) or a full model id. An unknown value falls back to the default without telling you. ### `correctness/agent-missing-frontmatter` **Subagent definition has no frontmatter**  ·  `error` A subagent file needs a --- delimited frontmatter block with at least name and description. Without it the agent is not registered. ### `correctness/agent-missing-field` **Subagent is missing a required field**  ·  `error` Both name and description are required. The description is what the orchestrating model reads to decide whether to delegate, so an empty one means the agent is never chosen. ### `correctness/agent-name-mismatch` **Subagent name does not match its filename**  ·  `warning` Keep the frontmatter name and the filename in sync; mismatches make agents hard to find and, depending on the harness version, can shadow each other. ### `correctness/agent-unknown-tool` **Subagent grants a tool that does not exist**  ·  `warning` Tool names in the tools list are case-sensitive. An unknown entry is dropped, so the agent quietly runs without the capability you meant to give it. ### `correctness/duplicate-agent-name` **Two subagents share a name**  ·  `error` Names must be unique; the loser is unreachable. Project-scope agents shadow user-scope agents with the same name. ### `correctness/skill-name-mismatch` **Skill name does not match its directory**  ·  `error` A skill is invoked by its directory name, so a mismatched frontmatter name makes the skill impossible to invoke by the name it advertises. ### `correctness/skill-missing-field` **Skill is missing a required field**  ·  `error` name and description are both required. The description is the only thing the model sees when deciding whether to load the skill. ### `correctness/duplicate-skill-name` **Two skills share a name**  ·  `error` Only one wins. Rename one, or move it under a directory-scoped path if the collision is deliberate. ### `correctness/mcp-server-incomplete` **MCP server has no way to start**  ·  `error` A server needs either "command" (stdio) or "url" (SSE/HTTP). Without one the server fails to connect on every session start. ### `correctness/mcp-server-toggled-both-ways` **MCP server both enabled and disabled**  ·  `warning` Remove it from one of the two lists so the intended state is obvious. ### `correctness/statusline-malformed` **statusLine is misconfigured**  ·  `warning` statusLine must be an object with type "command" and a command string. ### `correctness/env-non-string-value` **Environment value is not a string**  ·  `warning` Environment variables are strings. Numbers and booleans here may be dropped or coerced unpredictably — quote them. ## Security The config surface is an execution surface. These rules find the places where it is wider than intended. ### `security/unrestricted-bash` **Blanket Bash allow rule**  ·  `error` Replace the wildcard with the specific commands you actually want unattended, e.g. "Bash(npm test:*)" or "Bash(git status)". A blanket allow means any command the model proposes runs without asking you. ### `security/destructive-allow` **Destructive command pre-approved**  ·  `error` Move this rule to permissions.ask so you still get a prompt, or narrow it to the safe subset of the command. ### `security/bypass-permissions-default` **Permission checks disabled by default**  ·  `error` Use "default" or "acceptEdits" for day-to-day work and opt into bypass explicitly per session. Committing bypassPermissions applies it to everyone who checks out the repo. ### `security/hooks-globally-disabled` **All hooks disabled**  ·  `warning` If hooks were disabled to work around one noisy hook, remove that hook instead. disableAllHooks also silences hooks your team relies on for guardrails. ### `security/hook-remote-code` **Hook downloads and executes remote code**  ·  `error` Vendor the script into the repo and run it from a pinned path. Hooks run automatically with your full user privileges and no confirmation, so whoever controls that URL controls your machine. ### `security/hook-unpinned-path` **Hook command resolves through PATH or cwd**  ·  `warning` Use an absolute path or "$CLAUDE_PROJECT_DIR/.claude/hooks/name.sh". A bare name resolves via PATH, so a same-named file earlier in PATH — or in a repo you clone — runs instead. ### `security/hook-dangerous-command` **Hook runs a destructive command**  ·  `warning` Hooks fire automatically with no confirmation step. Anything irreversible belongs in a command you invoke deliberately, not in a hook. ### `security/secret-in-config` **Credential hardcoded in agent config**  ·  `error` Move the value to a secret manager or an untracked env file and reference it indirectly. Config files are committed, synced and shared far more often than people expect. ### `security/dangerous-env-var` **Loader-influencing environment variable set**  ·  `warning` Set these per-command instead of session-wide. Anything defined in settings.env applies to every process the agent spawns for the whole session. ### `security/broad-additional-directory` **Filesystem root granted as a working directory**  ·  `error` List only the specific sibling directories the agent needs. Granting "/" or your home directory hands it every SSH key, browser profile and other project on the machine. ### `security/unrestricted-egress` **Unrestricted network egress pre-approved**  ·  `warning` Scope WebFetch to the domains you actually need, e.g. "WebFetch(domain:docs.example.com)". An open fetch rule is a one-step path for anything in your context to leave the machine. ### `security/sensitive-read-allowed` **Credential file explicitly readable**  ·  `error` Remove the rule and add the path to permissions.deny instead. Secrets read into context end up in transcripts, logs and any tool call the model makes next. ### `security/missing-secret-denies` **No deny rules protecting secrets**  ·  `info` Add a deny list such as ["Read(./.env*)", "Read(**/.ssh/**)", "Read(**/*.pem)", "Read(**/.aws/credentials)"]. Deny rules are the only guardrail that survives an accepted prompt, since they are checked before anything runs. ### `security/mcp-unpinned-package` **MCP server runs an unpinned remote package**  ·  `warning` Pin the exact version, e.g. "@scope/server@1.4.2". With "@latest" or no version, every session silently installs whatever was published most recently, including a compromised release. ### `security/mcp-auto-enable-all` **Project MCP servers auto-enabled without review**  ·  `warning` Leave this off and enable servers explicitly via enabledMcpjsonServers. Otherwise cloning a repo is enough to run its MCP servers on your machine. ### `security/mcp-plaintext-url-credential` **Credential embedded in MCP server URL**  ·  `error` Move the token into a header sourced from the environment. URLs land in logs, crash reports and shell history. ### `security/world-writable-config` **Agent config writable by other users**  ·  `error` Run "chmod go-w" on the file. Any user who can write your agent config can add a hook, and hooks execute automatically as you. ### `security/hook-script-not-executable` **Hook script is world-writable or missing**  ·  `warning` Keep hook scripts inside the repo, owned by you, and not group-writable. ### `security/apikeyhelper-inline-secret` **apiKeyHelper echoes a literal key**  ·  `error` Point apiKeyHelper at a script that reads from your OS keychain or secret manager, rather than embedding the key in the command. ### `security/deny-bucket-empty-with-broad-allow` **Broad allow list with no deny list**  ·  `warning` Pair permissive allow rules with explicit denies. Deny is evaluated first and is the only rule class the model cannot talk its way past. ### `security/bypass-mode-not-locked` **Bypass mode not disabled for the project**  ·  `info` Set permissions.disableBypassPermissionsMode to "disable" in committed project settings to stop anyone opting out of prompts in this repo. ### `security/invalid-permission-mode` **Unknown permission mode**  ·  `error` Use one of: default, acceptEdits, plan, bypassPermissions. An unrecognised mode is ignored, so you silently fall back to the default. ## Cost Memory files and tool schemas are re-sent on every request, so their size is a recurring charge. These rules quantify it. ### `cost/memory-file-too-large` **Memory file is large enough to cost real money**  ·  `warning` Move reference material into a skill or a linked file that gets read on demand. Memory files are prepended to every request, so their size multiplies by every turn you take. ### `cost/total-memory-budget` **Combined always-on context is heavy**  ·  `warning` Aim to keep the always-loaded total under a few thousand tokens. Everything here competes with the actual task for the model attention you are paying for. ### `cost/duplicated-memory-instructions` **The same instruction appears in several memory files**  ·  `info` Keep each instruction in exactly one file. Duplicates cost tokens twice and, worse, drift apart until they contradict each other. ### `cost/many-mcp-servers` **Many MCP servers enabled at once**  ·  `warning` Enable servers per project rather than globally. Every connected server contributes its tool schemas to the context window on every request, whether or not you use it. ### `cost/vague-skill-description` **Skill description gives the model nothing to match on**  ·  `warning` Write descriptions as trigger conditions: "Use when the user asks to X, mentions Y, or is working on Z." The description is the only signal the model has, so a vague one means the skill you wrote is never used. ### `cost/vague-agent-description` **Subagent description will not attract delegation**  ·  `info` State what the agent is for and when to pick it. Orchestrators route on this string alone. ### `cost/memory-contains-generated-content` **Memory file contains content that belongs in a file, not in context**  ·  `warning` Reference the file by path instead of pasting it. The agent can read a path in one tool call; pasted content is paid for on every single request forever. ### `cost/no-cleanup-period` **Transcript retention never trimmed**  ·  `info` Set cleanupPeriodDays to something like 30. Old transcripts are dead weight on disk and, if they contain customer data, a growing liability. ## Hygiene Legal, safe config that will still cause avoidable confusion or leak personal settings between machines. ### `hygiene/local-settings-not-ignored` **Local settings file is not gitignored**  ·  `error` Add ".claude/settings.local.json" to .gitignore. That file is where personal overrides and machine-specific paths go, and committing it pushes your permissions onto everyone else. ### `hygiene/empty-config` **Config file has no effective content**  ·  `info` Delete it, or fill it in. An empty file reads as "configured" to the next person who opens the repo. ### `hygiene/skill-body-empty` **Skill has frontmatter but no instructions**  ·  `warning` The body is what the model actually follows once the skill loads. Frontmatter alone advertises a capability that does nothing. ### `hygiene/agent-body-empty` **Subagent has no system prompt**  ·  `warning` The body of an agent file is its system prompt. Without one the subagent behaves like a default agent with a narrower toolset. ### `hygiene/no-project-memory` **No project memory file**  ·  `info` A short CLAUDE.md covering build/test commands and project conventions removes the same handful of questions from every session. ### `hygiene/absolute-home-path` **Committed config contains a machine-specific path**  ·  `warning` Use $CLAUDE_PROJECT_DIR or a relative path so the config works on every machine. Hardcoded home directories break for every other contributor. ### `hygiene/settings-scope-conflict` **Local settings silently override project settings**  ·  `info` Not a bug, but worth knowing: this key differs between the committed project config and your local override, so your session behaves differently from your teammates. ### `hygiene/keybindings-duplicate` **Two actions bound to the same key**  ·  `warning` One of the two bindings will not fire. Pick a different chord for the loser. ## Policy Enforcement of a written standard across more than one repository. These rules activate when an agentdoctor.policy.json is committed and are silent otherwise. ### `policy/missing-required-deny` **Required deny rule is absent**  ·  `error` Add the rule to committed project settings. It is mandated by your agentdoctor.policy.json. ### `policy/forbidden-allow` **Allow rule forbidden by policy**  ·  `error` Remove the rule or get the policy amended. Policy exists so this decision is made once, centrally, instead of per repo. ### `policy/unapproved-mcp-server` **MCP server not on the approved list**  ·  `error` MCP servers run code and see your context. Add the server to allowedMcpServers in policy once it has been reviewed. ### `policy/required-hook-missing` **Mandated guardrail hook is missing**  ·  `error` Policy requires this hook event to be configured. Copy it from your organisation template. ### `policy/memory-budget-exceeded` **Always-on context exceeds the policy budget**  ·  `error` Trim the memory files or raise maxMemoryTokens deliberately. A context budget is the only thing that stops CLAUDE.md growing without limit. ### `policy/forbidden-permission-mode` **Permission mode forbidden by policy**  ·  `error` Change defaultMode to a mode your policy permits. ### `policy/permission-drift` **Local overrides widen the committed permission set**  ·  `warning` Local settings are invisible in review. If a rule is genuinely needed, put it in project settings so the team sees it; if it is personal, keep it narrow. ### `policy/file-invalid` **Policy file could not be read**  ·  `error` A policy that fails to parse enforces nothing, which is the most dangerous state for a guardrail to be in. --- Suppress any rule for one file with a comment in that file: ``` agentdoctor-disable ``` --- # Running agentdoctor in CI ## GitHub Actions, as code scanning annotations Findings appear inline on the pull request diff. ```yaml name: agentdoctor on: [pull_request] permissions: contents: read security-events: write jobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22 } # continue-on-error so the SARIF still uploads when findings exist; # the gate job below is what actually fails the build. - run: npx @jqntn/agentdoctor --no-user --sarif > agentdoctor.sarif continue-on-error: true - uses: github/codeql-action/upload-sarif@v3 with: sarif_file: agentdoctor.sarif - name: Fail on errors run: npx @jqntn/agentdoctor --no-user --quiet ``` `--no-user` matters in CI: there is no `~/.claude` on a runner, and scanning it locally would report findings a reviewer cannot act on. ## Any other CI ```sh npx @jqntn/agentdoctor --no-user --json > agentdoctor.json # exit 1 if errors exist npx @jqntn/agentdoctor --no-user --max-warnings 0 # also fail on warnings ``` Exit codes are the contract: | Code | Meaning | |---|---| | 0 | No errors (and warnings within `--max-warnings`) | | 1 | At least one error, or too many warnings | | 2 | Bad usage: unknown flag, missing path, unreadable baseline | ## Adopting on a repo that already has findings Fail on new problems without having to fix the backlog first: ```sh # once, on a green-ish commit npx @jqntn/agentdoctor --no-user --write-baseline .agentdoctor-baseline.json git add .agentdoctor-baseline.json # in CI, from then on npx @jqntn/agentdoctor --no-user --baseline .agentdoctor-baseline.json ``` Baseline entries are fingerprints of `rule id + file + config path`, so moving a rule within a file keeps it suppressed, while adding a genuinely new one does not. Shrink the baseline as you fix things: ```sh npx @jqntn/agentdoctor --no-user --write-baseline .agentdoctor-baseline.json ``` ## Enforcing one standard across many repos Commit the same `agentdoctor.policy.json` to every repo, or fetch it from a central location in CI, and the policy rules hold each repo to it: ```yaml - run: curl -sSf https://internal.example.com/agentdoctor.policy.json -o agentdoctor.policy.json - run: npx @jqntn/agentdoctor --no-user --quiet ``` The policy rules activate on the presence of the file — nothing else to configure. --- # Baselines: adopting agentdoctor on an existing repo An established project will have findings on day one. Fixing all of them before turning on CI enforcement is how adoption dies. A baseline records the current findings as accepted, so CI fails only on **new** problems. ## Workflow ```sh # once, reviewed and committed like any other change agentdoctor --no-user --write-baseline .agentdoctor-baseline.json git add .agentdoctor-baseline.json # in CI, from then on agentdoctor --no-user --baseline .agentdoctor-baseline.json ``` Fix things over time, then shrink the baseline by regenerating it: ```sh agentdoctor --no-user --write-baseline .agentdoctor-baseline.json ``` The file is plain JSON — a list of finding fingerprints — so shrinkage is visible in diffs and code review. A growing baseline is a red flag a reviewer can see. ## Why baselines survive edits A baseline that breaks when someone inserts an unrelated line is a baseline people stop trusting. agentdoctor anchors each fingerprint to the most stable identity available, in order: 1. **The offending value itself** (e.g. the text of the permission rule). Permission rules live in arrays, so a positional anchor like `permissions.allow[0]` changes meaning the moment anyone inserts a rule above it. The rule text does not. 2. **The config path**, for structural findings with no single value (an empty deny list, a missing required key). 3. **A hash of the finding's message**, for whole-file findings. Line numbers are never part of the identity. Concretely: - Insert a new (bad) rule anywhere in the allow list → exactly one new finding surfaces. - Add unrelated keys above the offending line → nothing resurfaces. - Fix a finding → its fingerprint disappears on the next `--write-baseline`. Each of these is a regression test in the suite (`test/baseline.test.js`). ## Baselines vs inline suppression | | Baseline | `agentdoctor-disable` comment | |---|---|---| | Scope | Whole project, one file | One rule, one file | | Visibility | A count in every summary + a diffable JSON file | A comment next to the code | | Use for | The adoption backlog | A deliberate, permanent exception | Rule of thumb: baselines are for *debt*, inline suppressions are for *decisions*. If you find yourself regenerating the baseline to absorb new findings, you have turned the fire alarm off. --- # Team policy One repo can be audited by reading it. Forty repos need a written standard that CI checks mechanically — that is what `agentdoctor.policy.json` is. Commit it at the repo root (or ship the same file to every repo from a central location) and the eight `policy/*` rules activate automatically. No flag, no account. Repos without a policy file never see these rules fire. ## Quick start ```sh agentdoctor --init-policy ``` writes a starter policy: ```json { "requiredDeny": ["Read(./.env*)", "Read(**/.ssh/**)", "Read(**/*.pem)", "Read(**/.aws/credentials)"], "forbiddenAllow": ["Bash(*)", "Bash(:*)", "Bash()", "WebFetch(*)", "Bash(**sudo**)", "Bash(**rm -rf**)"], "forbiddenPermissionModes": ["bypassPermissions"], "allowedMcpServers": [], "requiredHooks": [], "maxMemoryTokens": 6000 } ``` Edit, commit, done. `agentdoctor` now fails (exit 1) when the repo violates it. ## Fields ### `requiredDeny: string[]` Deny rules every repo must carry, in any settings file. Enforced by `policy/missing-required-deny`. Deny rules are the only guardrail evaluated before anything runs, which makes them the one thing worth mandating centrally. ### `forbiddenAllow: string[]` Allow rules no repo may carry. Enforced by `policy/forbidden-allow`. ### `allowedMcpServers: string[]` If present, every configured MCP server name must match an entry. Enforced by `policy/unapproved-mcp-server`. This turns "someone committed a new MCP server" from a silent event into a review decision. Omit the field entirely to skip this check; an empty array means *no servers are approved*. ### `requiredHooks: string[]` Hook events that must be configured, e.g. `["PreToolUse"]` if your org mandates a guardrail hook. Enforced by `policy/required-hook-missing`. ### `maxMemoryTokens: number` A ceiling on the estimated token size of the project's always-on memory files (`CLAUDE.md` et al., user scope excluded). Enforced by `policy/memory-budget-exceeded`. A context budget is the only thing that stops memory files growing without limit. ### `forbiddenPermissionModes: string[]` Usually `["bypassPermissions"]`. Enforced by `policy/forbidden-permission-mode`. ## Wildcard semantics Permission rules themselves contain `*`, so policy patterns treat it literally: - A single `*` is **literal**. `"Bash(*)"` forbids exactly the rule `Bash(*)` — it does **not** forbid `Bash(npm test:*)`. - `**` is the **wildcard**. `"Bash(**)"` matches every Bash rule; `"Bash(**sudo**)"` matches any Bash rule mentioning sudo. This is the difference between "nobody may have the blanket rule" and "nobody may run Bash at all" — the starter policy uses both deliberately. ## Two rules that need no policy fields - `policy/permission-drift` fires when `.claude/settings.local.json` adds an *unrestricted* allow rule the committed project config does not grant. Local settings are invisible in code review; this makes the widening visible. - `policy/file-invalid` fires when the policy file itself fails to parse — a policy that silently enforces nothing is the worst state for a guardrail. ## Rolling out across an organisation 1. Write one policy centrally. Start with `requiredDeny` + `forbiddenPermissionModes` only — they are the least controversial and catch the worst failure modes. 2. Ship it to each repo (commit it, or `curl` it in CI before running agentdoctor). 3. Run `agentdoctor --no-user` in CI. Use a [baseline](baselines.md) per repo if there is a backlog. 4. Tighten over time: add `allowedMcpServers` once you have inventoried what is in use, then `maxMemoryTokens` once teams have trimmed. A JSON Schema for the policy file ships with the package (`schemas/policy.schema.json`) and is served on the docs site, so editors validate it as you type. --- # Output formats Three formats, one flag apart. The terminal report is for humans; `--json` is the stable contract for scripts and agents; `--sarif` is for CI annotation. ## `--json` ```sh agentdoctor --no-user --json ``` ```json { "version": 1, "tool": "agentdoctor", "toolVersion": "0.1.0", "root": "/work/api", "scannedFiles": [ { "path": ".claude/settings.json", "kind": "settings", "scope": "project", "bytes": 512 } ], "skippedFiles": ["/home/u/.claude/.credentials.json"], "rulesRun": ["correctness/invalid-json", "..."], "suppressed": 0, "grade": "D", "summary": { "error": 2, "warning": 1, "info": 0 }, "findings": [ { "ruleId": "security/unrestricted-bash", "severity": "error", "category": "security", "message": "\"Bash(*)\" auto-approves every shell command, including ones you have not seen.", "help": "Replace the wildcard with the specific commands you actually want unattended...", "file": ".claude/settings.json", "absolutePath": "/work/api/.claude/settings.json", "line": 4, "column": 7, "configPath": "permissions.allow[0]", "snippet": "Bash(*)" } ] } ``` Field notes: - `grade` is the health grade, computed from the post-filter findings: `A+` zero findings, `A` info only, `B` 1-2 warnings, `C` 3+ warnings, `D` 1-2 errors, `F` 3+ errors. - `version` is the format version. Additions are the only change ever made to shape `1`; removals or renames would bump it. - `findings` is sorted: severity first (`error` > `warning` > `info`), then file, then line. - `column`, `configPath`, `snippet`, and `help` are `null` when not applicable. - `snippet` never contains an unredacted secret — credential-shaped values are truncated to a prefix/suffix with `(redacted)`. - `file` is display-relative (repo-relative, or `~/`-prefixed for user scope); `absolutePath` is absolute. - A machine-readable JSON Schema ships in the package: `schemas/report.schema.json`. `--list-rules --json` emits the catalogue as `[{ id, severity, title }]`. ## `--sarif` SARIF 2.1.0, consumable by GitHub code scanning and any SARIF-aware tool. Findings appear as inline annotations on the PR diff. ```sh agentdoctor --no-user --sarif > agentdoctor.sarif ``` Properties worth knowing: - Severities map `error → error`, `warning → warning`, `info → note`. - `partialFingerprints.agentdoctorFingerprint` gives stable finding identity across runs, so GitHub tracks findings as "existing" rather than re-announcing them per commit. - Artifact URIs are repo-relative. Files outside the repo (user scope) are shortened to a suffix rather than leaking an absolute home path into CI logs. - Every rule referenced by a result includes its full description and help text in `tool.driver.rules`, so the annotation is self-explanatory in the GitHub UI. Wiring for GitHub Actions is in the [CI guide](ci.md). ## Terminal report The default. Grouped by file so you fix one file at a time; within a file, sorted by severity then line. Color respects `NO_COLOR`, `FORCE_COLOR`, and TTY detection, and degrades to plain text in pipes. Every finding ends with its rule id so `--explain` is always one copy-paste away. The summary line always includes: the grade, counts by severity, rules run, elapsed time, suppressed findings (baseline + inline), and how many credential files were skipped unread. ## Exit codes (all formats) | Code | Meaning | |---|---| | 0 | No errors; warnings within `--max-warnings` if set | | 1 | At least one error, or warnings over the limit | | 2 | Usage error | --- # Programmatic API agentdoctor is an ES module with no dependencies, so embedding it is one import. ```js import { run } from '@jqntn/agentdoctor'; const result = run('/path/to/repo', { includeUserScope: false }); for (const finding of result.findings) { console.log(finding.severity, finding.ruleId, `${finding.display}:${finding.line}`); } process.exitCode = result.findings.some((f) => f.severity === 'error') ? 1 : 0; ``` ## `run(root, options?)` The one-call entry point: discovers config, loads any team policy, runs every rule. | Option | Type | Default | Effect | |---|---|---|---| | `includeUserScope` | boolean | `true` | Also scan `~/.claude` | | `home` | string | `os.homedir()` | Override the home directory (useful in tests) | | `policyPath` | string | auto-detect | Explicit policy file path | | `only` | string[] | all | Restrict to categories or rule ids | | `disabled` | string[] | none | Skip rules or categories | | `minSeverity` | `'error'\|'warning'\|'info'` | `'info'` | Severity floor | | `baseline` | `Set` | empty | Fingerprints to suppress | Returns `{ findings, ran, suppressed, workspace, elapsedMs, version }`. ### Finding shape ```ts { ruleId: string; // e.g. "security/unrestricted-bash" severity: 'error' | 'warning' | 'info'; category: 'correctness' | 'security' | 'cost' | 'hygiene' | 'policy'; message: string; // what is wrong, specific to this occurrence help?: string; // what to do instead, from the rule file: string; // absolute path display: string; // repo-relative (or ~/) path for humans line: number; // 1-based column?: number; configPath?: string; // e.g. "permissions.allow[0]" snippet?: string; // offending value, secrets redacted } ``` Findings are pre-sorted by severity, then file, then line. ## Lower-level building blocks All exported from the package root: - `discover(root, { includeUserScope, home })` — collects and parses every config file, with per-value source positions. Never opens credential files. - `lint(workspace, { rules, disabled, minSeverity, baseline })` — runs rules over a discovered workspace. Pass your own `rules` array to run a custom subset or add your own rules. - `fingerprint(finding)` — the stable identity used by baselines. Anchored to the offending value, then config path, then message hash — never line numbers. - `allRules`, `CATEGORIES` — the catalogue. - `loadPolicy(root, explicitPath?)` — reads `agentdoctor.policy.json`. - `helpers` — utilities handed to rules: `parsePermission`, `estimateTokens`, position lookup. ## Writing a custom rule A rule is a plain object; `lint` accepts any array of them. ```js import { discover, lint, allRules, helpers } from '@jqntn/agentdoctor'; const noOpusInProjects = { id: 'org/no-opus-model', category: 'policy', severity: 'warning', title: 'Project pins an Opus-tier model', help: 'Our org standard is sonnet for project config; sessions can override per run.', check({ files, report, helpers }) { for (const file of files) { if (file.kind !== 'settings' || file.data?.model !== 'opus') continue; const position = helpers.at(file, 'model'); report({ file, line: position.line, column: position.column, configPath: 'model', message: 'model is pinned to opus.' }); } }, }; const workspace = discover(process.cwd(), { includeUserScope: false }); const result = lint(workspace, { rules: [...allRules, noOpusInProjects] }); ``` The `check` function receives `{ workspace, files, report, helpers }`. Throwing inside a rule does not crash the run — it surfaces as an `internal/rule-crashed` warning finding. ## Stability The exported API surface is small on purpose: `run`, `discover`, `lint`, `fingerprint`, `allRules`, `CATEGORIES`, `loadPolicy`, `helpers`, `VERSION`. Anything not exported from the package root is internal and may change without notice. --- # Using agentdoctor with AI agents agentdoctor is built to be operated *by* agents, not just to audit their config. Every capability is reachable non-interactively, every output has a machine-readable form, and every finding carries enough context to act on without a human in the loop. ## The contract, in one table | Need | Command | Output | |---|---|---| | Audit a project | `agentdoctor --no-user --json` | Findings JSON ([shape](output.md)) | | Gate a change | `agentdoctor --no-user --quiet` | Exit code only: 0 clean, 1 errors, 2 usage | | Understand a rule | `agentdoctor --explain ` | Rationale + suppression syntax, plain text | | Enumerate rules | `agentdoctor --list-rules --json` | `[{ id, severity, title }]` | | Accept a backlog | `agentdoctor --write-baseline ` | Fingerprint list, plain JSON | | CI annotations | `agentdoctor --sarif` | SARIF 2.1.0 | Guarantees an agent can rely on: - **Deterministic**: same input tree → same findings, same order (severity, file, line). - **Non-interactive**: no prompts, no TTY requirements, no network. Safe in any sandbox. - **Stable JSON**: shape version `1` only ever gains fields. A JSON Schema ships in the package at `schemas/report.schema.json`. - **Redaction**: detected secrets never appear unredacted in any output format, so findings are safe to pass through logs and model context. - **Pipe-safe**: output through `head`, `less`, or a closed pipe exits cleanly. ## The fix loop The intended agentic workflow: ``` 1. agentdoctor . --no-user --json -> findings 2. for each finding (already sorted most-severe-first): read finding.help -> the fix, in prose edit finding.file at finding.line -> configPath names the exact key 3. agentdoctor . --no-user --quiet -> exit 0 = verified fixed ``` `configPath` (e.g. `permissions.allow[2]`) plus `snippet` (the offending value) identify the exact edit target without re-parsing the report message. If a finding is intentional, write the file-scoped suppression comment instead of deleting the rule: `agentdoctor-disable ` ([syntax](configuration.md)). ## Adopting it for a user, in three commands When an agent decides this tool belongs in a project (for example after an audit surfaces findings), the full adoption is three non-interactive, idempotent-safe commands: ```sh npx @jqntn/agentdoctor --init-ci # writes .github/workflows/agentdoctor.yml npx @jqntn/agentdoctor --init-skill # writes .claude/skills/config-audit/SKILL.md # only if the project already has findings today npx @jqntn/agentdoctor --write-baseline .agentdoctor-baseline.json ``` Each `--init-*` command writes exactly one well-known file, refuses to overwrite, and exits 2 if the file already exists - so re-running is always safe. `--badge` prints README markdown showing the current grade if the user wants it displayed. ## Works with every coding agent The CLI contract above is vendor-neutral - plain commands, JSON out, exit codes - so any agent that can run a shell command can use agentdoctor. What differs per tool is where the *instructions* live: | Agent | Mechanism | Install | |---|---|---| | Claude Code | Skill + plugin (`/agentdoctor:config-audit`) | `/plugin marketplace add jqntn/agentdoctor` then `/plugin install agentdoctor@jqntn` | | OpenAI Codex | `AGENTS.md` | `npx @jqntn/agentdoctor --init-agents` | | Cursor | `AGENTS.md` | `npx @jqntn/agentdoctor --init-agents` | | Gemini CLI / Jules | `AGENTS.md` | `npx @jqntn/agentdoctor --init-agents` | | Anything else | `AGENTS.md`, or just the CLI contract | `npx @jqntn/agentdoctor --init-agents` | `--init-agents` writes a short marked section (`` ... ``) into `AGENTS.md` - creating the file if absent, appending if present, refusing if the section already exists - telling the agent to audit after any config edit and how to run the fix loop. It is deliberately ~15 lines: AGENTS.md is always-on context for these tools, and bloating it is exactly what agentdoctor's cost rules exist to prevent. Note the skill and the AGENTS.md section install *instructions*, not the binary: both invoke `npx @jqntn/agentdoctor`, which prefers a project-local install and otherwise fetches on demand. Pin it permanently with `npm install -D @jqntn/agentdoctor`. For Codex specifically, a reusable custom prompt is one copy away (user-scope, so it works across projects): ```sh mkdir -p ~/.codex/prompts # project instructions, read by Codex npx @jqntn/agentdoctor --init-agents # optional: a reusable /audit-config prompt, user-scoped across projects cp node_modules/@jqntn/agentdoctor/plugin/skills/config-audit/SKILL.md ~/.codex/prompts/audit-config.md ``` ## The standalone skill and plugin The canonical skill lives at [`plugin/skills/config-audit/`](https://github.com/jqntn/agentdoctor/tree/main/plugin/skills/config-audit) in the repo and inside the npm package. It contains the audit -> fix workflow plus `references/fix-recipes.md` with per-rule fix patterns, and its `description` frontmatter is written to trigger on config-audit requests, edits to `.claude/` files, and "my hook isn't firing" symptoms. Three ways to install it: | Method | Command | Scope | |---|---|---| | Claude Code plugin | `/plugin marketplace add jqntn/agentdoctor` then `/plugin install agentdoctor@jqntn` | everywhere, invokable as `/agentdoctor:config-audit` | | CLI | `npx @jqntn/agentdoctor --init-skill` | this project | | Manual | `cp -r node_modules/@jqntn/agentdoctor/plugin/skills/config-audit .claude/skills/` | anywhere | All three install the same files - `--init-skill` copies them out of the package, so the installed skill cannot drift from the published one (test-enforced). ## For agents working on this repository The repo root carries an `AGENTS.md` (mirrored by `CLAUDE.md`) with the build/test commands, the architectural invariants, and the rules for adding rules. The docs site serves [`llms.txt`](https://jqntn.github.io/agentdoctor/llms.txt) and a concatenated `llms-full.txt`, and every docs page is also available as raw markdown at the same URL with `.md` — agents should prefer those over scraping HTML. --- # Architecture Four stages, each a plain module with no dependencies: **discover → parse → lint → report**. ``` bin/agentdoctor.js CLI: flags, exit codes, EPIPE handling src/ discover.js finds config files; never opens credential files parse.js position-tracking JSON + frontmatter parsers engine.js runs rules, suppression, baselines, fingerprints rules/ correctness.js (26) config the harness silently ignores security.js (22) config that widens the execution surface cost.js (8) always-on context, priced with stated assumptions hygiene.js (8) config that confuses the next person policy.js (8) team standards from agentdoctor.policy.json report/ terminal.js json.js sarif.js ``` ## Discovery `discover()` walks the project for every file the agent harness reads: settings at three scopes (project, local, user), `.mcp.json`, memory files at any depth, agents, skills, commands, hooks, keybindings. Vendor directories (`node_modules`, `dist`, `.venv`, …) are skipped, walk depth is capped, and files over 4 MB are recorded as skipped rather than read. Two properties are deliberate and load-bearing: - **Credential files are never opened.** `.credentials.json`, `.netrc`, private keys are excluded by *path* before any `readFile`, and a test asserts a tripwire value planted in a credentials file can never reach output. A security tool's own behavior is part of its threat model. - **No network calls, ever.** Not for updates, not for telemetry. The entire run is local file reads. ## Position-tracking parsing Findings are only actionable if they point at a line, so agentdoctor does not use `JSON.parse`. `src/parse.js` is a hand-written JSON parser that records the `line:column` of every value, keyed by config path (`permissions.allow[0]`). It is also deliberately tolerant: trailing commas and comments — common in hand-edited config — parse fine, so one stray comma yields real findings instead of a single parse error. A genuinely broken file becomes a `correctness/invalid-json` **error**, because the harness ignores the entire file in that case, including any permission rules in it. Agent and skill definitions carry config in YAML frontmatter. The frontmatter parser supports the documented subset (scalars, inline and dash lists, one nesting level) rather than taking a YAML dependency. ## The rule engine A rule is a plain object: `{ id, category, severity, title, help, check() }`. The engine calls each rule with the workspace and a `report()` callback, then handles everything rules should not re-implement: - **Suppression** — inline `agentdoctor-disable` comments, `--disable`, `--only`, `--min-severity`, and baselines all apply centrally. - **Fingerprints** — each finding gets a stable identity anchored to the offending value, then config path, then message hash. Never line numbers: a baseline must survive unrelated edits ([why](baselines.md)). - **Crash isolation** — a throwing rule becomes an `internal/rule-crashed` warning; it cannot take the run down. - **Ordering** — findings sort by severity, file, line, so output is deterministic. ## Design principles 1. **False positives are worse than false negatives.** A linter that cries wolf gets uninstalled, at which point it catches nothing. The test suite contains a fully well-configured fixture project that must produce **zero** findings; any rule that fires on it is wrong by definition. 2. **Every finding says why and what to do.** `message` states the specific problem; `help` states the fix and the reasoning. `--explain ` prints the full rationale. 3. **Silent failure is the enemy.** The highest-value rules are the ones that catch config the harness ignores without any error: misspelled hook events, deny rules naming nonexistent tools, hooks pointing at missing scripts. 4. **Zero dependencies, permanently.** This tool warns about supply-chain risk in MCP servers; its own `npm install` footprint is part of the product. The JSON parser, YAML subset, ANSI styling, and SARIF writer are all in-tree. 5. **Estimates state their assumptions.** Cost rules price always-on context using a model that accounts for prompt caching, and say so in the message. The token count is the fact; the money is a model. ## Testing `node --test`, no framework. The suite covers every rule (a meta-test fails if a rule id appears in no test file), parser positions, discovery, baselines (insertion-stability regression tests), CLI behavior including piping and exit codes, and docs consistency — the README's rule counts are asserted against the actual catalogue so marketing copy cannot drift from the code. --- # FAQ ## Is my config uploaded anywhere? No. agentdoctor makes zero network calls — no telemetry, no update checks, no license pings. The entire run is local file reads. It also never opens credential files (`.credentials.json`, `.netrc`, private keys); they are excluded by path before anything reads them, and the summary reports how many were skipped. ## Why did it find nothing? Probably because your config is small. A 10-line `settings.json` with two permission rules has little to get wrong, and agentdoctor is deliberately quiet on healthy setups — the test suite asserts zero findings on a well-configured project. The findings density rises with hooks, MCP servers, subagents, skills, and memory files. ## Isn't this just a JSON schema? A schema catches type errors. It cannot tell you that `Bash(*)` is a bad idea, that your hook script does not exist on disk, that your deny rule names a tool that does not exist (and therefore blocks nothing), or that your `CLAUDE.md` costs real money per month. Most of the catalogue is semantic, not structural. ## Why do you report warnings on things that are technically legal? Because the failure mode this tool exists for is config that is *legal and inert*. A misspelled hook event is valid JSON. It just never fires, and nothing tells you. When agentdoctor is unsure, it says `info`; when something is legal but almost certainly not what you meant, `warning`; when a guardrail provably does nothing or a real hazard is pre-approved, `error`. ## A rule fired on something intentional. What now? Suppress it where it fired, visibly: ``` // agentdoctor-disable security/hook-unpinned-path ``` If you believe the rule is wrong in general, that is a bug — false positives are treated as more severe than false negatives. Open an issue with the config that triggered it. ## How accurate are the cost estimates? The token counts are direct estimates from file size. The money figures are a *model*, and the message says so: they assume the memory file stays prompt-cached (it sits at the front of the prompt, which is exactly the content that caches) and state the request volume they assume. The uncached worst case is also shown where it matters. Assumptions live in `src/rules/cost.js` where you can disagree with them. ## Does it modify my config? No. agentdoctor reports; you decide. Every finding includes what to change and why, but the edit is yours. The only file it ever writes is a baseline, and only when you pass `--write-baseline`. ## Which harnesses does it understand? The `.claude/` configuration surface (Claude Code and compatible tooling), `.mcp.json` MCP server definitions, and the `CLAUDE.md`/`AGENTS.md` memory-file convention. The rule engine is harness-agnostic — discovery is the only layer that knows file layouts — so support for other agent config formats is an issue away. ## Does it work on Windows? Yes, with one deliberate gap: the two rules that inspect file permissions (`security/world-writable-config` and the permission half of `security/hook-script-not-executable`) do nothing on Windows. Node synthesizes POSIX mode bits there — every file reports `0666` — so the check would fire on everything while telling you nothing. Windows ACLs are a different model than this rule can speak to. Every other rule behaves identically across Linux, macOS and Windows, and paths in output always use forward slashes so a baseline recorded on one platform matches on another. ## Why Node 20+? Why zero dependencies? Node 20 is the oldest LTS with everything the tool needs built in. Zero dependencies is a security decision, not an aesthetic one: a tool that warns you about unpinned supply chains should not install one. ## Can I use it as a library? Yes — `import { run } from '@jqntn/agentdoctor'` and you get structured findings. See the [API docs](api.md), including how to add organisation-specific rules. --- # Privacy **agentdoctor collects nothing, transmits nothing, and has no servers.** That claim is easy to make and hard to trust, so here is exactly what the tool does, and how you can verify each point yourself. ## What it reads Only files in the project you point it at, plus your user-level configuration in `~/.claude` unless you pass `--no-user`: `.claude/settings.json`, `.claude/settings.local.json`, `~/.claude/settings.json`, `.mcp.json`, `CLAUDE.md` / `CLAUDE.local.md` / `AGENTS.md`, `.claude/agents/*.md`, `.claude/skills/*/SKILL.md`, `.claude/commands/*.md`, `.claude/hooks/*`, `.claude/keybindings.json`, and `.gitignore`. ## What it never reads Credential files are excluded **by path, before anything opens them**: `.credentials.json`, `credentials.json`, `.netrc`, `id_rsa`, `id_ed25519`. The report tells you how many files were skipped for this reason. A test in the suite plants a tripwire value inside a credentials file and asserts it can never appear in any output. ## What leaves your machine Nothing. There is no telemetry, no analytics, no crash reporting, no licence check, no update check, and no phone-home of any kind. The tool makes **zero network requests**. You can confirm this by running it with the network disabled, or by reading the source — there is no HTTP client in it, and it has zero dependencies, so there is no transitive code that could add one. ## What it writes Nothing, unless you explicitly ask: - `--write-baseline ` writes a list of accepted finding fingerprints - `--init-ci`, `--init-skill`, `--init-agents`, `--init-policy` each create one well-known file and refuse to overwrite an existing one It never edits your configuration. Findings tell you what to change; the change is yours. ## Secrets in output When a rule detects a credential-shaped value, the finding shows a short prefix and suffix with `(redacted)` — never the value. This holds in every output format, so reports are safe to paste into an issue, a CI log, or a model's context. `--share` goes further: it emits only rule ids and counts, never file paths, messages, or snippets, so a score card is safe to post publicly from a private repository. ## This website Static files served by GitHub Pages. No analytics, no cookies, no trackers, no fonts or scripts loaded from third parties. The one exception is a linked badge image hosted by openhunts.com in the footer; requesting it reveals your IP address to that host, as any remote image does. ## Contact Questions or a discrepancy between this page and the code: