mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 21:19:41 +02:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8e0e2a343 | ||
|
|
4d0586a2aa | ||
|
|
67a15b5949 | ||
|
|
6bf69a82c8 | ||
|
|
d2efaa255b | ||
|
|
a721af4552 | ||
|
|
e6b18fd126 | ||
|
|
6ee88be549 | ||
|
|
1316725fdc | ||
|
|
187ce653ae | ||
|
|
da00fa6038 | ||
|
|
a36543c1b9 | ||
|
|
dea015dc91 | ||
|
|
333dc047c3 | ||
|
|
d897c9a1cf | ||
|
|
880b63d2a0 | ||
|
|
eb874339dd |
@@ -2,8 +2,27 @@ dist/
|
|||||||
coverage/
|
coverage/
|
||||||
node_modules/
|
node_modules/
|
||||||
src/web/public/vendor/
|
src/web/public/vendor/
|
||||||
|
src/web/public/gesture/
|
||||||
src/web/public/app.js
|
src/web/public/app.js
|
||||||
src/web/public/styles.css
|
src/web/public/styles.css
|
||||||
src/web/public/mobile.css
|
src/web/public/mobile.css
|
||||||
src/web/public/index.html
|
src/web/public/index.html
|
||||||
|
# Hand-formatted public JS modules (never prettier-enforced; the new
|
||||||
|
# check-public-assets.mjs still validates NUL bytes + JS syntax on these).
|
||||||
|
src/web/public/constants.js
|
||||||
|
src/web/public/image-input.js
|
||||||
|
src/web/public/input-cjk.js
|
||||||
|
src/web/public/keyboard-accessory.js
|
||||||
|
src/web/public/notification-manager.js
|
||||||
|
src/web/public/orchestrator-panel.js
|
||||||
|
src/web/public/panels-ui.js
|
||||||
|
src/web/public/ralph-panel.js
|
||||||
|
src/web/public/ralph-wizard.js
|
||||||
|
src/web/public/respawn-ui.js
|
||||||
|
src/web/public/session-ui.js
|
||||||
|
src/web/public/settings-ui.js
|
||||||
|
src/web/public/sw.js
|
||||||
|
src/web/public/terminal-ui.js
|
||||||
|
src/web/public/voice-input.js
|
||||||
|
src/web/public/upload.html
|
||||||
scripts/remotion/
|
scripts/remotion/
|
||||||
|
|||||||
@@ -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,13 +78,15 @@ 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`). 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`) |
|
||||||
|
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
|
||||||
| Production start | `npm run start` |
|
| Production start | `npm run start` |
|
||||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||||
|
|
||||||
**CI**: `.github/workflows/ci.yml` runs `check:lockfile`, `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
|
**CI**: `.github/workflows/ci.yml` runs `check:lockfile`, `typecheck`, `lint`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s) on push to master/main and on PRs (Node 22). The unit test suite is excluded (it spawns tmux).
|
||||||
|
|
||||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
||||||
|
|
||||||
@@ -99,6 +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 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.
|
||||||
@@ -172,9 +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`/`CODEMAN_PASSWORD` env vars |
|
| **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). 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
+3867
-2696
File diff suppressed because it is too large
Load Diff
+26
-11
@@ -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",
|
||||||
@@ -21,8 +21,9 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||||
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
||||||
"format": "prettier --write 'src/**/*.ts'",
|
"format": "prettier --write 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
|
||||||
"format:check": "prettier --check 'src/**/*.ts'",
|
"format:check": "prettier --check 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
|
||||||
|
"check:public-assets": "node scripts/check-public-assets.mjs",
|
||||||
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
|
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
|
"version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
"@fastify/compress": "^8.3.1",
|
"@fastify/compress": "^8.3.1",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^9.1.3",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-unicode11": "^0.9.0",
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
@@ -62,18 +63,18 @@
|
|||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^5.1.0",
|
"fastify": "^5.8.5",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^14.0.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.29.8",
|
"@changesets/cli": "^2.29.8",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^9.0.0",
|
||||||
"@remotion/cli": "4.0.429",
|
"@remotion/cli": "4.0.473",
|
||||||
"@remotion/transitions": "4.0.429",
|
"@remotion/transitions": "4.0.473",
|
||||||
"@types/node": "^20.19.33",
|
"@types/node": "^20.19.33",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
"agent-browser": "^0.6.0",
|
"agent-browser": "^0.6.0",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
@@ -90,16 +91,30 @@
|
|||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"prettier": "^3.4.0",
|
"prettier": "^3.4.0",
|
||||||
"puppeteer": "^24.36.0",
|
"puppeteer": "^24.36.0",
|
||||||
"remotion": "4.0.429",
|
"remotion": "4.0.473",
|
||||||
"tsx": "^4.15.0",
|
"tsx": "^4.15.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.0.0",
|
"typescript-eslint": "^8.0.0",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.1.8"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||||
"@rspack/binding-linux-x64-gnu": "^1.7.7"
|
"@rspack/binding-linux-x64-gnu": "^1.7.7"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"basic-ftp": "^5.3.1",
|
||||||
|
"fast-uri": "^3.1.2",
|
||||||
|
"flatted": "^3.4.2",
|
||||||
|
"anymatch": {
|
||||||
|
"picomatch": "^2.3.2"
|
||||||
|
},
|
||||||
|
"micromatch": {
|
||||||
|
"picomatch": "^2.3.2"
|
||||||
|
},
|
||||||
|
"readdirp": {
|
||||||
|
"picomatch": "^2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+1095
-891
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,6 @@
|
|||||||
"jsdom": "^24.1.3",
|
"jsdom": "^24.1.3",
|
||||||
"tsup": "^8.5.1",
|
"tsup": "^8.5.1",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^4.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { dirname, extname, join, relative, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const publicRoot = resolve(repoRoot, 'src/web/public');
|
||||||
|
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
|
||||||
|
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
|
||||||
|
|
||||||
|
function collectTextAssets(dir) {
|
||||||
|
const files = [];
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const fullPath = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...collectTextAssets(fullPath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (checkedExtensions.has(extname(entry.name))) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNullByte(buffer) {
|
||||||
|
for (let i = 0; i < buffer.length; i += 1) {
|
||||||
|
if (buffer[i] === 0) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = collectTextAssets(publicRoot);
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const rel = relative(repoRoot, file);
|
||||||
|
const data = readFileSync(file);
|
||||||
|
const nullByteIndex = findNullByte(data);
|
||||||
|
if (nullByteIndex !== -1) {
|
||||||
|
failures.push(`${rel}: contains literal NUL byte at offset ${nullByteIndex}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extname(file) === '.js') {
|
||||||
|
try {
|
||||||
|
execFileSync(process.execPath, ['--check', file], { cwd: repoRoot, stdio: 'pipe' });
|
||||||
|
} catch (err) {
|
||||||
|
failures.push(`${rel}: JavaScript syntax check failed\n${String(err.stderr || err.message).trim()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execFileSync(prettierBin, ['--check', ...files], { cwd: repoRoot, stdio: 'pipe' });
|
||||||
|
} catch (err) {
|
||||||
|
failures.push(`Prettier public asset check failed\n${String(err.stdout || err.stderr || err.message).trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(failures.join('\n\n'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Public asset checks passed (${files.length} files).`);
|
||||||
@@ -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('');
|
||||||
|
|||||||
+11
-3
@@ -483,21 +483,29 @@ program
|
|||||||
program
|
program
|
||||||
.command('web')
|
.command('web')
|
||||||
.description('Start the web interface')
|
.description('Start the web interface')
|
||||||
|
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
|
||||||
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
|
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
|
||||||
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
||||||
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
||||||
|
.option(
|
||||||
|
'--allow-unauthenticated-network',
|
||||||
|
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
|
||||||
|
)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const { startWebServer } = await import('./web/server.js');
|
const { startWebServer } = await import('./web/server.js');
|
||||||
|
const host = options.host;
|
||||||
const port = parseInt(options.port, 10);
|
const port = parseInt(options.port, 10);
|
||||||
const https = !!options.https;
|
const https = !!options.https;
|
||||||
const titleHostname = options.titleHostname;
|
const titleHostname = options.titleHostname;
|
||||||
|
const allowUnauthenticatedNetwork = !!options.allowUnauthenticatedNetwork;
|
||||||
const protocol = https ? 'https' : 'http';
|
const protocol = https ? 'https' : 'http';
|
||||||
|
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||||
|
|
||||||
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
|
console.log(chalk.cyan(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const server = await startWebServer(port, https, false, titleHostname);
|
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
|
||||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`));
|
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://${displayHost}:${port}`));
|
||||||
if (https) {
|
if (https) {
|
||||||
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-10
@@ -73,6 +73,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
|||||||
/** Default stats collection interval (2 seconds) */
|
/** Default stats collection interval (2 seconds) */
|
||||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
/** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */
|
||||||
|
const TMUX_LAUNCH_CWD = '/tmp';
|
||||||
|
|
||||||
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
|
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
|
||||||
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
|
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
|
||||||
|
|
||||||
@@ -679,8 +682,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
||||||
const cleanEnv = { ...process.env };
|
const cleanEnv = { ...process.env };
|
||||||
delete cleanEnv.TMUX;
|
delete cleanEnv.TMUX;
|
||||||
execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, {
|
// Start the tmux server from a stable local cwd so FUSE/rclone workspace
|
||||||
cwd: workingDir,
|
// blips do not poison tmux's long-lived getcwd state.
|
||||||
|
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
|
||||||
|
cwd: TMUX_LAUNCH_CWD,
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
env: cleanEnv,
|
env: cleanEnv,
|
||||||
@@ -706,11 +711,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// so secret values stay off the bash command line. Must run before respawn-pane.
|
// so secret values stay off the bash command line. Must run before respawn-pane.
|
||||||
this.applyEnvOverrides(muxName, envOverrides);
|
this.applyEnvOverrides(muxName, envOverrides);
|
||||||
|
|
||||||
// Replace the shell with the actual command (no echo in terminal)
|
// Replace the shell with the actual command (no echo in terminal). Keep
|
||||||
execSync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
// pane launch in /tmp, then cd inside bash against the current mount table.
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||||
stdio: 'ignore',
|
execSync(
|
||||||
});
|
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||||
|
{
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: 'ignore',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Wait for tmux session to be queryable
|
// Wait for tmux session to be queryable
|
||||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||||
@@ -890,9 +900,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||||
this.applyEnvOverrides(muxName, envOverrides);
|
this.applyEnvOverrides(muxName, envOverrides);
|
||||||
|
|
||||||
await execAsync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
await execAsync(
|
||||||
});
|
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||||
|
{
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
);
|
||||||
// Wait for the respawned process to start
|
// Wait for the respawned process to start
|
||||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||||
const pid = this.getPanePid(muxName);
|
const pid = this.getPanePid(muxName);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* - CORS (localhost only)
|
* - CORS (localhost only)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyInstance } from 'fastify';
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
import { StaleExpirationMap } from '../../utils/index.js';
|
import { StaleExpirationMap } from '../../utils/index.js';
|
||||||
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||||
@@ -69,6 +69,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
|||||||
const authSessions = state.authSessions;
|
const authSessions = state.authSessions;
|
||||||
const authFailures = state.authFailures;
|
const authFailures = state.authFailures;
|
||||||
|
|
||||||
|
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void {
|
||||||
|
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
|
||||||
|
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
||||||
|
reply.header('Retry-After', String(retryAfterSeconds));
|
||||||
|
reply.code(429).send('Too Many Requests — try again later');
|
||||||
|
}
|
||||||
|
|
||||||
app.addHook('onRequest', (req, reply, done) => {
|
app.addHook('onRequest', (req, reply, done) => {
|
||||||
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
|
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
|
||||||
// Safe: validated by HookEventSchema, only triggers broadcasts.
|
// Safe: validated by HookEventSchema, only triggers broadcasts.
|
||||||
@@ -90,13 +97,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
|||||||
|
|
||||||
const clientIp = req.ip;
|
const clientIp = req.ip;
|
||||||
|
|
||||||
// Rate limit: reject if too many failed attempts from this IP
|
|
||||||
const failures = authFailures.get(clientIp) ?? 0;
|
|
||||||
if (failures >= AUTH_FAILURE_MAX) {
|
|
||||||
reply.code(429).send('Too Many Requests — try again later');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check session cookie first (avoids re-sending credentials on every request)
|
// Check session cookie first (avoids re-sending credentials on every request)
|
||||||
// Use get() instead of has() so refreshOnGet extends the TTL on active sessions
|
// Use get() instead of has() so refreshOnGet extends the TTL on active sessions
|
||||||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||||||
@@ -140,6 +140,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate limit only requests that failed to authenticate on this attempt.
|
||||||
|
const failures = authFailures.get(clientIp) ?? 0;
|
||||||
|
if (failures >= AUTH_FAILURE_MAX) {
|
||||||
|
sendAuthRateLimit(reply, clientIp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Auth failed — track failure count
|
// Auth failed — track failure count
|
||||||
authFailures.set(clientIp, failures + 1);
|
authFailures.set(clientIp, failures + 1);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { isIP } from 'node:net';
|
||||||
|
|
||||||
|
const EXPLICIT_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||||
|
|
||||||
|
export function isExplicitlyEnabled(value: string | undefined): boolean {
|
||||||
|
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoopbackBindHost(host: string): boolean {
|
||||||
|
const normalized = host
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^\[(.*)\]$/, '$1');
|
||||||
|
if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isIP(normalized) === 4 && normalized.startsWith('127.')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return normalized.startsWith('::ffff:127.');
|
||||||
|
}
|
||||||
@@ -1366,7 +1366,7 @@ class CodemanApp {
|
|||||||
const placeholders = [];
|
const placeholders = [];
|
||||||
const masked = text.replace(fenceRe, (m) => {
|
const masked = text.replace(fenceRe, (m) => {
|
||||||
placeholders.push(m);
|
placeholders.push(m);
|
||||||
return ` | |||||||