diff --git a/docs/SECURITY.md b/docs/SECURITY.md index bf1daaa..950c6e3 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -87,8 +87,11 @@ engine is configured without filesystem access by default.) ### Loopback-only HTTP [`src/api/server.ts`](../src/api/server.ts) is served on `:4000` with no auth. -Binding to `0.0.0.0` or putting the dashboard behind a proxy is out of scope. -Running this on a shared host without a tunnel is unsupported. +[`src/cli.ts`](../src/cli.ts) defaults `--bind` to `127.0.0.1` for both `run` +and `replay`, so the dashboard is reachable only from loopback unless the +operator explicitly opts out (e.g. `--bind 0.0.0.0` behind a tunnel they +control). Putting the dashboard behind an exposed proxy without auth is out +of scope. Running this on a shared host without a tunnel is unsupported. ### No network from the orchestrator itself diff --git a/docs/design-docs/threat-model.md b/docs/design-docs/threat-model.md index 6bba1b7..5a45333 100644 --- a/docs/design-docs/threat-model.md +++ b/docs/design-docs/threat-model.md @@ -2,7 +2,7 @@ _Status:_ active _Created:_ 2026-04-18 -_Last reviewed:_ 2026-04-18 +_Last reviewed:_ 2026-05-02 Long-form discussion of what Symphony defends against, what it doesn't, and why. The short summary lives in [`../SECURITY.md`](../SECURITY.md); read that @@ -42,13 +42,13 @@ first. ## Mitigations -| Goal | Mitigation | Covered by | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -| 1 | Workspace is a git worktree; `before_remove` hook tears it down. | `WorkspaceManager` tests. | -| 2 | `assertSafeIdentifier` rejects non-`[A-Za-z0-9_-]` identifiers. | `workspace/manager.test.ts`. | -| 3 | Claude stdout is line-buffered via `readline`; `toAgentTurn` drops unparseable JSON; stderr is capped at 8 KiB. | `src/agent/claude-code.ts`. | -| 4 | `LINEAR_API_KEY` is read in `cli.ts`, passed directly to the HTTP header, and never logged. The rendered-prompt path has no access to `process.env`. | review (no test yet — filed in tech-debt). | -| 5 | `serve({ fetch, port })` binds to all interfaces by default on Hono's node adapter, but our documentation (and README) specify `localhost`-only use. If this becomes a concrete risk, we'll bind to `127.0.0.1` explicitly. | README warning. | +| Goal | Mitigation | Covered by | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| 1 | Workspace is a git worktree; `before_remove` hook tears it down. | `WorkspaceManager` tests. | +| 2 | `assertSafeIdentifier` rejects non-`[A-Za-z0-9_-]` identifiers. | `workspace/manager.test.ts`. | +| 3 | Claude stdout is line-buffered via `readline`; `toAgentTurn` drops unparseable JSON; stderr is capped at 8 KiB. | `src/agent/claude-code.ts`. | +| 4 | `LINEAR_API_KEY` is read in `cli.ts`, passed directly to the HTTP header, and never logged. The rendered-prompt path has no access to `process.env`. | review (no test yet — filed in tech-debt). | +| 5 | `cli.ts` passes `hostname` to `serve({ fetch, port })`, defaulting `--bind` to `127.0.0.1` for both `run` and `replay`; LAN exposure requires the operator to opt in (e.g. `--bind 0.0.0.0`). | `src/cli.ts`; [`SECURITY.md`](../SECURITY.md). | ## Attacker playbook (what we assume they try) @@ -69,8 +69,6 @@ first. ## Open gaps -- No explicit `serve` bind address — tracked in - [`../exec-plans/tech-debt-tracker.md`](../exec-plans/tech-debt-tracker.md). - No eval that confirms `LINEAR_API_KEY` is absent from the DB + JSONL — tracked in tech-debt. - No kill-switch if the agent decides to `git push --force` to an upstream diff --git a/docs/exec-plans/tech-debt-tracker.md b/docs/exec-plans/tech-debt-tracker.md index 8f77f45..1e48da3 100644 --- a/docs/exec-plans/tech-debt-tracker.md +++ b/docs/exec-plans/tech-debt-tracker.md @@ -23,14 +23,14 @@ workaround should append a row before moving on. The doc-gardening eval | 2026-04-18 | web | Visual QA flow is manual. No automated browser walkthrough. | low | `.github/media/` | Add a browser MCP task that captures screenshots into `.github/media/`. | | 2026-04-18 | prompts | No lint for undefined liquid vars in prompt files. | low | `src/config/workflow.ts` | During parse, render the template against a well-known context skeleton and fail on missing refs. | | 2026-04-18 | persistence | No log rotation; `.symphony/logs/` grows unbounded until manual prune. | low | `src/persistence/logger.ts` | Add size-capped rotation or wire `prune` into a long-run heuristic. | -| 2026-04-18 | security | `serve({ fetch, port })` does not explicitly bind `127.0.0.1`. | low | `src/cli.ts` | Bind to loopback explicitly; surface a `--bind` flag. | | 2026-04-18 | security | No eval asserting `LINEAR_API_KEY` is absent from the DB + JSONL. | low | `src/eval/` | Add a scenario that seeds the env and greps the logs. | ## Resolved (archive) _Move rows here when the debt ships a regression guard._ -| Date resolved | Date filed | Debt | Resolved by | -| ------------- | ---------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| 2026-04-20 | 2026-04-18 | Leftover `worktrees/BEN-*` from old Elixir runs. | Directory no longer present in the repo; no regression guard added (won't recur by design). | -| 2026-04-23 | 2026-04-18 | Layer-direction rule is review-only; no automatic graph check. | [`src/arch.test.ts`](../../src/arch.test.ts) walks `src/` and fails on any forward-only break. | +| Date resolved | Date filed | Debt | Resolved by | +| ------------- | ---------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| 2026-04-20 | 2026-04-18 | Leftover `worktrees/BEN-*` from old Elixir runs. | Directory no longer present in the repo; no regression guard added (won't recur by design). | +| 2026-04-23 | 2026-04-18 | Layer-direction rule is review-only; no automatic graph check. | [`src/arch.test.ts`](../../src/arch.test.ts) walks `src/` and fails on any forward-only break. | +| 2026-05-02 | 2026-04-18 | `serve({ fetch, port })` does not explicitly bind `127.0.0.1`. | [`src/cli.ts`](../../src/cli.ts) now defaults `--bind` to `127.0.0.1` and threads `hostname` into both `serve()` calls. | diff --git a/docs/product-specs/replay.md b/docs/product-specs/replay.md index 3a30b2b..d073c41 100644 --- a/docs/product-specs/replay.md +++ b/docs/product-specs/replay.md @@ -14,10 +14,12 @@ a live run in the dashboard. This is the operator's primary post-mortem tool. ## Command ```bash -pnpm tsx src/cli.ts replay [--port 4000] [--speed 5] +pnpm tsx src/cli.ts replay [--port 4000] [--bind 127.0.0.1] [--speed 5] ``` - `--port` — HTTP port for the dashboard. +- `--bind` — HTTP bind address. Defaults to `127.0.0.1`; pass `0.0.0.0` + to expose on the LAN. - `--speed` — playback multiplier (1 = realtime; 5 = five× faster). ## Invariants diff --git a/src/cli.ts b/src/cli.ts index 7d5bc2b..cb4d61f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,6 +45,7 @@ const DEMO_ISSUES: Issue[] = [ interface BootOptions { workflowPath: string; port: number; + bind: string; mock: boolean; noDemo: boolean; seedPath?: string; @@ -66,7 +67,14 @@ function loadSeedIssues(path: string): Issue[] { return SeedFileSchema.parse(raw).issues; } -async function boot({ workflowPath, port, mock, noDemo, seedPath }: BootOptions): Promise { +async function boot({ + workflowPath, + port, + bind, + mock, + noDemo, + seedPath, +}: BootOptions): Promise { const workflow = parseWorkflow(workflowPath); const isMock = mock || workflow.config.agent.kind === "mock"; @@ -202,10 +210,10 @@ async function boot({ workflowPath, port, mock, noDemo, seedPath }: BootOptions) }, }), }); - const server = serve({ fetch: app.fetch, port }); + const server = serve({ fetch: app.fetch, port, hostname: bind }); orchestrator.start(); - console.log(`symphony listening on http://localhost:${port} (${modeLabel})`); + console.log(`symphony listening on http://${bind}:${port} (${modeLabel})`); const shutdown = async () => { console.log("\nshutting down"); @@ -218,7 +226,12 @@ async function boot({ workflowPath, port, mock, noDemo, seedPath }: BootOptions) process.once("SIGTERM", shutdown); } -async function replay(opts: { runId: string; port: number; speed: number }): Promise { +async function replay(opts: { + runId: string; + port: number; + bind: string; + speed: number; +}): Promise { const dbPath = resolve(".symphony/symphony.db"); const logsDir = resolve(".symphony/logs"); const logger = new SymphonyLogger({ dbPath, logsDir }); @@ -230,8 +243,8 @@ async function replay(opts: { runId: string; port: number; speed: number }): Pro }); const app = createServer({ events, logger }); - const server = serve({ fetch: app.fetch, port: opts.port }); - console.log(`symphony replay serving http://localhost:${opts.port} (speed ${opts.speed}x)`); + const server = serve({ fetch: app.fetch, port: opts.port, hostname: opts.bind }); + console.log(`symphony replay serving http://${opts.bind}:${opts.port} (speed ${opts.speed}x)`); try { await run(); @@ -262,17 +275,29 @@ program .command("run", { isDefault: true }) .argument("", "path to WORKFLOW.md") .option("-p, --port ", "HTTP server port", "4000") + .option( + "--bind ", + "HTTP server bind address; defaults to loopback. Pass 0.0.0.0 to expose on the LAN.", + "127.0.0.1", + ) .option("--mock", "use mock agent instead of real agent") .option("--no-demo", "skip seeding the built-in mock-mode demo issues") .option("--seed ", "YAML file with a demo issues list (see fixtures/seed.example.yaml)") .action( async ( workflowPath: string, - opts: { port: string; mock?: boolean; demo?: boolean; seed?: string }, + opts: { + port: string; + bind: string; + mock?: boolean; + demo?: boolean; + seed?: string; + }, ) => { await boot({ workflowPath, port: Number(opts.port), + bind: opts.bind, mock: Boolean(opts.mock), noDemo: opts.demo === false, seedPath: opts.seed, @@ -323,11 +348,17 @@ program .command("replay") .argument("", "id of a previously recorded run from .symphony/symphony.db") .option("-p, --port ", "HTTP server port", "4000") + .option( + "--bind ", + "HTTP server bind address; defaults to loopback. Pass 0.0.0.0 to expose on the LAN.", + "127.0.0.1", + ) .option("--speed ", "playback speed multiplier (1 = realtime)", "5") - .action(async (runId: string, opts: { port: string; speed: string }) => { + .action(async (runId: string, opts: { port: string; bind: string; speed: string }) => { await replay({ runId, port: Number(opts.port), + bind: opts.bind, speed: Number(opts.speed), }); });