---
title: "Doctor (Code Checks)"
description: "agent-native doctor scans generated app and workspace source for the framework's security and architecture invariants — the portable code-safety guards shared outside the monorepo CI suite."
---

# Doctor (Code Checks)

Generated apps and workspaces contain source code that can be changed through the repository's
development workflow, including by an agent frame intentionally granted
workspace and write tooling. `agent-native doctor` is the automated check for that: it
scans app source for the framework's security-critical code-safety invariants —
the same class of check that lives in this framework's own CI as
`scripts/guard-*.mjs` — ported to run against generated projects instead of
the framework's multi-template monorepo layout. A workspace-root run scans each
`apps/<name>` project and the workspace's `packages/shared` package when present,
prefixing findings with the project path. It's read-only: doctor never edits
your code, it only reports.

## Quick start {#quick-start}

```bash
agent-native doctor
```

Scaffolded apps and workspaces expose this check as `pnpm agent-native:doctor`.
Use that exact script; `pnpm doctor` runs pnpm's own command rather than the
scaffolded check.

The full framework-repository `pnpm guards` suite remains a monorepo check;
generated projects use this version-matched portable Doctor surface instead.

A clean run:

```
agent-native doctor: /path/to/app
Guards run: no-drizzle-push, no-empty-migrations, no-unscoped-credentials, no-unscoped-queries, no-env-credentials, db-tool-scoping, no-env-mutation, no-localhost-fallback, explicit-collab-access, identity-columns-registered, resource-action-access, feature-dependencies, server-plugin-default-export, migration-manifest
Clean — no findings.
```

A run with findings prints one line per finding and exits `1`:

```
2 finding(s):
  [no-env-credentials] server/routes/webhook.ts:14 — process.env.STRIPE_KEY read — not a deploy-level allowlisted key. User credentials must be read via resolveCredential(key, { userEmail, orgId }), never process.env.
  [no-unscoped-queries] actions/list-notes.ts:9 — unscoped select on "notes" (drizzle) — add accessFilter/resolveAccess/assertAccess or an explicit ownerEmail/userEmail/orgId filter.

agent-native doctor found issues above. Fix them, or add a `// guard:allow-<check> — reason` opt-out with reviewer approval.
```

## Guards {#guards}

Doctor runs the enabled portable guards for the project. Source scans skip `node_modules`, `.git`, `dist`, `build`, and other build-output directories.

| Guard                          | What it catches                                                                                                                                                                                                                            | Why it matters                                                                                                                                                                                                                                                                             |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `no-drizzle-push`              | `drizzle-kit push` / `drizzle push` wired into a build/deploy script hook (`build`, `install`, `deploy`, `start`, `ci`, `release`, and their `pre`/`post` variants) or a `netlify.toml` build command                                      | `drizzle-kit push` can apply destructive schema changes on every deploy with no review step. Standalone `db:push` scripts are **not** flagged — those are explicit, human-invoked commands, not build hooks.                                                                               |
| `no-empty-migrations`          | An empty `runMigrations([])` call in application source                                                                                                                                                                                    | An empty migration list adds startup work but performs no migration.                                                                                                                                                                                                                       |
| `no-unscoped-credentials`      | `resolveCredential` / `hasCredential` / `saveCredential` / `deleteCredential` called with only one argument                                                                                                                                | The one-arg form skips the required `{ userEmail, orgId }` context object, so a per-user credential lookup can read (or delete) another tenant's credential.                                                                                                                               |
| `no-unscoped-queries`          | A query in `actions/` or `server/` against a table built with `...ownableColumns()`, with no `accessFilter` / `resolveAccess` / `assertAccess` and no explicit `ownerEmail` / `userEmail` / `orgId` filter anywhere in the enclosing block | An unscoped `select`/`update`/`delete`/`insert` on a shared table returns or mutates another user's rows. Also flags a `mentionProviders` search closure that queries an ownable table without access control — every user who types `@` in chat would otherwise see rows owned by others. |
| `no-env-credentials`           | Any `process.env.<KEY>` read whose key isn't on the fixed deploy-var allowlist (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `NODE_ENV`, …), plus any dynamic `process.env[key]` read                                                             | `process.env` is one shared value per deployment, read on every signed-in user's request — reading a per-user credential from it pools every tenant onto the same secret. A dynamic key can never be safely allowlisted.                                                                   |
| `db-tool-scoping`              | A table in `server/db/schema.ts` with no `owner_email` / `org_id` column and no matching entry in `agent-native.json`'s `doctor.dbToolScopingDenylist`                                                                                     | The agent's raw `db-query` / `db-exec` / `db-patch` tools can only safely expose tenant-scoped tables; an unscoped table needs an explicit, reviewed exception.                                                                                                                            |
| `no-env-mutation`              | Production code assigning to, or `delete`-ing, `process.env.KEY`                                                                                                                                                                           | `process.env` is process-scoped, not request-scoped. A warm serverless container handles many concurrent requests in one process, so mutating `process.env` in a request handler leaks state across users. Use `runWithRequestContext` instead.                                            |
| `no-localhost-fallback`        | The literal `"local@localhost"`, or `?? DEV_MODE_USER_EMAIL` / `\|\| DEV_MODE_USER_EMAIL`, used as a fallback identity                                                                                                                     | Falling back to a sentinel identity when there's no session pools every unauthenticated request onto one shared tenant. Throw or return 401 instead.                                                                                                                                       |
| `explicit-collab-access`       | A `createCollabPlugin(...)` call with no explicit `access` — must declare `access: { mode: "resource", resourceType }` or `access: { mode: "all-authenticated" }` (legacy `resourceType` is still accepted during migration)               | An implicit config can silently scope real-time collaboration delivery wrong — for example broadcasting deployment-wide when only one resource's viewers should see the edits.                                                                                                             |
| `identity-columns-registered`  | Identity-bearing columns in Core schema or SQL source that are missing from the identity rekey registry                                                                                                                                    | Email changes and offboarding must handle every identity-bearing column consistently.                                                                                                                                                                                                      |
| `resource-action-access`       | Warns when a resource-scoped action in `actions/` has no declared `access` contract or `assertAccess`                                                                                                                                      | Resource-scoped actions need an explicit access check before they read or change a resource.                                                                                                                                                                                               |
| `feature-dependencies`         | A configured feature whose required optional peer package is not resolvable                                                                                                                                                                | The feature cannot load until its optional peer dependency is installed.                                                                                                                                                                                                                   |
| `server-plugin-default-export` | A file Nitro loads from `server/plugins/` (any depth; `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, `.cts`, `.tsx`, `.jsx`; spec/test files excluded) with no runtime default export                                                               | Nitro calls every plugin's default export, so the production build fails with `[MISSING_EXPORT]` even though typecheck passes. Use `export default defineNitroPlugin(...)`, or move registration-only code to `server/` and import it from a plugin.                                       |
| `migration-manifest`           | Imports listed in an installed package's `migration-manifest.json`                                                                                                                                                                         | The import is scheduled to move or disappear. Preview with `--dry-run`, then apply the supported codemod before the upgrade makes it a hard error.                                                                                                                                         |

Guard names are stable identifiers — the same strings work in `--only`, `agent-native.json`'s `doctor.disabledGuards`, and every finding's `guard` field.

Run the migration preflight:

```bash
agent-native doctor --only migration-manifest
```

Preview the upgrade and codemod diff without writing files:

```bash
npx agent-native upgrade --codemods --dry-run
```

Apply supported import rewrites:

```bash
npx agent-native upgrade --codemods
```

`--codemods` applies supported rewrites by default; add `--dry-run` to preview
without writing files. Review the diff and warnings before applying. `--yes` is
accepted for compatibility and has no effect. `migration-manifest` has no opt-out
and cannot be disabled. Entries marked `planned` are warnings only and are not
rewritten. Active entries are findings, and the codemod rewrites them only when
the destination export is installed. Removed exports require a manual migration.
Follow the guide linked in the finding or the [Core 0.198.0 migration guide](/docs/upgrading-core-ui),
which lists the `ts-morph` and optional feature packages used by the migration.

## Flags and exit codes {#flags}

```
agent-native doctor                        Scan app source for security-critical guard violations
agent-native doctor --json                 Machine-readable report: { ok, findings, guardsRun, strict }
agent-native doctor --only <guard,guard>   Run only the named guard(s)
agent-native doctor --strict               Keep the build gate explicit when invoked by `agent-native build`
agent-native doctor --cwd <dir>            Run against a project root other than the current directory
agent-native doctor --fix                  Not implemented in this version
agent-native doctor --help                 Show this help
```

- `--only` takes a comma-separated list of guard names from the table above, e.g. `--only no-env-credentials,no-env-mutation`. An unknown name exits `2` with a usage error before any scan runs.
- `--strict`, on the standalone `agent-native doctor` command, only tags the `--json` output's `strict` field — the command already exits `1` on any finding whether or not `--strict` is passed. It matters when `agent-native build --strict` runs doctor as a pre-step; see [The build hook](#build-hook).
- `--fix` is reserved for a future version. Passing it today prints an error and exits `2` instead of silently doing nothing, so a script that already passes `--fix` fails loudly rather than getting a false "clean."

Exit codes: **`0`** clean, **`1`** findings present, **`2`** usage or execution error (a bad `--cwd`, an unknown `--only` guard, or `--fix`).

## The build hook {#build-hook}

`agent-native build` always runs doctor as a pre-step before the real build. It is a **hard gate by default**: findings print to stderr prefixed `[doctor]`, and the build exits before publishing. A project using `agent-native build` can explicitly opt out with `failOnBuild: false`, but `--strict` always keeps the gate enabled. Community templates that use another build command receive a strict `prebuild` hook instead:

- `agent-native.json` sets `{ "doctor": { "failOnBuild": false } }` only when that exception has been reviewed.

If the doctor pre-step itself throws — for example, because a source file is unreadable or a guard crashes — the build is blocked. A partial security scan must never look like a clean result.

## Per-app configuration {#config}

`agent-native.json`'s optional `"doctor"` key. A fresh app runs every enabled Doctor guard
with zero config and fails builds on findings by default:

```json
{
  "doctor": {
    "disabledGuards": ["no-drizzle-push"],
    "dbToolScopingDenylist": {
      "legacy_import_log": "Write-only ingestion table, no per-user reads — reviewed 2026-06-01."
    },
    "failOnBuild": true
  }
}
```

| Key                     | Type                    | Effect                                                                                                                                                                                                                                     |
| ----------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabledGuards`        | `string[]`              | Guard names (from the table above) to skip — excluded from the default run and from the build hook. An explicit `--only` list overrides this and runs even a disabled guard.                                                               |
| `dbToolScopingDenylist` | `Record<table, reason>` | Bare table name (the SQL name, not template-prefixed) → reviewer-readable reason. Used only by `db-tool-scoping`. A denylist entry with no matching table in `server/db/schema.ts` itself becomes a finding — "stale … entry — remove it." |
| `failOnBuild`           | `boolean`               | See [The build hook](#build-hook). Default `true`; set `false` only for a reviewed exception.                                                                                                                                              |

## Opt-out markers {#opt-out}

Five guards support a per-line comment marker with a required short reason after an em dash or hyphen:

```ts
// guard:allow-unscoped-credential — support tool reads any user's key by design, gated on admin role
const key = resolveCredential("stripe_secret");
```

| Guard                     | Marker                                        | Placement                                                                                 |
| ------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `no-unscoped-credentials` | `// guard:allow-unscoped-credential — reason` | Same line as the call                                                                     |
| `no-unscoped-queries`     | `// guard:allow-unscoped — reason`            | Anywhere in the enclosing block, or in the file's first 30 lines for a whole-file opt-out |
| `no-env-credentials`      | `// guard:allow-env-credential — reason`      | Same line, or the line immediately above                                                  |
| `no-env-mutation`         | `// guard:allow-env-mutation — reason`        | Same line, or the line immediately above                                                  |
| `no-localhost-fallback`   | `// guard:allow-localhost-fallback — reason`  | Same line, or the line immediately above                                                  |

`no-drizzle-push` has no opt-out marker — remove the forbidden script instead. `db-tool-scoping` doesn't use a code comment at all; add the table to `agent-native.json`'s `doctor.dbToolScopingDenylist` with a reason (above).

A reason is required: `no-unscoped-credentials`, `no-env-credentials`, `no-env-mutation`, and `no-localhost-fallback` all reject a marker with no text after the dash — for `no-env-credentials` specifically, a reasonless marker still reports as an "opt-out invalid" finding rather than being silently accepted. `no-unscoped-queries`'s marker doesn't syntactically enforce a reason, but every opt-out should still carry one for the next reviewer.

## JSON output for CI {#json-output}

The actual `--json` payload also carries a `strict` field reflecting whether `--strict` was passed (the `--help` summary line above omits it for brevity):

```json
{
  "ok": false,
  "findings": [
    {
      "guard": "no-env-mutation",
      "file": "server/middleware/auth.ts",
      "line": 22,
      "message": "process.env mutated in production code: process.env.AGENT_USER_EMAIL = userEmail. process.env is process-scoped, not request-scoped — use runWithRequestContext instead."
    }
  ],
  "warnings": [],
  "guardsRun": [
    "no-drizzle-push",
    "no-empty-migrations",
    "no-unscoped-credentials",
    "no-unscoped-queries",
    "no-env-credentials",
    "db-tool-scoping",
    "no-env-mutation",
    "no-localhost-fallback",
    "explicit-collab-access",
    "identity-columns-registered",
    "resource-action-access",
    "feature-dependencies",
    "server-plugin-default-export",
    "migration-manifest"
  ],
  "workspaceApps": ["apps/mail", "apps/tasks"],
  "strict": false
}
```

`ok` is `true` when `findings` is empty; planned migration entries are listed
under `warnings` and do not make the command fail. `guardsRun` lists every guard
that actually ran (after `--only` / `disabledGuards` filtering) — useful for
confirming a guard wasn't silently skipped.

The JSON report always writes to **stdout**, whether the run is clean or has findings — the exit code (`0` clean, `1` findings present) is the signal for pass/fail, not which stream the report landed on. A plain `agent-native doctor --json > report.json` captures the full report in every case, including a non-clean run; no stream redirection tricks needed. (Usage/execution errors — a bad `--cwd`, an unknown `--only` guard — still print their own JSON error payload to stderr and exit `2`; those aren't the report.)

## Doctor vs. upgrade check vs. recap doctor {#relationship}

`agent-native doctor` is one of three "doctor"-shaped commands in the CLI, each diagnosing a different domain — none are folded into a shared mega-doctor:

- **`agent-native doctor`** (this page) — source-code safety invariants: data scoping, credential handling, environment-variable usage.
- **`agent-native upgrade check`** — dependency-pin health: stale `@agent-native/*` pins, framework overrides/patches.
- **`agent-native recap doctor`** — PR Visual Recap configuration health.

## What's next

- [**Security & Data Scoping**](/docs/security) — the `ownableColumns()` / access-guard model that `no-unscoped-queries` and `db-tool-scoping` enforce
- [**Audit Log**](/docs/audit-log) — the durable record of who changed what, for after a mutation lands
- [**Deployment**](/docs/deployment) — where `agent-native build` and `agent-native.json` fit into shipping
