This document describes Codeman's security model: how it decides who may reach the web UI, how requests are authenticated, how the file-serving and tmux layers are hardened, and the recommended ways to expose an instance safely.
Codeman spawns and drives Claude/OpenCode CLIs with
--dangerously-skip-permissions. Anyone who can reach an unauthenticated
instance can run arbitrary commands as your user. The defaults below are chosen
so that a fresh install is safe on the machine it runs on, while remote access is
an explicit, guided opt‑in.
TL;DR — Codeman binds loopback only (
127.0.0.1) by default, so out of the box it is reachable only from the same machine and needs no password. To reach it from elsewhere, either put it behind an authenticated tunnel (tailscale serve/cloudflared) or bind a wider host and setCODEMAN_PASSWORD. If you bind a non‑loopback host with no password, Codeman still starts but prints a loud warning telling you how to secure it.
- Network binding model
- Authentication
- Request‑origin trust & the tunnel caveat
- Recommended remote‑access setups
- File‑serving hardening
- tmux launch hardening
- Supply‑chain & build‑asset hardening
- Multi‑instance isolation
- Transport security headers
- Docker container isolation
- Quick reference
The security boundary is the network bind plus authentication — not the code Codeman
runs. Because sessions launch with --dangerously-skip-permissions, the web UI is by
design a remote‑code‑execution surface for whoever is allowed to reach it. Everything
below exists to control who that is.
| Actor | Reaches the UI when… | Is granted |
|---|---|---|
| Same‑machine user | Always (default loopback bind) | Full session control — the intended local‑use case. |
| Authenticated remote client | Tunnel/LAN reachability and a valid password or session cookie | Full session control. |
| Unauthenticated remote client | Only if you bind a non‑loopback host with no password | Full session control — the exact case every default and warning works to prevent. |
| Clients behind a loopback‑connecting tunnel | A reverse tunnel terminates on 127.0.0.1 |
Inherit req.ip = 127.0.0.1, so they hit the localhost‑only exemptions (§3) unless a password is set. |
Explicitly out of scope. Codeman is access control for the operator console, not a
sandbox for the code that console runs. It does not defend against: a compromised
local user account (loopback is trusted), malicious contents in a workspace you
deliberately open, or the breadth of filesystem a session's workingDir is pointed at
(§5).
| Setting | Default | Source |
|---|---|---|
| Bind host | 127.0.0.1 (loopback) |
--host / CODEMAN_HOST → WebServer ctor |
| Port | 3000 |
--port / CODEMAN_PORT |
| TLS | off (--https to enable) |
--https |
isLoopbackBindHost() (src/web/network-auth-policy.ts) decides whether a bind
host is loopback-only. It returns true for:
localhost- any IPv4 in
127.0.0.0/8(e.g.127.0.0.1,127.42.0.9) - IPv6 loopback
::1(bracketed[::1]and the long form0:0:0:0:0:0:0:1) - IPv4‑mapped loopback
::ffff:127.*
It returns false for 0.0.0.0, :: (all interfaces), LAN IPs, and hostnames.
The classification is fail‑safe in the dangerous direction: any host that is
not provably loopback is treated as non‑loopback (it never mistakes 0.0.0.0
for loopback). Shorthand forms like 127.1 or integer/octal IPs classify as
non‑loopback (you'll get a warning, not a silent wide‑open bind) — use
127.0.0.1 for an unambiguous loopback bind.
At WebServer.start():
| Bind host | CODEMAN_PASSWORD |
Behavior |
|---|---|---|
| loopback (default) | unset | Start. Safe — reachable only from this machine. |
| loopback | set | Start. Auth required even locally. |
| non‑loopback | set | Start. Auth protects the open bind. |
| non‑loopback | unset | Start + LOUD warning listing how to secure it. |
| non‑loopback | unset, --allow-unauthenticated-network |
Start + terse acknowledged note. |
History: an earlier iteration (unreleased COD‑29) refused to start on a non‑loopback bind without a password. That surprised setups that "just worked" before, so 0.9.0 changed it to start‑and‑warn. Loopback is still the safe default; the warning (with three concrete fixes) replaces the hard failure.
The warning points at three ways to secure the instance:
CODEMAN_PASSWORD=<password>— turns on HTTP Basic auth (see §2).--host 127.0.0.1+ an authenticated tunnel (cloudflared/tailscale serve).--allow-unauthenticated-network/CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1— explicitly accept the risk (downgrades the warning to a one‑line note). This flag is only an acknowledgement; it does not change reachability.
CODEMAN_API_URL (used by hooks/child processes) is always derived as a loopback
address (0.0.0.0/localhost/::1 → 127.0.0.1) so in‑process hooks reach the
server over loopback regardless of the public bind.
Auth is optional and controlled by env vars captured at startup:
CODEMAN_USERNAME(defaultadminwhen only a password is set)CODEMAN_PASSWORD
When CODEMAN_PASSWORD is unset, no auth is enforced — which is why the default
loopback bind matters. The auth pipeline (src/web/middleware/auth.ts,
onRequest hook) runs in this order:
- Localhost‑only exemptions (always first):
POST /api/hook-eventand the QR/q/short‑code path are exempt whenreq.ipis loopback (see §3). While the managed tunnel is running, the hook‑event exemption additionally requires the per‑instanceX-Codeman-Hook-Secretheader (COD‑54); failed presentations are rate‑limited in a dedicated bucket (separate from Basic‑Auth failures) so misfiring hooks can never lock out the login path. - Session cookie check — a valid
codeman_sessioncookie short‑circuits to allow. - HTTP Basic check — correct credentials short‑circuit to allow and clear that IP's failure counter.
- Rate‑limit gate — if neither cookie nor credentials passed and the IP is
locked out, return
429with aRetry-Afterheader. - Otherwise return
401, incrementing the IP's failure counter.
On successful Basic auth the server issues codeman_session, an opaque
server‑side token (randomBytes(32)), valid 24h with auto‑extend and device
context for the audit log. Tokens are not client‑signed — they're validated
by presence in a server‑side map, so they cannot be forged offline.
Failed auth is tracked per IP: 10 failures → 429, with a 15‑minute decay.
The QR path has its own separate limiter.
The lockout check sits after the cookie/credential checks (step 4, not first).
This is deliberate: a user with a valid cookie or correct password recovers
immediately even while an attacker is hammering the same IP — important because
all traffic through a tunnel shares one source IP (loopback). Wrong credentials
are still counted and still hit the 429 at the threshold, so brute‑force
protection is unchanged.
req.ip is derived from the TCP socket only — Fastify runs with
trustProxy: false, so X-Forwarded-For / X-Real-IP / Forwarded are
ignored. A remote client cannot forge req.ip to 127.0.0.1.
However, a reverse tunnel that connects to the server over loopback (e.g.
cloudflared --url http://localhost:3000) makes every tunneled request arrive
with req.ip = 127.0.0.1. The localhost‑only exemptions then treat those
requests as local:
POST /api/hook-event— auth‑exempt for loopback only while no managed tunnel is running. When Codeman's own tunnel is up, the exemption requires the per‑instance shared secret (X-Codeman-Hook-Secret, 256‑bit hex in~/.codeman/hook-secret, mode 0600, COD‑54). Local hook commands read the secret file at execution time ($CODEMAN_HOOK_SECRET_FILE, exported into every managed session), so they keep working — tunneled internet traffic can't know it. Even without the secret the impact is bounded: the route isHookEventSchema‑validated and requires a valid in‑memorysessionId; it can drive respawn signals, SSE broadcasts, push notifications, and transcript watching — not arbitrary terminal input or file reads.⚠️ The gate keys off the managed tunnel — an externally run loopback proxy (your owncloudflared,tailscale serve) is invisible to it, so the plain loopback exemption still applies there (prefertailscale serve, which authenticates at the tailnet layer). Hook configs regenerated since COD‑54 always present the header, so a future release can require the secret unconditionally.- QR
/q/— still protected by its own short‑code brute‑force limiter (10 failures / 60s against a 62⁶ space).
Mitigation: set CODEMAN_PASSWORD whenever a loopback‑connecting tunnel is
up — it gates everything except the (secret‑gated) hook exemption and is the
documented practice; since COD‑55 enabling the managed tunnel refuses to start
without it unless CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 explicitly
acknowledges the exposure. Prefer tailscale serve (below), which authenticates
at the tailnet layer so untrusted clients never reach the loopback port at all.
Since 0.9.5 an always‑on onRequest hook (registerHostGuard,
src/web/middleware/auth.ts; policy in src/web/network-auth-policy.ts) runs
before the auth pipeline in §2 and guards every request — including the
localhost‑only exemptions above, SSE, the WebSocket upgrade, and static files. It
closes the browser‑driven RCE path (DNS rebinding plus a cross‑site text/plain
POST) that the loopback‑no‑password default otherwise exposed to any site the
operator merely visits.
- Host allowlist (anti‑DNS‑rebinding). The
Hostheader is validated on every request, all methods. A custom domain rebound to127.0.0.1is rejected with403 Forbidden: host not allowedbefore any handler runs. Allowed:localhost; any IP literal (IPv4/IPv6 — a browser hitting a numeric address can't be a rebinding victim); the bind host; the suffixes.ts.net,.trycloudflare.com,.cfargotunnel.com; the hostname of the active Codeman‑managed tunnel; and anything inCODEMAN_ALLOWED_HOSTS. A missing/emptyHostis rejected. - Origin / CSRF guard. On state‑changing methods (everything except
GET/HEAD/OPTIONS) theOriginheader must also pass the same allowlist, else403 Forbidden: cross‑site request blocked. A missingOriginis allowed (socurl, the CLI, and Claude Code hooks keep working); only a present‑but‑foreign origin — or the opaquenullorigin (sandboxed iframe) — is rejected. This blocks the cross‑site CSRF that could previously create sessions, trigger self‑update, or fliptunnelEnabled. - Raw
text/plainbodies. The globaltext/plaincontent‑type parser no longer JSON‑parses bodies — it hands handlers the raw string (/api/crash-diagself‑parses its beacon payload). This removes the CORS "simple request" CSRF vector, where a cross‑sitefetchwithContent-Type: text/plainsmuggled a JSON body into a write route with no preflight — defense‑in‑depth alongside the Origin guard. - WebSocket upgrades. The terminal WS upgrade (
src/web/routes/ws-routes.ts) runs the same Host + Origin check and closes with code4003on failure (anti‑CSWSH).
The policy is rebuilt per request from
buildHostPolicy(bindHost, tunnelManager.getUrl()), so starting or stopping a
tunnel at runtime updates the allowlist with no restart.
Reverse‑proxy operators: a custom proxy domain (e.g.
codeman.example.com) is not in the default allowlist and gets403 host not allowed. Add it viaCODEMAN_ALLOWED_HOSTS— comma‑separated, case‑insensitive; an exact hostname matches only itself, while a leading‑dot entry (.corp.internal) matches the bare domain and all subdomains. Behaviour is covered bytest/network-host-guard.test.ts.
Ordered most‑to‑least recommended:
Bind loopback, let Tailscale front it on your tailnet with a real cert. The installer sets this up for you: choose Tailscale at the network-access prompt, or retrofit an existing install with:
bash ~/.codeman/app/install.sh tailscaleThe guided flow installs Tailscale if needed, walks through login and the tailnet HTTPS-certificates toggle, and configures the equivalent of:
codeman web # binds 127.0.0.1:3000 (plain HTTP is fine here)
tailscale serve --bg 3000 # HTTPS at https://<node>.<tailnet>.ts.netOnly devices on your tailnet can reach it; Tailscale handles identity and
terminates TLS with a real Let's Encrypt certificate (so PWA install and web
push work). No app password and no 0.0.0.0 bind required. (This is the
maintainer's production setup.) CODEMAN_TAILSCALE=1 presets the choice for
automation; the installer never runs tailscale serve reset and never touches
serve mappings other than 443 -> Codeman's port.
export CODEMAN_PASSWORD=<password>
codeman web --https
cloudflared tunnel --url https://localhost:3000Always set CODEMAN_PASSWORD here — the tunnel connects over loopback, so the
hook‑event exemption (§3) would otherwise be reachable from the public URL.
export CODEMAN_PASSWORD=<password>
codeman web --https --host 0.0.0.0Exposes the port on all interfaces; the password is the only thing protecting it.
--host 0.0.0.0 without a password. Codeman will start (and warn), but
anyone on the network can control your Claude sessions. Never re‑expose 0.0.0.0
without a password.
Three routes serve workspace files; all require a valid sessionId and run the
shared path validator validateSessionFilePath() (src/web/route-helpers.ts):
it realpaths the target before the boundary check and rejects anything that
escapes the session working directory (.., absolute paths, and symlinks that
resolve outside). The realpath‑before‑check ordering closes the validation‑time
TOCTOU window.
| Route | Cap | Notes |
|---|---|---|
file-content |
10 MB | text preview |
file-raw |
2 GB (CODEMAN_MAX_DOWNLOAD_BYTES, 0 = unlimited) |
inline MIME map; X-Content-Type-Options: nosniff on all responses; streamed, Range-aware (206 slices come from the same validated path, and the cap is checked before the range) |
GET /api/download |
same cap | forced attachment; sensitive‑path blocklist; streamed, Range-aware |
A workspace .svg served inline as image/svg+xml is a stored‑XSS vector (SVG
can carry <script>, same‑origin = full session control). file-raw therefore
serves .svg as application/octet-stream + Content-Disposition: attachment +
nosniff. The control here is the octet-stream + attachment + nosniff
combination, which forces a download instead of a render — not the CSP: the
policy's script-src allows 'unsafe-inline' (§9), so a same‑origin HTML
document would be able to run inline scripts if the browser ever rendered it.
By the same combination, other text types (.html, .xml, …) that fall through
to octet-stream are downloaded, not executed. Trusted QR/welcome SVGs are
injected from API JSON (innerHTML), not via file-raw, so they are unaffected.
/api/download additionally refuses a blocklist of sensitive paths
(/etc/shadow, ~/.ssh/, .env, *credentials*, .aws/credentials, …). This
is defense‑in‑depth, not the primary boundary — the realpath containment is
the control. The blocklist patterns are shared (src/web/sensitive-path.ts) with
the attachment guard below.
Live external attachments (src/attachment-registry.ts) mint an att_<uuid> id
for a host file so browser requests carry the id, never an absolute path. Serving
is by id (GET /api/sessions/:id/attachments/:attachmentId/raw, same download cap,
nosniff) and re‑resolves the symlink + re‑checks the attachment guard
(src/config/attachment-guard.ts: the shared sensitive‑path blocklist plus
the /root and /etc trees, extendable via attachmentBlockedPaths /
CODEMAN_ATTACHMENT_BLOCKED_PATHS) on every request. Unlike the workspace file
routes, attachments are intentionally cross‑workspace — so the effective gate
is the blocklist + a 6‑extension allowlist (png/pdf/docx/pptx/md/txt), not
realpath containment.
Two registration paths, with different trust:
- Explicit
POST /api/sessions/:id/attachments(andcodeman attach, which POSTs directly inside a managed session) — a deliberate, Origin‑guarded HTTP request. Allowed cross‑workspace (subject to the guard). This is the supported path for codeman‑publish and the~/.codemanreview‑card loop. - Terminal
codeman://attach?path=…magic links — scanned passively from session output. Terminal output is attacker‑influenceable (a prompt‑injected session can print an arbitrary path), and registration here is server‑side with no Origin gate and broadcasts therawUrlover SSE to all clients. This path is therefore force‑confined to the session workspace (forceWorkspaceConfinementinregisterExternalAttachment, wired inWebServer.registerAttachment), regardless of the global confine setting — a passive magic link cannot expose a file outside the session's own workspace. Cross‑workspace attach must go through the explicit POST path above.
The live file‑tail SSE route (FileStreamManager, used to stream a growing log
into the UI) does not use validateSessionFilePath; it has its own validator
with a deliberately wider allowlist: the session workingDir plus two
read‑only log roots — /var/log and ~/logs — so operators can tail
system/app logs. /tmp is intentionally excluded (world‑writable). Like the
other routes it realpaths the target and re‑checks right before spawning tail
(TOCTOU guard), and it is read‑only. This is the one place the per‑session
boundary is intentionally relaxed; on a password‑protected remote deployment an
authenticated user can therefore read /var/log and ~/logs outside their
session dir. (Security review M5: this divergence is by design and is now
documented here rather than silently diverging from the per‑session claim above.)
The file‑route boundary is the session's workingDir, and POST /api/sessions
currently accepts an arbitrary absolute workingDir (validated as "exists + is a
directory"). A session created with workingDir=/ can therefore read files
across the filesystem within that boundary. This is pre‑existing across all
file routes and not widened by the recent changes. Recommended follow‑up:
constrain workingDir to an allowlist (e.g. under the cases dir / $HOME).
New sessions and respawns launch the tmux server/pane from a stable /tmp
(TMUX_LAUNCH_CWD) and then cd into the real workspace inside the pane,
against the live mount table:
respawn-pane -k -c /tmp -t <session> bash -c "cd <workingDir> && <cmd>"
This avoids a class of failures on FUSE/rclone‑mounted workspaces where a
transient mount blip at launch poisons tmux's long‑lived cwd and crashes
new-session. Safety properties:
- Fail‑safe cwd: the command is
cd "<dir>" && <cmd>— ifcdfails the CLI does not run in/tmp; the pane dies with a visible error instead. - No injection:
workingDirpassesisValidWorkingDir(absolute, rejects;&|$\(){}<>'"and newlines and..) andisValidPath`, and is double‑quoted in the pane command. Paths with spaces work; metacharacters are rejected before reaching the shell. - It does not change which tmux socket is targeted, so instance isolation (§8) is preserved.
- Dependency advisories: security‑sensitive ranges are bumped to patched
versions, and
overridesforce patched transitive deps (picomatch,basic-ftp,fast-uri,flatted).test/dependency-security.test.tsasserts these stay patched in the lockfile. - Lockfile integrity:
npm run check:lockfile(CI on every push/PR) fails on drift betweenpackage.jsonandpackage-lock.json. All lockfile entries resolve toregistry.npmjs.orgwithsha512integrity hashes. - Public‑asset checker:
npm run check:public-assets(scripts/check-public-assets.mjs) scanssrc/web/public/**for literal NUL bytes and runsnode --checkon every.jsfile (syntax validation), plus a Prettier pass on maintained files. It usesexecFileSyncwith argv arrays (no shell), so filenames/content cannot inject commands;node --checkonly parses, never executes. Large hand‑formatted/generated assets (app.js, the gesture bundle, vendored libs) are.prettierignored for the style pass, but the NUL + syntax checks still cover them.
The tmux socket (tmux -L codeman[-<instance>]) and data dir
(~/.codeman[-<instance>]) are process‑wide and shared by every Codeman on the
machine, derived from CODEMAN_INSTANCE (src/config/instance.ts). A second
instance on the same socket discovers and attaches PTYs to the first
instance's live sessions. To run instances side by side, give each a distinct
CODEMAN_INSTANCE (scopes both dir + socket), or set CODEMAN_TMUX_SOCKET +
CODEMAN_DATA_DIR individually. CODEMAN_INSTANCE defaults to empty = the
production layout (~/.codeman, -L codeman, port 3000).
registerSecurityHeaders (src/web/middleware/auth.ts) applies on every response:
Content-Security-Policy— baselinedefault-src 'self', with these deliberate widenings (so the policy is tighter than "self only" but every exception is enumerated and same‑origin‑first):script-src/style-src/font-srcalso allowhttps://cdn.jsdelivr.net(CDN fallback for a few libraries).script-srcandstyle-srcadditionally allow'unsafe-inline'— relevant to the SVG/HTML handling in §5, where theoctet-stream+nosniffdownload (not the CSP) is what blocks execution. Because'unsafe-inline'is still present (removing it needs a nonce migration), AI‑derived strings rendered into the subagent/activity panels are HTML‑escaped at the injection sites (escapeHtmlinsrc/web/public/constants.js; sinks inpanels-ui.js/subagent-windows.js) so a hostile tool name or argument can't execute — defense‑in‑depth from the 2026‑06‑09 review (H4).connect-srcallowswss://api.deepgram.com(streaming voice input).img-srcallowsdata:andblob:(inline / generated images, QR codes).frame-ancestors 'self'.- Gesture opt‑in (
CODEMAN_GESTURE=1):script-srcgains'wasm-unsafe-eval'and aworker-src 'self' blob:directive is added, for self‑hosted MediaPipe. Its wasm runtime + model are same‑origin under/gesture/, so no extraconnect-srcentry is needed. OFF by default, so the production CSP is byte‑for‑byte unchanged.
X-Content-Type-Options: nosniff— blocks MIME sniffing (pairs with §5).X-Frame-Options: SAMEORIGIN— clickjacking defense (mirrorsframe-ancestors 'self').Strict-Transport-Security: max-age=31536000; includeSubDomains— only when served over HTTPS (--https).- CORS —
Access-Control-Allow-Originis reflected only for origins whose hostname islocalhost/127.0.0.1/::1; any other origin gets no CORS headers.OPTIONSpreflights are answered204.
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
- Hardened create flags, always —
--cap-drop ALL,--security-opt no-new-privileges,--pids-limit(fork‑bomb guard),--memory==--memory-swap(a real OOM cap),--init, and non‑root:--user <hostUid>:0on Linux (host uid → workspace files stay host‑owned; GID 0 keeps$HOMEwritable),--userns=keep-idon rootless Podman. Never--privileged, and never the docker socket — the pure builder indocker-hosts.tscannot emit them and the schema cannot represent them. - Credentials never enter an image — the convenient default bind‑mounts host cred dirs (
~/.claude,~/.codex,~/.gemini— which also carries Antigravity'santigravity-cli/state —~/.config/{gcloud,opencode}, five seeded files from~/.pi/agent, and three from~/.grok) read‑write. Bind mounts are physically excluded fromdocker commit, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY--env OPENAI_API_KEY(no=value, nopsleak, never committed); a create‑time-efor a secret is never used. The sealed profile (mountCredentials:false+network:none) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into. - Blast radius — accept it explicitly — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host
--dangerously-skip-permissionsexecution; use the sealed profile for genuinely untrusted work. - Import is untrusted‑bundle‑safe —
/api/docker-cases/importvalidates the manifest + per‑member SHA‑256 before extraction, rejects absolute /..tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwritecodeman/agent:baseor a pre‑existing tag. - Host guard & the bridge‑hooks listener — in‑container hook callbacks carry
Host: host.docker.internal/host.containers.internal; both are on the always‑on host‑header allowlist (DOCKER_HOST_GATEWAY_ALIASES) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in viaCODEMAN_DOCKER_BRIDGE_HOOKS=1, which binds a SECOND listener on the docker bridge gateway serving only the hook endpoints (every other path →403) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path. - Instance isolation — every managed container is labeled
codeman.instance=<CODEMAN_INSTANCE>; the boot reaper reaps orphans of its OWN instance only, so a beta never removes a prod container. The in‑container tmux socket (-L codeman-docker) + session name (codeman-dkr-*) deliberately fail a nested Codeman's discovery pattern.
Full feature guide: docker-cases.md.
codeman web --multiuser (or CODEMAN_MULTIUSER=1) turns on named users with individually scrypt‑hashed passwords in ~/.codeman/users.json (mode 0600). OFF by default; when off, nothing here applies and behavior is byte‑identical to single‑user. Design + phase status: multi-user-plan.md.
- It is workspace separation, NOT a security boundary between users. Every session still runs as the SAME OS account with agent code that can read the whole host. Any user can ask their agent to
catanother user's files; the WEB layer enforces scoping, the AGENT layer cannot. Mitigations: give non‑admins the defaultautopermission mode (classifier‑guarded), pair users with Docker cases (container per case) for real isolation, or run separate Codeman instances under separate OS accounts. Stated loudly in the admin panel and the plan's threat model (section 2). - It strictly improves network posture. It removes the single shared
CODEMAN_PASSWORDand gives each person a revocable credential; a non‑loopback bind and the tunnel‑enable guard are satisfied by "multi‑user with ≥1 enabled user" without a shared password. - Auth is a parallel branch (
middleware/auth.ts) that leaves the single‑user path untouched: per‑user scrypt verify (timingSafeEqual, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and amustChangePasswordlockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user). - Ownership is enforced server‑side only and fails closed:
req.authUser(a synthetic admin in single‑user),findSessionOrFailreturns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter bysession.owner, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is non‑adminworkingDirconfinement: a non‑admin's session/one‑shot working dir must realpath‑resolve inside~/codeman-users/<name>/cases, checked BEFORE any disk write. - Privileged actions are a one‑bit grant (
canBypassPermissions, default off): only granted users (and admins) get--dangerously-skip-permissions(others are silently downgraded to--permission-mode auto), shell‑mode sessions, cronlaunchCommand, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only. - Admin actions are audited append‑only to
~/.codeman/admin-audit.jsonl(acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth,logoutonly truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
A saved dashboard URL renders as a tab, served through Codeman's own origin at /webview/<capability>/. User guide: web-tabs.md. Three properties carry the security weight:
- The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate. The iframe is sandboxed without
allow-same-origin, so it is opaque‑origin: its requests are cross‑site, meaning theSameSite=laxsession cookie is never attached and its writes and WS upgrades arrive withOrigin: null. The credential is instead a 192‑bit capability in the path, minted only by an authenticatedPOST /api/webviews/:id/open, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL".⚠️ The Host allowlist is NOT bypassed, so DNS‑rebinding protection is unaffected. A secondReferer‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑/api, non‑/ws, non‑/qpaths. Edges pinned bytest/webview-auth-exemption.test.ts. - Sandboxed by default;
allow-same-originis an explicit per‑dashboard opt‑in. A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API.⚠️ In BOTH modes theAuthorizationheader and thecodeman_sessioncookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would handCODEMAN_PASSWORDto the dashboard. - Not an open relay, and not a privilege boundary.
resolveUpstreamUrl()refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands--dangerously-skip-permissionsagents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately no magic‑link path: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (169.254.0.0/16,fe80::/10,fd00:ec2::254,168.63.129.16,100.100.100.200,metadata.google.internal):webview-egress-policy.tsrefuses them at save time, andwebview-egress.tsre‑judges the RESOLVED address at connect time through alookuphook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carryReferrer-Policy: same-originso a dashboard cannot hand the capability‑bearing URL to a third‑party host it links.
| Env / flag | Effect |
|---|---|
CODEMAN_PASSWORD (+ CODEMAN_USERNAME) |
Enable HTTP Basic auth |
--host / CODEMAN_HOST |
Bind host (default 127.0.0.1) |
CODEMAN_ALLOWED_HOSTS |
Extra Host/Origin allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot .suffix for subdomains) — see §3 |
--base-url / CODEMAN_BASE_URL |
Sub‑path prefix Codeman is mounted under behind a reverse proxy, e.g. /codeman (default /); the proxy must forward the prefix unchanged. Independent of CODEMAN_ALLOWED_HOSTS |
--allow-unauthenticated-network / CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK |
Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
--https |
Enable TLS (adds HSTS) |
CODEMAN_INSTANCE |
Scope tmux socket + data dir for isolation |
CODEMAN_GESTURE=1 |
Make the gesture overlay available (widens CSP) |
CODEMAN_DOCKER_BRIDGE_HOOKS=1 |
Serve the hook endpoints on the docker bridge gateway (host‑internal, hooks‑only, 403 elsewhere) so in‑container hooks reach a loopback‑bound server — see §10 |
CODEMAN_DOCKER_BRIDGE_HOST |
Override the bridge gateway IP the hooks listener binds (default: auto‑detect) |
Audit log: session lifecycle and server start are recorded in
~/.codeman/session-lifecycle.jsonl.
| Concern | File |
|---|---|
Bind‑host classification, env‑flag parsing, Host/Origin allowlist (buildHostPolicy / isAllowedRequestHost / isAllowedRequestOrigin) |
src/web/network-auth-policy.ts |
| Start‑and‑warn policy | src/web/server.ts (WebServer.start()) |
Auth pipeline, rate limiting, security headers, CORS, Host/Origin guard (registerHostGuard) |
src/web/middleware/auth.ts |
| File‑path containment (realpath‑before‑check) | src/web/route-helpers.ts (validateSessionFilePath) |
| File routes, caps, SVG handling, download blocklist | src/web/routes/file-routes.ts |
| Instance/socket/data‑dir scoping | src/config/instance.ts |
Maintenance note: the behaviours above were verified against the source on 2026‑06‑09. When you change auth, the bind policy, CSP/headers, or the file routes, update this document in the same change — several sections quote exact values (caps, CSP directives, TTLs) that drift silently otherwise.