Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 8 additions & 10 deletions docs/design-docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions docs/exec-plans/tech-debt-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
4 changes: 3 additions & 1 deletion docs/product-specs/replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <runId> [--port 4000] [--speed 5]
pnpm tsx src/cli.ts replay <runId> [--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
Expand Down
47 changes: 39 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const DEMO_ISSUES: Issue[] = [
interface BootOptions {
workflowPath: string;
port: number;
bind: string;
mock: boolean;
noDemo: boolean;
seedPath?: string;
Expand All @@ -66,7 +67,14 @@ function loadSeedIssues(path: string): Issue[] {
return SeedFileSchema.parse(raw).issues;
}

async function boot({ workflowPath, port, mock, noDemo, seedPath }: BootOptions): Promise<void> {
async function boot({
workflowPath,
port,
bind,
mock,
noDemo,
seedPath,
}: BootOptions): Promise<void> {
const workflow = parseWorkflow(workflowPath);
const isMock = mock || workflow.config.agent.kind === "mock";

Expand Down Expand Up @@ -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");
Expand All @@ -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<void> {
async function replay(opts: {
runId: string;
port: number;
bind: string;
speed: number;
}): Promise<void> {
const dbPath = resolve(".symphony/symphony.db");
const logsDir = resolve(".symphony/logs");
const logger = new SymphonyLogger({ dbPath, logsDir });
Expand All @@ -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();
Expand Down Expand Up @@ -262,17 +275,29 @@ program
.command("run", { isDefault: true })
.argument("<workflow>", "path to WORKFLOW.md")
.option("-p, --port <port>", "HTTP server port", "4000")
.option(
"--bind <host>",
"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 <path>", "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,
Expand Down Expand Up @@ -323,11 +348,17 @@ program
.command("replay")
.argument("<runId>", "id of a previously recorded run from .symphony/symphony.db")
.option("-p, --port <port>", "HTTP server port", "4000")
.option(
"--bind <host>",
"HTTP server bind address; defaults to loopback. Pass 0.0.0.0 to expose on the LAN.",
"127.0.0.1",
)
.option("--speed <factor>", "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),
});
});
Expand Down
Loading