2026-06-08 19:29:47 +02:00
# Security Architecture
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 set
> `CODEMAN_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.
---
2026-06-09 00:52:27 +02:00
## Contents
1. [Network binding model ](#1-network-binding-model )
2. [Authentication ](#2-authentication )
3. [Request‑ origin trust & the tunnel caveat ](#3-requestorigin-trust--the-tunnel-caveat )
4. [Recommended remote‑ access setups ](#4-recommended-remoteaccess-setups )
5. [File‑ serving hardening ](#5-fileserving-hardening )
6. [tmux launch hardening ](#6-tmux-launch-hardening-cod31 )
7. [Supply‑ chain & build‑ asset hardening ](#7-supplychain--buildasset-hardening-cod28 )
8. [Multi‑ instance isolation ](#8-multiinstance-isolation )
9. [Transport security headers ](#9-transport-security-headers )
2026-07-19 22:23:05 +02:00
10. [Docker container isolation ](#10-docker-container-isolation )
11. [Quick reference ](#11-quick-reference )
2026-06-09 00:52:27 +02:00
---
## Trust model
**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).
---
2026-06-08 19:29:47 +02:00
## 1. Network binding model
| 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` |
### Bind host classification
`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 form `0: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.
### Startup policy (the "warn, don't block" rule)
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:
1. `CODEMAN_PASSWORD=<password>` — turns on HTTP Basic auth (see §2).
2. `--host 127.0.0.1` + an authenticated tunnel (`cloudflared` / `tailscale serve` ).
3. `--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.
---
## 2. Authentication
Auth is **optional** and controlled by env vars captured at startup:
- `CODEMAN_USERNAME` (default `admin` when 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:
1. **Localhost‑ only exemptions** (always first): `POST /api/hook-event` and the QR
2026-06-10 22:31:09 +02:00
`/q/` short‑ code path are exempt when `req.ip` is loopback (see §3). While the
**managed tunnel is running** , the hook‑ event exemption additionally requires
the per‑ instance `X-Codeman-Hook-Secret` header (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.
2026-06-08 19:29:47 +02:00
2. **Session cookie** check — a valid `codeman_session` cookie short‑ circuits to
allow.
3. **HTTP Basic** check — correct credentials short‑ circuit to allow and clear
that IP's failure counter.
4. **Rate‑ limit gate** — if neither cookie nor credentials passed and the IP is
locked out, return `429` with a `Retry-After` header.
5. Otherwise return `401` , incrementing the IP's failure counter.
### Session cookies
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.
### Rate limiting / lockout recovery
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.
---
## 3. Request‑ origin trust & the tunnel caveat
`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:
2026-06-10 22:31:09 +02:00
- `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 is
2026-06-08 19:29:47 +02:00
`HookEventSchema` ‑ validated and requires a valid in‑ memory `sessionId` ; it can
drive respawn signals, SSE broadcasts, push notifications, and transcript
2026-06-10 22:31:09 +02:00
watching — **not** arbitrary terminal input or file reads. ⚠️ The gate keys off
the **managed** tunnel — an externally run loopback proxy (your own
`cloudflared` , `tailscale serve` ) is invisible to it, so the plain loopback
exemption still applies there (prefer `tailscale 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.
2026-06-08 19:29:47 +02:00
- 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
2026-06-10 22:31:09 +02:00
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
2026-06-08 19:29:47 +02:00
at the tailnet layer so untrusted clients never reach the loopback port at all.
2026-06-09 04:35:29 +02:00
### Host‑ header & Origin allowlist (DNS‑ rebinding & CSRF defense)
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 `Host` header is validated on
**every** request, all methods. A custom domain rebound to `127.0.0.1` is
rejected with `403 Forbidden: host not allowed` before 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 in `CODEMAN_ALLOWED_HOSTS` . A missing/empty
`Host` is rejected.
- **Origin / CSRF guard.** On **state‑ changing** methods (everything except
`GET` /`HEAD` /`OPTIONS` ) the `Origin` header must also pass the same allowlist,
else `403 Forbidden: cross‑ site request blocked` . A **missing `Origin` is
allowed** (so `curl` , the CLI, and Claude Code hooks keep working); only a
present‑ but‑ foreign origin — or the opaque `null` origin (sandboxed iframe) — is
rejected. This blocks the cross‑ site CSRF that could previously create sessions,
trigger self‑ update, or flip `tunnelEnabled` .
- **Raw `text/plain` bodies.** The global `text/plain` content‑ type parser no
longer JSON‑ parses bodies — it hands handlers the raw string (`/api/crash-diag`
self‑ parses its beacon payload). This removes the CORS "simple request" CSRF
vector, where a cross‑ site `fetch` with `Content-Type: text/plain` smuggled 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 code `4003` on 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 gets `403 host not allowed`. Add it via
> `CODEMAN_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 by
> `test/network-host-guard.test.ts`.
2026-06-08 19:29:47 +02:00
---
## 4. Recommended remote‑ access setups
Ordered most‑ to‑ least recommended:
### A. Tailscale serve (recommended)
Bind loopback, let Tailscale front it on your tailnet with a real cert:
```bash
codeman web --https # binds 127.0.0.1:3000
tailscale serve --bg https / http://127.0.0.1:3000
```
Only devices on your tailnet can reach it; Tailscale handles identity. No app
password and no `0.0.0.0` bind required. (This is the maintainer's production
setup.)
### B. Authenticated cloudflared tunnel + password
```bash
export CODEMAN_PASSWORD = <password>
codeman web --https
cloudflared tunnel --url https://localhost:3000
```
Always set `CODEMAN_PASSWORD` here — the tunnel connects over loopback, so the
hook‑ event exemption (§3) would otherwise be reachable from the public URL.
### C. Direct LAN bind + password
```bash
export CODEMAN_PASSWORD = <password>
codeman web --https --host 0.0.0.0
```
Exposes the port on all interfaces; the password is the only thing protecting it.
### Avoid
`--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.
---
## 5. File‑ serving hardening
Three routes serve workspace files; all require a valid `sessionId` and run the
shared path validator `validateSessionFilePath()` (`src/web/route-helpers.ts` ):
it `realpath` s 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` | 50 MB | inline MIME map; ** `X-Content-Type-Options: nosniff` on all responses** |
| `POST /api/download` | 50 MB | forced `attachment` ; sensitive‑ path blocklist |
### SVG / content‑ type XSS
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` +
2026-06-09 00:52:27 +02:00
`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.
2026-06-08 19:29:47 +02:00
### Download sensitive‑ path blocklist
`/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
2026-06-11 10:27:05 +02:00
the control. The blocklist patterns are shared (`src/web/sensitive-path.ts` ) with
the attachment guard below.
### External attachments (registry) & the magic‑ link trust boundary
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` , 50 MB 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` ** (and `codeman 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 `~/.codeman` review‑ 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 the `rawUrl` over SSE to all clients. This path is
therefore **force‑ confined to the session workspace** (`forceWorkspaceConfinement`
in `registerExternalAttachment` , wired in `WebServer.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.
2026-06-08 19:29:47 +02:00
2026-06-09 20:02:17 +02:00
### SSE log‑ tail route — intentional extra read roots
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 `realpath` s 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.)
2026-06-08 19:29:47 +02:00
### Known limitation — `workingDir` scope
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` ).
---
## 6. tmux launch hardening (COD‑ 31)
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>` — if `cd` fails the CLI
does **not** run in `/tmp` ; the pane dies with a visible error instead.
- **No injection:** `workingDir` passes `isValidWorkingDir` (absolute, rejects
`;&|$\` (){}<>'"` and newlines and ` ..`) and ` isValidPath`, 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.
---
## 7. Supply‑ chain & build‑ asset hardening (COD‑ 28)
- **Dependency advisories:** security‑ sensitive ranges are bumped to patched
versions, and ` overrides` force patched transitive deps (` picomatch`,
` basic-ftp`, ` fast-uri`, ` flatted`). ` test/dependency-security.test.ts` asserts
these stay patched in the lockfile.
- **Lockfile integrity:** ` npm run check:lockfile` (CI on every push/PR) fails on
drift between ` package.json` and ` package-lock.json`. All lockfile entries
resolve to ` registry.npmjs.org` with ` sha512` integrity hashes.
- **Public‑ asset checker:** ` npm run check:public-assets`
(` scripts/check-public-assets.mjs`) scans ` src/web/public/**` for literal NUL
bytes and runs ` node --check` on every ` .js` file (syntax validation), plus a
Prettier pass on maintained files. It uses ` execFileSync` with argv arrays (no
shell), so filenames/content cannot inject commands; ` node --check` only parses,
never executes. Large hand‑ formatted/generated assets (` app.js`, the gesture
bundle, vendored libs) are ` .prettierignore`d for the style pass, but the NUL +
syntax checks still cover them.
---
## 8. Multi‑ instance isolation
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).
---
## 9. Transport security headers
2026-06-09 00:52:27 +02:00
` registerSecurityHeaders` (` src/web/middleware/auth.ts`) applies on every response:
2026-06-08 19:29:47 +02:00
2026-06-09 00:52:27 +02:00
- **` Content-Security-Policy`** — baseline ` default-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-src` also allow ` https://cdn.jsdelivr.net`
(CDN fallback for a few libraries). ` script-src` and ` style-src` additionally
allow ` 'unsafe-inline'` — relevant to the SVG/HTML handling in §5, where the
` octet-stream` + ` nosniff` download (not the CSP) is what blocks execution.
2026-06-09 04:35:29 +02:00
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 (` escapeHtml` in
` src/web/public/constants.js`; sinks in ` panels-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).
2026-06-09 00:52:27 +02:00
- ` connect-src` allows ` wss://api.deepgram.com` (streaming voice input).
- ` img-src` allows ` data:` and ` blob:` (inline / generated images, QR codes).
- ` frame-ancestors 'self'`.
- **Gesture opt‑ in (` CODEMAN_GESTURE=1`):** ` script-src` gains
` 'wasm-unsafe-eval'` and a ` worker-src 'self' blob:` directive is added, for
self‑ hosted MediaPipe. Its wasm runtime + model are same‑ origin under
` /gesture/`, so no extra ` connect-src` entry 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 (mirrors
` frame-ancestors 'self'`).
- **` Strict-Transport-Security: max-age=31536000; includeSubDomains`** — only when
served over HTTPS (` --https`).
- **CORS** — ` Access-Control-Allow-Origin` is reflected **only** for origins whose
hostname is ` localhost` / ` 127.0.0.1` / ` ::1`; any other origin gets no CORS
headers. ` OPTIONS` preflights are answered ` 204`.
2026-06-08 19:29:47 +02:00
---
2026-07-19 22:23:05 +02:00
## 10. Docker container isolation
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>:0` on Linux (host uid → workspace files stay host‑ owned; GID 0 keeps ` $HOME` writable), ` --userns=keep-id` on rootless Podman. **Never** ` --privileged`, and **never** the docker socket — the pure builder in ` docker-hosts.ts` cannot emit them and the schema cannot represent them.
- **Credentials never enter an image** — the convenient default bind‑ mounts host cred dirs (` ~/.claude`, ` ~/.codex`, ` ~/.gemini`, ` ~/.config/{gcloud,opencode}`) read‑ write. Bind mounts are physically excluded from ` docker 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`, no ` ps` leak, never committed); a create‑ time ` -e` for 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-permissions` execution; use the sealed profile for genuinely untrusted work.
- **Import is untrusted‑ bundle‑ safe** — ` /api/docker-cases/import` validates 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 overwrite ` codeman/agent:base` or 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 via ` CODEMAN_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`](docker-cases.md).
---
## 11. Quick reference
2026-06-08 19:29:47 +02:00
| Env / flag | Effect |
|------------|--------|
| ` CODEMAN_PASSWORD` (+ ` CODEMAN_USERNAME`) | Enable HTTP Basic auth |
| ` --host` / ` CODEMAN_HOST` | Bind host (default ` 127.0.0.1`) |
2026-06-09 04:35:29 +02:00
| ` CODEMAN_ALLOWED_HOSTS` | Extra ` Host`/` Origin` allowlist entries for reverse proxies (comma‑ separated; exact host, or leading‑ dot ` .suffix` for subdomains) — see §3 |
2026-06-08 19:29:47 +02:00
| ` --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) |
2026-07-19 22:23:05 +02:00
| ` 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) |
2026-06-08 19:29:47 +02:00
**Audit log:** session lifecycle and server start are recorded in
` ~/.codeman/session-lifecycle.jsonl`.
2026-06-09 00:52:27 +02:00
### Key source files
| Concern | File |
|---------|------|
2026-06-09 04:35:29 +02:00
| Bind‑ host classification, env‑ flag parsing, Host/Origin allowlist (` buildHostPolicy` / ` isAllowedRequestHost` / ` isAllowedRequestOrigin`) | ` src/web/network-auth-policy.ts` |
2026-06-09 00:52:27 +02:00
| Start‑ and‑ warn policy | ` src/web/server.ts` (` WebServer.start()`) |
2026-06-09 04:35:29 +02:00
| Auth pipeline, rate limiting, security headers, CORS, Host/Origin guard (` registerHostGuard`) | ` src/web/middleware/auth.ts` |
2026-06-09 00:52:27 +02:00
| 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.