agenthropic is a self-hosted, local-first dashboard that reads Claude Code transcripts
from ~/.claude/projects and serves them over a loopback-bound HTTP server. It is a
personal project with a single maintainer.
This document states what is supported, what the security model actually is, what counts as a vulnerability here, and how to report one.
| Version | Supported |
|---|---|
main |
Yes |
| Anything else | No |
There is no released version. The root package is at version 0.3.0 (package.json) and
every workspace package is private: true; there is no tag, no published package and no
binary, and the only way
to run agenthropic is a checkout of main plus the Quickstart in README.md. v1.0 is
targeted for 2026-12-01 (RELEASE.md, kill checkpoint KC-4). Until then, fixes land
on main only, and "upgrade" means pulling main.
Node 22 is required (.nvmrc; engines.node is >=22 <23, enforced at install and, by
scripts/check-node-version.mjs, at pnpm test / pnpm start).
AMENDED 2026-09-23 (J-1). The guard reaches further than pnpm test / pnpm start, which
was its whole wiring when this was written: it also prefixes the root gate:node,
apps/server's dev, start, bench and test, apps/web's dev and test, and the
test script of packages/shared, packages/core and packages/test-fixtures. It is not on
typecheck, lint, format, format:check, hooks:install, apps/web build or
render-claims. Treat it as a footgun guard, not a security control: it is a package-script
prefix, so a direct npx vitest / tsx invocation runs on whatever Node is on PATH.
Use GitHub's private vulnerability reporting:
- Open https://github.com/IvanBBaev/agenthropic/security.
- Click Report a vulnerability.
- Describe the issue, the affected file or endpoint, the preconditions an attacker needs, and a reproduction if you have one.
If that button is not there, the maintainer has not enabled private reporting on this repository yet. In that case, do not open an issue containing the exploit. Open a normal issue that names only the affected component (for example "the SSE origin check" or "corpus path resolution") and asks for a private channel, then wait for one to be opened. Keep the details out of the public thread until then.
There is no security email address, no phone number and no PGP key published for this project. Do not send exploit details through any channel other than the private report above.
agenthropic is maintained by one person in their own time. Concretely:
- No response-time commitment. There is no SLA, no triage rota and no guarantee that any particular report is looked at within any particular window.
- No bug bounty. There is no money, no swag and no reward program of any kind.
- No CVE process. No CNA relationship exists; nothing here is going to be assigned an identifier by this project.
What a valid report does get is a fix on main, and acknowledgement of the reporter in
the fix if the reporter wants to be named.
The server binds 127.0.0.1 only and every /api/* route is Bearer-token gated (the static
SPA shell, which holds no secret, is the one thing served without a token). That shapes
both halves of the scope below.
An attacker who already has local shell access as the user running the dashboard is
outside the threat model. Such an attacker can already read ~/.claude/projects
directly, read the SQLite database directly, read DASHBOARD_TOKEN out of the
environment or the running process, and execute anything the user can execute. Nothing
in agenthropic is designed to stand up to that, and a report whose precondition is
"given local code execution as the user" will be closed as out of scope. So will:
- Findings against a deployment that deliberately widened the bind or put a reverse proxy in front of the port. That is not a supported configuration (see rule 5).
- Missing rate limiting, resource exhaustion or denial of service against a loopback-only, token-gated port.
- Vulnerabilities in Claude Code itself, or in the content of your own transcripts.
- Anything requiring physical access to the machine.
- Dependency advisories with no demonstrated path to one of the in-scope items below. Those are welcome as a normal issue instead.
Anything that:
- widens the bind beyond loopback, or lets a request reach the server from a non-loopback address;
- weakens or bypasses the auth gate: an ungated route, a path that reaches a handler
before the global
onRequesthook, or a token comparison that leaks; - reintroduces a subprocess spawner or dynamic code evaluation reachable from request input;
- leaks the dashboard token anywhere it should not be, including a log line, an error body, a response payload, the database, or a process argv;
- escapes the read-only corpus path: a traversal, a followed symlink, or any write,
rename or unlink against
~/.claude/projects; - defeats redaction, so a credential present in a hook payload reaches storage or the browser;
- introduces an outbound request whose URL comes from ingested data (SSRF).
Every item below is enforced in code, not by convention. File references are to main.
The bind host is the constant HOST = '127.0.0.1' in apps/server/src/config.ts, and
there is no configuration path that changes it. The port is configurable
(DASHBOARD_PORT); the host is not. After listen, enforceLoopbackOrExit in
apps/server/src/index.ts re-reads every bound address and calls process.exit(1) if
any of them is not loopback, so a wide bind terminates the process instead of serving.
The static gate additionally fails the build on 0.0.0.0, host: true, host: '' and
host: '::' / host: '::0' anywhere in the scanned trees, and on any package.json
scripts entry that passes the wide address or a --host flag without an explicit
loopback value (a CLI flag would override the config file's loopback host).
Since 2026-09 that check also fails closed on an empty address list. If the server
reports no bound address at all, the loopback invariant could not be verified, and an
unverifiable invariant is treated as a failure: the process logs FATAL: the server reported no bound address, so the loopback-only invariant could not be verified; shutting down.,
runs its cleanup and exits non-zero. The earlier form passed vacuously in that case, because
"none of the addresses is non-loopback" is trivially true of an empty list.
This is the most important line in this document, because it describes a surface
agenthropic deliberately does not have. There is no /api/run, no endpoint that spawns
a process, and no path from request input to a shell. The refusal is guarded statically
rather than left to review: scripts/check-no-spawner.mjs fails the build on
child_process, execa, .spawn(, spawnSync, execSync, execFile*, fork(,
bracket-form access to any of those, eval(, indirect eval, Function(, the vm
module, data: dynamic imports, concatenated dynamic-import specifiers, and imports of
subprocess-wrapper packages (cross-spawn, cross-spawn-async, shelljs, node-pty,
tinyexec, zx, nano-spawn, spawndamnit). It scans apps/,
packages/, scripts/, hooks/ and the repo-root config files, sources and tests
alike, and it also opens the root and every workspace package.json and fails on a
direct dependency named for a subprocess or WebSocket package (execa, the wrapper
packages above, ws, socket.io, anything containing websocket) - direct
dependencies only; a transitive one is the lockfile review's job. It runs as the
first CI step, before typecheck (.github/workflows/ci.yml) - it is the cheapest
check in the workflow and guards the one invariant the project cannot walk back.
The gate's own header is honest about its limit: a regex scanner stops the idiomatic reintroduction paths, not a developer who is deliberately obfuscating. If you find a way to get a process spawned, obfuscated or not, that is a valid and high-severity report.
The gate has exactly two escape hatches, both auditable: a whole-file allowlist that
contains only the policy file itself (and logs on every run), and a per-line
spawner-gate-allow marker that is visible in any diff. As of 2026-09-09 the marker
covers three sanctioned exceptions, none reachable from a request: the license gate's
fixed-argv pnpm licenses list call (scripts/check-licenses.mjs), a server test that
runs tsx once at test time to re-measure the migration checksums it pins
(apps/server/test/migrations-checksum-stability.test.ts), and a shared test asserting
the loopback guard rejects 0.0.0.0 (packages/shared/test/security.test.ts).
AMENDED 2026-09-23: this paragraph used to say the marker "sits on three sites", which
reads as a line count and is not one. Those three exceptions are carried on five
marked lines, because two of them need the marker on the import as well as on the
call. Since the gate started reporting its own hatches it prints each of those lines and
totals them as 5 line(s) inline-exempt, so audit the gate's output against the line
count, not against the number of exceptions. Two further mentions of the marker sit in
prose - comments explaining why a file carries it - and suppress nothing; the gate names
them as dead markers and does not fail on them.
requireDashboardToken in packages/shared/src/security/index.ts throws when
DASHBOARD_TOKEN is unset, empty, or shorter than MIN_TOKEN_LENGTH (16 characters),
so the server refuses to start rather than defaulting to open. Comparison is
timingSafeTokenEqual: both sides are hashed to fixed-length SHA-256 digests before
crypto.timingSafeEqual, so neither the value nor its length leaks through timing or
through that function's equal-length precondition.
The gate itself is a single global onRequest hook registered before any route in
apps/server/src/server.ts, so a route added tomorrow is token-gated by construction
instead of by someone remembering a per-route guard. It authorizes on Fastify's routed
path (request.routeOptions.url), not the raw request URL, so a percent-encoded path
such as /%61pi/health cannot slip past it. /api/health and the hook receiver
(POST /api/hooks/event) are gated exactly like everything else - reading health
requires the token.
The realtime transport is server-sent events, never WebSocket. The static gate fails the
build on WebSocketServer, any import or require whose specifier contains websocket,
the ws and socket.io packages, and the { websocket: true } Fastify route option.
On /api/stream the same-origin check runs before the token check, so a request
carrying a foreign Origin is rejected with 403 even when it presents a valid token. A
present Origin must be exactly http://127.0.0.1:<port> or http://localhost:<port>
(isAllowedOrigin); no wildcard CORS header is ever sent. A request with no Origin
header is allowed through to the token check on purpose: that means a non-browser client,
which is not the attack this rule closes, and it still has to present the credential.
Because the browser EventSource API cannot set headers, /api/stream also accepts the
token as ?token=. That is the one place a token can legitimately appear in a URL, so it
is the one place logging scrubs it: the Fastify request serializer
(redactedRequestSerializer in apps/server/src/server.ts) runs redactTokenInUrl on
every request line, and the root not-found handler logs nothing, so an unrouted request
such as POST /api/stream?token=... cannot write its URL into a log message either
(Fastify's default 404 handler logs the raw URL as a plain message, bypassing the
serializer).
Reach the dashboard from another machine with an SSH port-forward or a Tailscale tunnel terminating at the loopback address. This repository contains no TLS termination, no reverse-proxy configuration and no public-exposure path, and none will be added. If you put agenthropic behind a proxy on a public address, that is outside the supported configuration and outside this policy.
The filesystem port that the entire ingest side depends on (CorpusFs in
apps/server/src/corpus/fs-port.ts) exposes readDirNames, lstat, realpath,
readFileConfined and readFileTailConfined - and nothing else. There is no write,
rename, unlink, chmod or open-for-write operation on the interface at all, so the live
transcripts Claude Code is actively appending to cannot be perturbed by the dashboard,
even by accident.
The reads are hardened as well as narrow: the walk uses lstat rather than stat so a
symlink is observable as a symlink and skipped instead of silently followed, and files
are opened O_RDONLY | O_NOFOLLOW (apps/server/src/corpus/node-corpus-fs.ts) so a
symlink swapped in after the check cannot be followed either. A path that resolves
outside the canonical corpus root raises ContainmentError, which is the one error the
ingest loop never swallows: exitOnCorpusFatal in apps/server/src/index.ts logs it and
exits non-zero, on the grounds that a crafted or compromised corpus is a stop-everything
signal rather than something to skip past.
Note that the corpus is treated as untrusted input, not as trusted local data - it is whatever an agent session happened to write. Corpus-driven parsing findings are in scope.
Hook payloads are scrubbed by redactSecrets (apps/server/src/hooks/redact.ts)
before the envelope is built and before the idempotency key is computed
(apps/server/src/hooks/routes.ts), so a raw secret never reaches the stored envelope or
its hash, and a redelivered event redacts identically and still dedupes. Two independent
rules apply. Field names that normalize to a secret-bearing fragment (token, secret,
password, passwd, credential, apikey, authorization, bearer, privatekey,
accesskey, sessionkey, cookie) are replaced wholesale, with an explicit allowlist so
token count fields such as input_tokens survive as the observability data they are.
String values are then scanned for credential shapes - sk- keys, GitHub ghp_/gho_/
ghu_/ghs_/ghr_ and github_pat_ tokens, xox-prefixed Slack tokens, AKIA access
key ids, JWTs, and inline Bearer fragments - and each match is masked in place.
Redaction is defence in depth, not a promise that your database holds no sensitive text: the JSONL corpus is ingested for token counts and structure, and it is your own prompt content. Treat the SQLite file and its backups as sensitive.
SQLite through better-sqlite3, opened in WAL mode with foreign_keys = ON. Both pragmas
are read back after being set and a connection that did not take them throws rather than
being handed to the rest of the app (apps/server/src/db/connection.ts). Backups are
written by an in-process daily timer next to the database, and the restore path refuses
any image that fails PRAGMA integrity_check.
The server makes no outbound request of any kind today: no telemetry, no update check,
no webhook dispatcher (alerting is post-1.0 and not built). ANTHROPIC_API_KEY appears
nowhere under apps/, packages/, hooks/ or scripts/ - the dashboard process does
not hold it and no code path reads it. The SSRF class of bug therefore currently has no
surface to land on; a change that introduced one would be in scope. Since 2026-09-26 the
absence is also enforced: pnpm run gate:spawner fails CI on an outbound network
primitive or HTTP client in apps/server/src/ or packages/*/src/, or an HTTP client
package in those manifests.
None of the above helps if the deployment undoes it.
- Use a long, random
DASHBOARD_TOKEN. The 16-character minimum is an enforced floor, not a recommendation. Keep it out of shell history and out of version control. - Do not expose port 4317 (or your
DASHBOARD_PORT) to a network. Tunnel instead. - The SQLite database and its backups contain your session content. Protect them the way you protect the transcripts they were built from.
- The hooks installer (
hooks/install.mjs) generates acurlcommand that reads the token from the environment at fire time using curl's--variable/--expand-header, so the token never enters any process's argv. That mechanism requires curl 8.3.0 or newer; on an older curl the hook delivers nothing rather than falling back to a form that would leak. If you hand-edit those hooks, do not put the literal token on the command line. - The same command starts with
curl --disableand passes--noproxy '*': a~/.curlrccannot add tracing or a proxy, and anhttp_proxy/ALL_PROXYin the environment cannot route the loopback POST (token and prompt) through a proxy. Hooks written by an earlier installer version are recognised and upgraded in place on the next install.
pnpm run gate:spawner # static: no spawner, no wide bind, no eval, no WebSocket
pnpm run test # includes apps/server/test/security-contract.test.tsThe security contract suite boots the real composition root and asserts that only
127.0.0.1 is bound; that /api/health is 401 without a token and 401 with a wrong
token; that a foreign Origin on /api/stream is 403 token or not; that a
percent-encoded /api path cannot bypass the gate; that the origin check fails closed
when the local port cannot be determined; that a 401 body never echoes the token; and
that constructing the config without DASHBOARD_TOKEN throws.
One caveat, stated plainly because the workflow file states it too: CI runs these gates
on every push and pull request, and since 2026-08-25 a red gate also withholds the
merge button, because main requires the ci status check - the job id in
.github/workflows/ci.yml, lowercase, not the workflow's display name. Until that date
this paragraph ended "making a red gate block a merge requires a GitHub branch-protection
rule. That is an owner setting on github.com and is not something this repository can
attest." The rule now exists; force-pushes to main and deletion of main are refused for
everyone.
The one exemption is deliberate, and it travels with every "merge-blocking" claim in this
repository: enforce_admins is off, so a red run withholds the merge button from a
contributor and not from the repository owner. agenthropic has exactly one maintainer
whose normal working mode is a direct push to main, and turning admin enforcement on would
lock the sole maintainer out of their own repository. That is a stated design choice, not an
oversight and not something still to be done. Verify:
gh api repos/IvanBBaev/agenthropic/branches/main/protection \
--jq '{contexts: .required_status_checks.contexts, enforce_admins: .enforce_admins.enabled}'
# -> {"contexts":["ci"],"enforce_admins":false}The standing write-up lives in the decisions index.