Security model
agent-devtools is a local dev-server-only tool. Zero bytes of widget code reach a production bundle, and there is no externally-reachable surface. This page documents the layers that uphold that boundary plus the honest scope of the workspace setting.
Pairing Token
Section titled “Pairing Token”The agent server requires Authorization: Bearer <token> on every request. The token contract is defined in packages/core/src/server/auth.ts.
- Rotation policy — 32 random bytes (
crypto.randomBytes(32)encoded as base64url), minted once per CLI process start. The token dies with the process; a restart produces a brand-new token. - In-memory only / never persisted — the token is not written to any file. It does not leak into dotenv, lock files, or caches.
- Never in URLs — to avoid leaking via browser history, external reverse-proxy logs, or error-reporter URL capture, the token never appears in a query string or path. Only an inline
<script>in the dev HTML<head>exposes it viawindow.__AGENT_DEVTOOLS_CONFIG__. It is not present in any source file — it lives only inside Vite’stransformIndexHtmlresponse (see the header comment inpackages/vite/src/plugin.ts). - Header transport — fetch and SSE requests carry the token solely via
Authorization: Bearer …. - Constant-time comparison — the server validates with
timingSafeEqualto deny length/content side-channel attacks (packages/core/src/server/auth.ts:26).
Dev-Only Guard (2-layer)
Section titled “Dev-Only Guard (2-layer)”To prevent widget code from ever shipping to production users, every bundler integration follows the same two-layer guard. Full contract: .claude/rules/dev-only-guard.md.
Layer 1: Build-time exclusion
Section titled “Layer 1: Build-time exclusion”In a production build, the agent-devtools code path never enters the module graph in the first place. We do not rely on tree-shaking.
- Vite — the
agentDevtools()plugin declaresapply: 'serve'(packages/vite/src/plugin.ts:109), sovite buildignores the plugin entirely.transformIndexHtmlis never invoked, so neither the widget bootstrap nor the pairing-token inline script can sneak into production HTML. - User-side dynamic import guard — when mounting manually without the plugin, the recommended pattern is
if (import.meta.env.DEV) { await import('@agent-devtools/react') }(see the README “Mounting without the plugin” section). The dynamic import itself is tree-shaken out of the production bundle. - Next.js / Nuxt / Webpack — same discipline: the plugin/module entry only adds imports or entries after checking
NODE_ENV !== 'production'(ornuxt.options.dev). New adapters must inherit this contract verbatim.
Layer 2: Runtime NODE_ENV gate
Section titled “Layer 2: Runtime NODE_ENV gate”Even if Layer 1 is bypassed, the code refuses to run. Fail-loud (throw) is the default — silent no-op would hide a misdeployment.
mountAgentDevtools()throws whenprocess.env.NODE_ENV === 'production'(seeisProductionBuildinpackages/widget-core/src/orchestrator/mount.ts:764). The explicit override{ force: true }is the only escape hatch and is intended for justified operational debugging.startAgentDevtoolsServerperforms the same check — the server will neverlistenin production.enabled: falseand similar runtime opt-out options are a separate layer from Layer 2. Opt-out is a dev-time off switch and does not substitute for the production block.
127.0.0.1 Loopback
Section titled “127.0.0.1 Loopback”The local agent server binds the loopback interface only — there is no external network exposure.
- The
LOOPBACK_HOST = '127.0.0.1'constant is enforced at the type level (packages/core/src/server/server.ts:9). Thehostoption type itself istypeof LOOPBACK_HOST, so binding to any other interface is not even expressible. - If the default port is busy, sequential fallback tries subsequent ports. If no port in
[desiredPort, desiredPort + maxAttempts - 1]is free, startup fails with an explicit error. - The browser does not hit
http://127.0.0.1:<port>directly. It goes through a same-origin proxy mount (/__agent_devtools) on the Vite dev server (packages/vite/src/plugin.ts). This removes the CORS preflight surface and keeps the loopback binding strictly server-side.
Closed Shadow DOM
Section titled “Closed Shadow DOM”The widget UI mounts onto the host page inside a closed shadow root.
- Host CSS variables, global styles, and event flow are isolated. None of the widget’s CSS leaks into the host.
- The React 19 runtime is a separate module instance — the widget does not depend on the host’s React provider/context, Pinia, or Redux store (dual-tree). Version mismatches with the host React are not a concern.
- The
AGENT_DEVTOOLS_OPEN_SHADOW=1environment variable is Playwright E2E-only. It flips the shadow root to open so automation can snapshot the widget’s internal DOM; the production-default closed isolation is never changed (packages/vite/src/plugin.ts:103).
For the full adapter isolation contract, see the “Isolation” section in .claude/rules/adapter-discipline.md.
Workspace boundary — what it does and does not enforce
Section titled “Workspace boundary — what it does and does not enforce”The workspace option (see configuration) is the canonical cwd of the spawned Claude Code child process and the boundary that the in-process FileTools (used by the picker source-slice preamble) enforces. It is not an OS-level sandbox.
What is enforced:
Workspace.resolveForRead/resolveForWriteinpackages/core/src/files/workspace.tscanonicalise viarealpathSyncand check the result against the canonical root. Any..escape, or a symlink whose target escapes the root, throwsPathOutsideWorkspaceErrorbefore any FS call. This applies to picker preamble reads only — the source slice the widget attaches to a message so the agent does not have to grep for the picked file (seepackages/core/src/providers/context-preamble.ts).- The Claude Code child process inherits the workspace root as its
cwd(packages/core/src/providers/sdk.tsandpackages/core/src/providers/acp.ts). Tools that respect cwd (relative path resolution, working-directory-relative searches) inherit that scope automatically.
What is not enforced by agent-devtools:
- The SDK’s own tool calls (
Read,Edit,Bash, …) run inside the child process with the host user’s OS file-system permissions. The same files you can open from a terminal at that cwd are reachable. agent-devtools does not layer an additional FS sandbox, jail, container, or AppArmor profile on top. - Claude Code’s own workspace-trust prompts and
--allowedToolsflags still apply — those are SDK-side controls, not something agent-devtools adds. - The action-aware permission policy is the right knob for limiting what the agent is allowed to do. It cancels
bash,webFetch, andmcpToolby default, which is what keeps an unattended browser tab safe. It does not narrow the FS surface beyond what the SDK already exposes.
If you need a stricter FS boundary (read-only mode, containerised cwd, etc.), run the dev server inside a container or under an OS user that only has access to the project directory.
Automated regression guards
Section titled “Automated regression guards”Two automated checks run continuously to prevent any of the four layers from silently breaking.
- Dev injection check — the example’s
pnpm devHTML must contain the widget bootstrap<script>tag. - Production no-leak check — grepping every text file in the example’s
pnpm buildoutput must yield zero occurrences of@agent-devtools. This is enforced bypackages/vite/src/build-integration.test.ts, which runs a real production build.
If either check fails in CI, release is automatically blocked. Any change that bypasses or disables a regression guard must carry an explicit justification in the PR body or it will be rejected.
Related docs
Section titled “Related docs”- Installation & plugin configuration:
installation,configuration - Permission model:
permission-modes - First-run walkthrough:
first-run - Bring your own provider — the server-side seam every LLM backend runs through:
byo-provider