Security
Claude Remote puts a full shell of a cluster account behind a public HTTPS endpoint. This document states what is protected, by which implemented control, which test covers each control, and what is left exposed. Authentication details are in authentication.md; the network path is in network-architecture.md.
1. Threat model
Assets
| Asset | Where | Consequence if compromised |
|---|---|---|
| Interactive shell / Claude Code session of the operator account on M5M36 | tmux sessions | Arbitrary code execution as that account; lateral movement to nodes that accept its SSH key |
Claude Code account credentials (after /login) |
macOS Keychain / ~/.claude of that account |
Use of the owner's Claude subscription |
| Project files | workspace root | Disclosure, tampering |
| Node SSH key | ~/.ssh on M5M36 |
Shell on the allow-listed compute nodes; git access (git-shell only) on the git server |
| Application secrets | .env (0600), PM2 environment, the mld manifest on the gateway |
CR_RUNTIME_TOKEN (runtime control), CR_SECRET_KEY (TOTP secrets), database password, agentd token |
| Authentication database | PostgreSQL (loopback) | Password hash, encrypted TOTP secret, passkey public keys, session token hashes |
| Backups | ~/backups/claude-remote + passphrase file |
Offline copy of the database |
Actors
| Actor | Capability assumed |
|---|---|
| Anonymous Internet client | Can send any request to https://claude.spboucher.ai |
| Hostile web page opened in the administrator's browser | Cross-site requests with ambient cookies, WebSocket attempts, framing attempts |
| Device on the home LAN | Can reach M5M36:8620 directly over plain HTTP |
| Another local process running as the same macOS account | Can read the account's files, including .env |
| Another local macOS account on M5M36 | Can connect to TCP ports; cannot read 0600/0700 files |
| Content processed inside a session (repository, web page, MCP output) | Can influence Claude Code (prompt injection) or run as code the administrator executes |
| The public gateway (BHS64) | Terminates TLS: sees all traffic in clear, sets X-Forwarded-For |
There is one administrator. There are no roles and no multi-tenancy.
Trust boundaries
- Internet → Caddy on the gateway (TLS).
- Gateway →
apps/serverover WireGuard (plain HTTP inside the tunnel). apps/server→apps/runtimeover the unix socket (bearer token).apps/runtime→ tmux → the session process (no boundary: same account).- Session process → Claude Code hook endpoint (low-privilege hook token).
- M5M36 → other nodes (SSH, only hosts in
CR_EXEC_HOSTS) and agentd (read-only telemetry).
Attack surface
| Surface | Reachable by | Notes |
|---|---|---|
/api/v1/* REST and WebSocket upgrades |
Internet, LAN | 1 public route (GET /health), pre-auth login/setup routes, everything else authenticated |
| Static web application | Internet, LAN | No secrets; strict CSP |
| Runtime unix socket | Processes of the same account | Requires the bearer token |
| tmux socket | Processes of the same account | Stock tmux permissions |
| PostgreSQL | Loopback | Password authentication for role claude_remote |
| Claude Code hooks | Processes inside sessions | POST /hooks/claude only |
2. Controls implemented, with the test that covers each
Test files: S/ = apps/server/src/, R/ = apps/runtime/src/, W/ = apps/web/.
Authentication and sessions
| Control | Implementation | Test |
|---|---|---|
| Every API route requires a session with a completed second factor unless explicitly listed | global guard S/security/guard.ts; route table checked |
S/http.test.ts — "every registered API route refuses unauthenticated callers…", "the declared auth levels match the documented public list" |
Public health discloses only {ok, version} |
S/modules/system/index.ts |
S/http.test.ts — "health is public and discloses only {ok, version}" |
| One-time setup token (0600 file, never logged, constant-time compare, removed once used); setup is 404 once an administrator exists | S/security/setup-token.ts, S/modules/auth/index.ts |
S/modules/auth/auth.test.ts — setup tests |
| Password ≥ 12 characters, argon2id (64 MiB, 3 passes) or scrypt fallback, dummy verification for unknown users | S/security/password.ts |
S/security/unit.test.ts; auth.test.ts — "wrong password, unknown user and wrong TOTP all answer 401 invalid_credentials" |
| Mandatory second factor; a password alone cannot enrol a new factor once one exists | assertMayEnrol in S/modules/auth/common.ts |
auth.test.ts — "a fresh admin without a second factor may only use enrolment endpoints", "with a second factor enrolled, the password alone cannot enrol another factor or reach the API" |
| TOTP (RFC 6238, ±1 step), secret sealed with AES-256-GCM bound to the user id, each code accepted once | S/security/totp.ts, crypto.ts, checkTotp |
unit.test.ts (RFC vectors, replay); auth.test.ts — "codes cannot be replayed" |
| Passkeys (WebAuthn): RP ID = public hostname, exact origin, user verification required, server-side single-use challenges (5 min), counter check | S/security/webauthn.ts, S/modules/auth/factors.ts |
S/modules/auth/webauthn.test.ts (12 tests, software authenticator) |
Session cookie __Host-cr_sid: HttpOnly, Secure, SameSite=Strict, Path=/, no Domain; only the SHA-256 of the token is stored |
guard.ts, S/security/sessions.ts |
S/prod.test.ts; auth.test.ts — "logout revokes; only the sha256 of the token is stored" |
| Idle timeout 12 h, absolute 30 d, 15 min before the second factor; token and CSRF token rotated when the second factor completes | sessions.ts |
auth.test.ts — "absolute expiry", "idle timeout (12 h) and sliding refresh", "…the session token is rotated on upgrade" |
| Brute-force throttling stored in the database: 5 failures per username, 10 per IP, 100 global per 15 min; progressive lock (60 s doubling, cap 15 min); a correct password does not reset the TOTP counter | S/security/throttle.ts |
auth.test.ts — four throttling tests; webauthn.test.ts — "failed passkey verifications are throttled like passwords" |
Request rate limits per minute: credential endpoints 20 per IP (shared bucket), anonymous GET /auth/status 240 per IP, GET /health 600 per IP, everything else 600 per session |
S/app.ts |
S/rate-limit.test.ts (6 tests), prod.test.ts |
| Password change revokes all other sessions; revoking a session closes its WebSockets at once (4401); open sockets re-check their session every 60 s | S/live.ts, S/modules/terminal-ws.ts |
auth.test.ts — "password change revokes every other session…"; S/modules/ws.test.ts — "logout closes the terminal socket with 4401…", "a session revoked or expired behind our back is caught by the periodic re-check" |
Browser-side attacks
| Control | Implementation | Test |
|---|---|---|
CSRF: mutating requests need X-CSRF-Token and Origin equal to CR_PUBLIC_ORIGIN; pre-auth POSTs need the Origin and application/json |
guard.ts |
auth.test.ts — "mutations need the session CSRF token and the exact Origin", "rejects a wrong token (and the Origin / content-type rules apply before auth)" |
| WebSocket upgrades need the cookie, a completed second factor and the exact Origin; a refused upgrade is a plain HTTP answer | guard.ts, terminal-ws.ts |
ws.test.ts — "refuses without a cookie (401), with a wrong or missing Origin (403), before the second factor (403)" |
CSP without 'unsafe-inline' for scripts: per-document SHA-256 hashes of the export's inline scripts; object-src 'none', frame-ancestors 'none', frame-src 'self' blob:; all assets self-hosted |
S/static.ts |
http.test.ts — "serves trailing-slash routes with a per-document CSP…"; unit.test.ts — "never allows unsafe-inline scripts"; W/e2e/features.spec.ts — "every route renders without console errors or CSP violations" |
HSTS (2 years, includeSubDomains), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Cross-Origin-Resource-Policy: same-origin, Cache-Control: no-store on API responses |
app.ts, guard.ts, static.ts |
http.test.ts — "API responses are no-store and carry the hardening headers" |
| CSRF token kept in memory only; downloads served as attachments; Markdown preview renders no raw HTML; SVG not previewed as an image | W/src/lib/api.ts, W/src/components/editor/previews.tsx, S/modules/files/index.ts |
S/modules/sessions/sessions.test.ts — "markup in titles round-trips as inert JSON"; otherwise not covered by a dedicated test |
Service worker caches the static shell only; /api/* is never handled or cached |
W/public/sw.js |
W/e2e/setup-offline.spec.ts — "offline: the cached shell loads…; /api is never cached" |
Runtime boundary and host access
| Control | Implementation | Test |
|---|---|---|
| Runtime listens only on a 0600 unix socket inside a 0700 directory; refuses to start on a live socket | R/main.ts |
R/main.test.ts — "listens only on a 0600 unix socket inside a 0700 directory", "refuses to start a second runtime on a live socket" |
| Bearer token (≥ 32 characters, constant-time compare) on every runtime HTTP route and WebSocket upgrade | R/auth.ts |
main.test.ts — "requires the bearer token on every HTTP route and WebSocket upgrade"; R/sessions.test.ts — "rejects an unauthenticated attach before any PTY exists" |
Separate hook token accepted only on POST /hooks/claude; passed to curl through a file descriptor, not argv |
auth.ts, R/bin/cr-hook |
R/hooks.test.ts — "the hook token works on /hooks/claude and nowhere else" |
Runtime secrets (CR_RUNTIME_TOKEN, DATABASE_URL, CR_SECRET_KEY, CR_AGENTD_TOKEN, all CR_*) are stripped from the environment of sessions and tasks and removed from the tmux global environment |
R/core-util.ts, R/tmux.ts |
main.test.ts — "strips runtime secrets and reserved names"; sessions.test.ts — "gives the session its id and hook access but none of the runtime secrets" |
No permission bypass: --dangerously-skip-permissions, --allow-dangerously-skip-permissions, --permission-mode bypassPermissions refused; a --resume value may not start with -; conversation ids must match ^[A-Za-z0-9][A-Za-z0-9-]{7,63}$ |
R/sessions.ts, S/modules/sessions/index.ts |
sessions.test.ts (runtime) — "rejects every permission-bypass form", "refuses an option-like value after --resume"; S/modules/sessions/sessions.test.ts — "builds claude args server-side only…"; unit.test.ts — "claude args come only from validated fields" |
File API confined to the configured workspace roots: no NUL, no absolute path, no .., realpath containment with trailing separator, no write through a symlink leaving the root, special files refused |
R/paths.ts (and a syntactic pre-check in S/paths.ts) |
R/paths.test.ts (29 tests); R/fs.test.ts; unit.test.ts — path rejection cases |
| Session and task working directories must be inside a workspace root | assertInsideWorkspace in core-util.ts |
R/tasks.test.ts — "validates host allowlist, cwd and duplicates"; sessions.test.ts — "…bad specs" |
Subprocesses use argv arrays, never a shell; git inspection disables fsmonitor and hooks; clone accepts only https://, ssh://, git@host:path, gitsrv: and never prompts |
R/fsx-exec.ts, R/git.ts |
R/git.test.ts |
Remote task hosts must be in CR_EXEC_HOSTS (checked by server and runtime); ssh -o BatchMode=yes |
S/modules/tasks/index.ts, R/core-task-manager.ts |
tasks.test.ts — "validates host allowlist…", "remote host: runs ssh with the documented argv (fake ssh on PATH)"; S/integration/real-runtime.test.ts — tasks test |
| MCP env/header values are write-only: never returned, stored, logged or audited; targets are masked for display | R/mcp.ts, R/fsx-mcp-config.ts, S/modules/skills-mcp/index.ts |
R/mcp.test.ts; S/modules/misc.test.ts — "proxies listings, caches them, and audits operations without secrets" |
| Skill operations only move, copy or delete files (no symlinks, ≤ 20 MiB, ≤ 2000 entries); skill code is never executed | R/skills.ts |
R/skills.test.ts |
| Size limits: JSON body 6 MiB, WebSocket frame 1 MiB, read 2 MiB, write 5 MiB, upload 200 MiB, task log 256 MiB (configurable cap) | app.ts, fs.ts, fsx-transfer.ts, core-task-manager.ts |
http.test.ts, ws.test.ts — "frames above 1 MiB are rejected", misc.test.ts — "upload size cap…", tasks.test.ts — "stops appending at the cap…" |
Logging, audit, data minimisation
| Control | Implementation | Test |
|---|---|---|
| Logs never contain passwords, codes, cookies, CSRF tokens, commands, file content or terminal data | pino redaction in app.ts (REDACT_PATHS); runtime logs method + path only |
auth.test.ts — "never logs passwords, codes, cookies or CSRF tokens" |
| Audit trail for authentication, session, file, task, project, skills and MCP operations, with actor, IP and outcome; no secrets or content | S/audit.ts |
auth.test.ts — "audits auth events without secrets"; misc.test.ts — "audit is paginated newest-first" |
Client IP taken from X-Forwarded-For only when the peer is a trusted proxy |
Fastify trustProxy = CR_TRUSTED_PROXIES |
auth.test.ts — "trusts X-Forwarded-For only from a trusted proxy peer" |
| Cluster view never forwards IP addresses, usernames, hostnames or serial numbers from agentd | S/modules/cluster/mapping.ts |
misc.test.ts — "never leaks IP addresses, usernames, hostnames or serials" |
| Notifications are generic unless the user opts into titles; never terminal content | S/modules/notifications/service.ts |
sessions.test.ts — "the hook message is shown only with notifications.detail = titles" |
Production refuses to start without an https origin, DATABASE_URL, CR_RUNTIME_TOKEN and a 64-hex CR_SECRET_KEY |
S/config.ts |
unit.test.ts — configuration tests |
Verified on the production deployment (2026-11-10)
Checked by hand right after deployment, not by an automated test: 10 API routes, a POST and a WebSocket upgrade all answered 401 without a session; a POST with a foreign Origin answered 403; a wrong setup token answered 401; the security headers were present; TLS was valid with HTTP→HTTPS redirect (308); data directory, socket and token files were 0600/0700; the only listeners were the server on port 8620 and PostgreSQL on loopback; the audit log recorded the real client address through the tunnel.
3. Deviations and residual risks
| # | Risk | Status |
|---|---|---|
| 1 | No dedicated OS identity. Server, runtime, tmux and every session run under the existing cluster operator macOS account. A dedicated account was considered and rejected: it breaks the cluster's mld/PM2 conventions and Claude Code's Keychain login. Isolation is by process separation, the 0600 socket and path confinement of the file API — not by OS user. |
Accepted deviation |
| 2 | The terminal is a full shell of that account, by design. Path confinement applies to the file API, the session's starting directory and the task working directory; it does not sandbox what a shell, Claude Code or a task does afterwards. Anyone who passes authentication has the account. | Inherent to the product |
| 3 | A process running as the same account can read .env, the runtime token and the tmux socket, and therefore control the runtime without going through the server. |
Follows from 1 |
| 4 | The node's SSH key gives a full shell on the compute nodes listed in CR_EXEC_HOSTS (M3U96a, M3U96b). A compromised session can reach them. |
Accepted; keep the list minimal |
| 5 | Prompt injection: content read by Claude Code can steer it. Mitigation is Claude Code's own permission prompts, which Claude Remote never bypasses; the laptop's auto-approve hook was deliberately not migrated. | Residual |
| 6 | The gateway terminates TLS and forwards plain HTTP through WireGuard; a compromise of the gateway exposes sessions and credentials in transit. | Residual (cluster design) |
| 7 | Port 8620 is reachable from the LAN over plain HTTP. Authentication is still required; login POSTs sent from that origin are refused (Origin ≠ CR_PUBLIC_ORIGIN) and the Secure cookie would not be accepted over HTTP, so a login cannot complete on that path — but credentials typed there would still travel in clear. |
Residual; do not use the LAN URL |
| 8 | Path checks and use are not atomic (TOCTOU), as stated in paths.ts. |
Accepted (single administrator, own workspace) |
| 9 | style-src 'unsafe-inline' is allowed (scripts are not). |
Residual |
| 10 | Backups are encrypted with AES-256-CBC + PBKDF2 (confidentiality, no authentication tag) and stored on the same host as the passphrase file. | Residual; copy both elsewhere, separately |
| 11 | PM2 keeps each process's environment (including the secrets) in its own state under ~/.pm2. Standard PM2 behaviour; file permissions on the node were not audited for it. |
Not verified |
| 12 | TOTP uses HMAC-SHA1 (the interoperable default). | Accepted |
| 13 | No account recovery flow. Losing every second factor requires a manual database intervention (see backup-recovery.md). | By design |
| 14 | Application-level rate limiting only; no IP allowlist, WAF or gateway-level basic auth in front. | Possible later without code changes |
| 15 | Security finding fixed before deployment: the first conversation-id pattern would have accepted a string starting with -- as a --resume value. Fixed in both server and runtime; covered by the tests listed above. |
Closed |
Limitations / not verified
- No external penetration test or dependency audit was performed.
- Passkey flows were verified with a software authenticator, not a real browser or device.
- The 4429 backpressure close was unit-tested, not observed under real saturation.
- macOS application firewall state on M5M36 is not managed by this project.