mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: release 0.9.0 — security hardening + warn-don't-block network policy
Release 0.9.0 covering the merged security/reliability PRs (#106 deps/ supply-chain, #107 auth/network, #108 test stability, #110 tmux cwd) plus: - Network policy: a non-loopback bind without CODEMAN_PASSWORD now STARTS with a loud warning (3 ways to secure) instead of refusing to start. Loopback stays the safe default. --allow-unauthenticated-network just acknowledges (terser note). (src/web/server.ts start()) - Post-install security note explaining the loopback default + safe exposure. - New docs/security-architecture.md documenting the full model (binding, auth pipeline, tunnel req.ip caveat, file-serving, supply-chain, isolation, recommended setups). CLAUDE.md Security section + gotcha updated. - Updated auth-security test: asserts warn-and-start (not throw). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,36 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 0.9.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Security hardening release: network-bind policy, auth lockout recovery, download/SVG hardening, dependency & supply-chain fixes, tmux launch reliability, and a full security-architecture doc.
|
||||||
|
|
||||||
|
**Network binding (COD-29, #107):**
|
||||||
|
- The web server now defaults to binding `127.0.0.1` (loopback) instead of `0.0.0.0`, so a fresh install is reachable only from the same machine and needs no password. New `--host` / `-H` / `CODEMAN_HOST` flag to choose the bind host.
|
||||||
|
- Binding a non-loopback host **without** `CODEMAN_PASSWORD` no longer refuses to start — it **starts and prints a loud warning** with the three ways to secure it (set `CODEMAN_PASSWORD`, bind loopback + an authenticated tunnel / `tailscale serve`, or acknowledge with `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`). This keeps Codeman "just working" for new users while making remote exposure a guided, explicit choice. Host classification lives in the new `src/web/network-auth-policy.ts` (handles `127.0.0.0/8`, `::1`, `::ffff:127.*`, bracketed IPv6).
|
||||||
|
- A post-install security note now explains the loopback default and how to expose safely.
|
||||||
|
|
||||||
|
**Authentication (COD-29, #107):**
|
||||||
|
- Auth lockout now recovers gracefully: the per-IP rate-limit (`429`) check runs **after** the cookie/credential checks, so a valid session cookie or correct password is never locked out by a prior attacker's failures from the same IP (important behind a shared-IP tunnel). Wrong credentials are still counted and still hit the limit, and a `Retry-After` header is returned.
|
||||||
|
|
||||||
|
**Downloads & content-type hardening (COD-29, #107):**
|
||||||
|
- New session-scoped `POST /api/download` route: realpath-bounded to the session working dir, a sensitive-path blocklist (`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, …), `isFile()` + 50 MB cap, forced `attachment`.
|
||||||
|
- Workspace `.svg` files are served as `application/octet-stream` + `attachment` + `nosniff` (closes a stored-XSS-via-SVG vector); `nosniff` now applies to all `file-raw` responses.
|
||||||
|
|
||||||
|
**Dependencies & supply chain (COD-28, #106):**
|
||||||
|
- Bumped security-sensitive deps to patched versions (`@fastify/static` 9, `fastify` 5.8, `uuid` 14, `vitest` 4.1, …) and added `overrides` for patched transitives (`picomatch`, `basic-ftp`, `fast-uri`, `flatted`); `npm audit` goes from 7 advisories to 0.
|
||||||
|
- New `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, plus a Prettier pass on maintained files. Removed literal NUL placeholders from `app.js`. Added `test/dependency-security.test.ts` and `test/frontend-public-tooling.test.ts`.
|
||||||
|
|
||||||
|
**tmux launch reliability (COD-31, #110):**
|
||||||
|
- New tmux sessions and respawns launch from a stable `/tmp` and `cd` into the workspace inside the pane, avoiding `new-session` crashes when a FUSE/rclone-mounted workspace has a transient mount blip at launch. The `cd "<dir>" && <cmd>` form is fail-safe (the CLI never runs in `/tmp`) and the path is validated + double-quoted.
|
||||||
|
|
||||||
|
**Test stability (COD-30, #108):**
|
||||||
|
- Cleared leaked auth env in the Vitest setup, corrected stale route status-code / SSE-lifecycle expectations to match shipped behavior, updated the mobile keyboard accessory expectations, and measured DOMContentLoaded via browser navigation timing. Also fixed the `WebServer` title tests for the new `host` constructor arg + async `renderIndexHtml`.
|
||||||
|
|
||||||
|
**Docs:**
|
||||||
|
- New `docs/security-architecture.md` documenting the full model (network binding, auth pipeline, the tunnel `req.ip` caveat, file-serving hardening, supply-chain, multi-instance isolation, security headers, and recommended secure setups). CLAUDE.md updated accordingly.
|
||||||
|
|
||||||
## 0.8.2
|
## 0.8.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ When user says "COM":
|
|||||||
|
|
||||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||||
|
|
||||||
**Version**: 0.8.2 (must match `package.json`)
|
**Version**: 0.9.0 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| Dev with TLS | `npx tsx src/index.ts web --https` |
|
| Dev with TLS | `npx tsx src/index.ts web --https` |
|
||||||
| Override window title hostname | `npx tsx src/index.ts web --title-hostname <name>` (default: `os.hostname()` — `codeman:<name>` is used for tab title, title-flash, and OS desktop notification prefix) |
|
| Override window title hostname | `npx tsx src/index.ts web --title-hostname <name>` (default: `os.hostname()` — `codeman:<name>` is used for tab title, title-flash, and OS desktop notification prefix) |
|
||||||
| Bind a non-loopback host | `npx tsx src/index.ts web --host 0.0.0.0` (or `-H`; env `CODEMAN_HOST`; default `127.0.0.1`). **Requires `CODEMAN_PASSWORD`** or it refuses to start — see Common Gotchas |
|
| Bind a non-loopback host | `npx tsx src/index.ts web --host 0.0.0.0` (or `-H`; env `CODEMAN_HOST`; default `127.0.0.1`). Without `CODEMAN_PASSWORD` it **starts but warns loudly** — see Common Gotchas + `docs/security-architecture.md` |
|
||||||
| Continuous typecheck | `tsc --noEmit --watch` |
|
| Continuous typecheck | `tsc --noEmit --watch` |
|
||||||
| Test coverage | `npm run test:coverage` |
|
| Test coverage | `npm run test:coverage` |
|
||||||
| Dead-code sweep | `npm run knip` (config in `knip.json`) |
|
| Dead-code sweep | `npm run knip` (config in `knip.json`) |
|
||||||
@@ -101,7 +101,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
|
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
|
||||||
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
|
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
|
||||||
- **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
|
- **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
|
||||||
- **Default bind is loopback-only — non-loopback fails closed without a password** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). Binding any non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) **throws at startup unless `CODEMAN_PASSWORD` is set** (or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` is given). The thrower is `isLoopbackBindHost()` in `server.ts`; flags are wired in `cli.ts`. ⚠️ Operational trap: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so after this change it became reachable **only on localhost** — remote access (LAN/Tailscale/tunnel) silently breaks until you add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback-bound server is still reachable through a same-host tunnel (cloudflared → `127.0.0.1`), but NOT by a browser hitting the box's LAN/Tailscale IP. Auth user defaults to `admin`.
|
- **Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Full model: `docs/security-architecture.md`.**
|
||||||
- **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
|
- **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
|
||||||
|
|
||||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||||
@@ -175,10 +175,12 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
**Full model: [`docs/security-architecture.md`](docs/security-architecture.md)** — network binding, auth pipeline, the tunnel caveat, file-serving hardening, supply-chain, instance isolation, and recommended secure setups.
|
||||||
|
|
||||||
| Layer | Details |
|
| Layer | Details |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (defaults to `admin`) / `CODEMAN_PASSWORD` env vars. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) |
|
| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (defaults to `admin`) / `CODEMAN_PASSWORD` env vars. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) |
|
||||||
| **Network bind** | Defaults to `127.0.0.1` (loopback). Binding a non-loopback host (`--host`/`CODEMAN_HOST`) **fails closed** without `CODEMAN_PASSWORD` unless `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`. Added by COD-29 (PR #107) — see Common Gotchas |
|
| **Network bind** | Defaults to `127.0.0.1` (loopback). A non-loopback bind (`--host`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **starts but warns loudly** (0.9.0; was fail-closed in COD-29/#107). `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges the warning. Classifier: `network-auth-policy.ts` |
|
||||||
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
|
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
|
||||||
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
||||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
|
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3).
|
||||||
|
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:
|
||||||
|
|
||||||
|
- `POST /api/hook-event` — auth‑exempt for loopback. Bounded impact: it is
|
||||||
|
`HookEventSchema`‑validated and requires a valid in‑memory `sessionId`; it can
|
||||||
|
drive respawn signals, SSE broadcasts, push notifications, and transcript
|
||||||
|
watching — **not** arbitrary terminal input or file reads. It is a
|
||||||
|
session‑disruption / notification‑spoofing surface, not RCE.
|
||||||
|
- 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 does not gate the hook‑event exemption, but it gates everything else and
|
||||||
|
is the documented practice). Prefer `tailscale serve` (below), which authenticates
|
||||||
|
at the tailnet layer so untrusted clients never reach the loopback port at all.
|
||||||
|
A future hardening could gate the hook‑event exemption on a shared secret while a
|
||||||
|
tunnel is active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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` +
|
||||||
|
`nosniff`. With global `nosniff` + CSP `default-src 'self'`, other text types
|
||||||
|
(`.html`, `.xml`, …) that fall through to `octet-stream` are not rendered as HTML
|
||||||
|
either. Trusted QR/welcome SVGs are injected from API JSON (`innerHTML`), not via
|
||||||
|
`file-raw`, so they are unaffected.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
the control.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
`registerSecurityHeaders` applies on every response:
|
||||||
|
|
||||||
|
- `Content-Security-Policy: default-src 'self'` (widened only for `/gesture/`
|
||||||
|
assets when `CODEMAN_GESTURE=1`, to load self‑hosted MediaPipe)
|
||||||
|
- `X-Content-Type-Options: nosniff`
|
||||||
|
- `X-Frame-Options`
|
||||||
|
- `Strict-Transport-Security` when served over HTTPS
|
||||||
|
- CORS restricted to localhost origins
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Quick reference
|
||||||
|
|
||||||
|
| Env / flag | Effect |
|
||||||
|
|------------|--------|
|
||||||
|
| `CODEMAN_PASSWORD` (+ `CODEMAN_USERNAME`) | Enable HTTP Basic auth |
|
||||||
|
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) |
|
||||||
|
| `--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) |
|
||||||
|
|
||||||
|
**Audit log:** session lifecycle and server start are recorded in
|
||||||
|
`~/.codeman/session-lifecycle.jsonl`.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.8.2",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.8.2",
|
"version": "0.9.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.8.2",
|
"version": "0.9.0",
|
||||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -460,3 +460,16 @@ if (process.env.CI || process.env.CODEMAN_NO_AUTOSTART) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Security note — printed on every install path
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
console.log(colors.bold('Security:'));
|
||||||
|
console.log(colors.dim(' Codeman binds ') + colors.cyan('127.0.0.1') + colors.dim(' (this machine only) — no password needed by default.'));
|
||||||
|
console.log(colors.dim(' To reach it from another device, do ONE of:'));
|
||||||
|
console.log(colors.dim(' • ') + colors.cyan('tailscale serve') + colors.dim(' / ') + colors.cyan('cloudflared tunnel') + colors.dim(' (recommended), or'));
|
||||||
|
console.log(colors.dim(' • ') + colors.cyan('codeman web --host 0.0.0.0') + colors.dim(' AND set ') + colors.cyan('CODEMAN_PASSWORD'));
|
||||||
|
console.log(colors.dim(' A non-loopback bind without a password still starts, but warns loudly.'));
|
||||||
|
console.log(colors.dim(' Details: docs/security-architecture.md'));
|
||||||
|
console.log('');
|
||||||
|
|||||||
+22
-13
@@ -1659,13 +1659,6 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD && !this.allowUnauthenticatedNetwork) {
|
|
||||||
throw new Error(
|
|
||||||
'Refusing to start Codeman on a non-loopback host without CODEMAN_PASSWORD. ' +
|
|
||||||
'Set CODEMAN_PASSWORD or explicitly allow unauthenticated network access.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.setupRoutes();
|
await this.setupRoutes();
|
||||||
|
|
||||||
const lifecycleLog = getLifecycleLog();
|
const lifecycleLog = getLifecycleLog();
|
||||||
@@ -1697,12 +1690,28 @@ export class WebServer extends EventEmitter {
|
|||||||
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
|
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
|
||||||
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
|
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
|
||||||
|
|
||||||
if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD && this.allowUnauthenticatedNetwork) {
|
// Codeman binds loopback (127.0.0.1) by default, which is safe out of the box.
|
||||||
console.warn('\n⚠ WARNING: No CODEMAN_PASSWORD set — server is accessible without authentication.');
|
// If the user opts into a non-loopback bind (e.g. --host 0.0.0.0) WITHOUT a
|
||||||
console.warn(' Anyone on your network can access and control Claude sessions.');
|
// password we no longer refuse to start — that surprised people whose setups
|
||||||
console.warn(
|
// "just worked" before. Instead we start and warn loudly, pointing at the ways
|
||||||
' This was explicitly allowed by --allow-unauthenticated-network or CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK.\n'
|
// to secure it. --allow-unauthenticated-network just acknowledges the risk (a
|
||||||
);
|
// terser note). See docs/security-architecture.md.
|
||||||
|
if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD) {
|
||||||
|
if (this.allowUnauthenticatedNetwork) {
|
||||||
|
console.warn(
|
||||||
|
`\n⚠ Codeman is reachable WITHOUT a password on ${displayHost}:${this.port} ` +
|
||||||
|
'(explicitly allowed). Anyone who can reach it can control your Claude sessions.\n'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.warn(`\n⚠ WARNING: Codeman is bound to a non-loopback host (${this.host}) with NO password.`);
|
||||||
|
console.warn(` Anyone who can reach ${displayHost}:${this.port} can control your Claude sessions.`);
|
||||||
|
console.warn(' Secure it with ONE of:');
|
||||||
|
console.warn(' • set CODEMAN_PASSWORD=<password> (HTTP Basic auth), or');
|
||||||
|
console.warn(' • bind loopback only: --host 127.0.0.1, then front it with an');
|
||||||
|
console.warn(' authenticated tunnel (cloudflared) or `tailscale serve`, or');
|
||||||
|
console.warn(' • keep this bind and accept the risk: --allow-unauthenticated-network');
|
||||||
|
console.warn(' See docs/security-architecture.md for details.\n');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set API URL for child processes (MCP server, spawned sessions)
|
// Set API URL for child processes (MCP server, spawned sessions)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const AUTH_PORT = 3160;
|
|||||||
const NOAUTH_PORT = 3161;
|
const NOAUTH_PORT = 3161;
|
||||||
const NETWORK_OVERRIDE_PORT = 3162;
|
const NETWORK_OVERRIDE_PORT = 3162;
|
||||||
const AUTH_RATE_LIMIT_PORT = 3220;
|
const AUTH_RATE_LIMIT_PORT = 3220;
|
||||||
|
const NOAUTH_NETWORK_PORT = 3221;
|
||||||
const TEST_USER = 'admin';
|
const TEST_USER = 'admin';
|
||||||
const TEST_PASS = 'test-password-12345';
|
const TEST_PASS = 'test-password-12345';
|
||||||
|
|
||||||
@@ -361,11 +362,22 @@ describe('No-Auth Server Startup Policy', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects non-loopback startup without a password or explicit override', async () => {
|
it('starts with a loud warning (not a hard failure) on a non-loopback bind without a password', async () => {
|
||||||
const networkServer = new WebServer(0, false, true, '0.0.0.0');
|
// Policy (0.9.0): loopback is the safe default, but opting into a non-loopback
|
||||||
|
// bind without a password no longer refuses to start — it starts and warns,
|
||||||
|
// pointing at how to secure it. See docs/security-architecture.md.
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const networkServer = new WebServer(NOAUTH_NETWORK_PORT, false, true, '0.0.0.0');
|
||||||
|
|
||||||
await expect(networkServer.start()).rejects.toThrow(/CODEMAN_PASSWORD/);
|
await expect(networkServer.start()).resolves.toBeUndefined();
|
||||||
|
const res = await fetch(`http://localhost:${NOAUTH_NETWORK_PORT}/api/status`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const warned = warnSpy.mock.calls.flat().join('\n');
|
||||||
|
expect(warned).toMatch(/non-loopback host|NO password/i);
|
||||||
|
expect(warned).toMatch(/CODEMAN_PASSWORD/);
|
||||||
|
|
||||||
|
warnSpy.mockRestore();
|
||||||
await networkServer.stop();
|
await networkServer.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user