diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index a958a53..40848f5 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,4 +1,5 @@ import { Hono } from "hono" +import { type AppEnv, authMiddleware } from "./auth" import { persistenceFromEnv } from "./persistence" import { artifactPath, SHAPING_STAGES } from "./shaping" import { @@ -16,12 +17,16 @@ import { */ const persistence = persistenceFromEnv() -export const app = new Hono() +export const app = new Hono() app.onError((err, c) => c.json({ error: String(err) }, 500)) app.get("/health", (c) => c.json({ ok: true })) +// Auth gate (ENG-004): GET read views stay open; mutating /api requests require a Bearer token when +// API_AUTH_TOKEN is set. The resolved principal is recorded on approval events below. +app.use("/api/*", authMiddleware()) + app.get("/api/tickets", async (c) => c.json({ tickets: await persistence.tracker.list() })) app.get("/api/audit", async (c) => c.json({ events: await persistence.audit.query() })) // Pending human gates, derived from the audit log. A roadmap sign-off is pending when the latest @@ -105,7 +110,15 @@ app.post("/api/epics/:id/decompose", async (c) => { app.post("/api/epics/:id/approve-roadmap", async (c) => { const id = c.req.param("id") const signaled = await approveRoadmap(id) - return c.json({ epicId: id, gate: "roadmap", signaled }) + const by = c.get("actor") + // Record who released the gate (ENG-004) — append-only, so the audit log carries the approver. + await persistence.audit.append({ + actor: "human", + kind: "approval_decided", + ticketId: null, + payload: { epicId: id, gate: "roadmap", by, signaled }, + }) + return c.json({ epicId: id, gate: "roadmap", signaled, by }) }) // Create a ticket through the tracker under a chosen epic (or seed the default Mission→Goal→Epic @@ -144,5 +157,13 @@ app.post("/api/tickets/:id/approve", async (c) => { const body = (await c.req.json().catch(() => ({}))) as { gate?: "merge" | "deploy" } const gate = body.gate === "deploy" ? "deploy" : "merge" await approveTicket(id, gate) - return c.json({ ticketId: id, gate, signaled: true }) + const by = c.get("actor") + // Record who released the gate (ENG-004). + await persistence.audit.append({ + actor: "human", + kind: "approval_decided", + ticketId: id, + payload: { gate, by }, + }) + return c.json({ ticketId: id, gate, signaled: true, by }) }) diff --git a/apps/server/src/auth.test.ts b/apps/server/src/auth.test.ts new file mode 100644 index 0000000..f44bcbd --- /dev/null +++ b/apps/server/src/auth.test.ts @@ -0,0 +1,70 @@ +import { Hono } from "hono" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { type AppEnv, authMiddleware, checkAuth } from "./auth" + +describe("checkAuth", () => { + it("allows dev mode (principal 'dev') when no token is configured", () => { + expect(checkAuth({}, undefined)).toEqual({ ok: true, principal: "dev" }) + }) + + it("accepts a correct Bearer token and defaults the principal to 'operator'", () => { + const r = checkAuth({ authorization: "Bearer s3cret" }, "s3cret") + expect(r).toEqual({ ok: true, principal: "operator" }) + }) + + it("honors the X-Actor header as the principal", () => { + const r = checkAuth({ authorization: "Bearer s3cret", actor: "toby" }, "s3cret") + expect(r).toEqual({ ok: true, principal: "toby" }) + }) + + it("rejects a missing or wrong token", () => { + expect(checkAuth({}, "s3cret").ok).toBe(false) + expect(checkAuth({ authorization: "Bearer nope" }, "s3cret").ok).toBe(false) + expect(checkAuth({ authorization: "s3cret" }, "s3cret").ok).toBe(false) // missing "Bearer " + }) +}) + +describe("authMiddleware", () => { + const prev = process.env.API_AUTH_TOKEN + let app: Hono + + beforeEach(() => { + app = new Hono() + app.use("/api/*", authMiddleware()) + app.get("/api/thing", (c) => c.json({ actor: c.get("actor") })) + app.post("/api/thing", (c) => c.json({ actor: c.get("actor") })) + }) + afterEach(() => { + if (prev === undefined) delete process.env.API_AUTH_TOKEN + else process.env.API_AUTH_TOKEN = prev + }) + + it("leaves GET read views open", async () => { + process.env.API_AUTH_TOKEN = "s3cret" + const res = await app.request("/api/thing") + expect(res.status).toBe(200) + }) + + it("401s an unauthenticated mutation when a token is configured", async () => { + process.env.API_AUTH_TOKEN = "s3cret" + const res = await app.request("/api/thing", { method: "POST" }) + expect(res.status).toBe(401) + }) + + it("allows an authenticated mutation and exposes the principal", async () => { + process.env.API_AUTH_TOKEN = "s3cret" + const res = await app.request("/api/thing", { + method: "POST", + headers: { authorization: "Bearer s3cret", "x-actor": "toby" }, + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ actor: "toby" }) + }) + + it("allows mutations in dev mode when no token is configured", async () => { + delete process.env.API_AUTH_TOKEN + const res = await app.request("/api/thing", { method: "POST" }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ actor: "dev" }) + }) +}) diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts new file mode 100644 index 0000000..c1a22a3 --- /dev/null +++ b/apps/server/src/auth.ts @@ -0,0 +1,78 @@ +import { timingSafeEqual } from "node:crypto" +import type { MiddlewareHandler } from "hono" + +/** + * API authentication (ENG-004). The approval gates are the product's core value (invariant #4), so + * mutating requests must be authenticated and the deciding principal recorded. Auth activates when + * `API_AUTH_TOKEN` is set; when it isn't, the server runs in an open "dev mode" (and warns loudly at + * startup) so the local `docker compose up` quickstart keeps working — set the token to enforce. + */ + +/** Hono environment: the authenticated principal attributed to writes (audit identity). */ +export type AppEnv = { Variables: { actor: string } } + +const ACTOR_HEADER = "x-actor" + +export interface AuthResult { + ok: boolean + /** Who to attribute the write to in the audit log. */ + principal: string +} + +function safeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a) + const bb = Buffer.from(b) + if (ab.length !== bb.length) return false + return timingSafeEqual(ab, bb) +} + +/** + * Decide whether a request is authorized and who the principal is. Pure + header-driven for testing. + * - No configured token → dev mode: allowed, principal `dev`. + * - Configured token → requires `Authorization: Bearer `; principal from `X-Actor` + * (default `operator`). A bad/missing token → not ok. + */ +export function checkAuth( + headers: { authorization?: string | null; actor?: string | null }, + configuredToken: string | undefined, +): AuthResult { + if (!configuredToken) return { ok: true, principal: "dev" } + const match = /^Bearer\s+(.+)$/i.exec((headers.authorization ?? "").trim()) + const token = match?.[1] + if (!token || !safeEqual(token, configuredToken)) { + return { ok: false, principal: "anonymous" } + } + const actor = (headers.actor ?? "").trim() + return { ok: true, principal: actor || "operator" } +} + +/** + * Hono middleware enforcing auth on mutating requests. GET (read views) and `/health` stay open; any + * non-GET request requires auth when a token is configured. The resolved principal is stored on the + * context for handlers to attribute approvals/writes to. + */ +export function authMiddleware(): MiddlewareHandler { + return async (c, next) => { + if (c.req.method === "GET") { + c.set("actor", "anonymous") + return next() + } + const result = checkAuth( + { authorization: c.req.header("authorization"), actor: c.req.header(ACTOR_HEADER) }, + process.env.API_AUTH_TOKEN, + ) + if (!result.ok) return c.json({ error: "unauthorized" }, 401) + c.set("actor", result.principal) + return next() + } +} + +/** Log a one-time warning if auth is disabled (no token configured). Call at server startup. */ +export function warnIfAuthDisabled(): void { + if (!process.env.API_AUTH_TOKEN) { + console.warn( + "[auth] API_AUTH_TOKEN is not set — mutating endpoints (incl. approval gates) are UNAUTHENTICATED. " + + "Set API_AUTH_TOKEN to require a Bearer token before any shared/live deployment.", + ) + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 49623f3..a89b164 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,9 +1,11 @@ import { serve } from "@hono/node-server" import { app } from "./app" +import { warnIfAuthDisabled } from "./auth" import { startHeartbeat } from "./scheduler" const port = Number(process.env.PORT ?? 3000) +warnIfAuthDisabled() serve({ fetch: app.fetch, port }) console.log(`[server] listening on http://localhost:${port}`) diff --git a/apps/server/src/temporal/activities.ts b/apps/server/src/temporal/activities.ts index e378c59..b9b42b8 100644 --- a/apps/server/src/temporal/activities.ts +++ b/apps/server/src/temporal/activities.ts @@ -276,11 +276,17 @@ export async function implementTicket(ticketId: string): Promise { goalContext: input.goalContext, task: `${input.task}\n\n${CONTRACT}`, budgetCentsRemaining: input.budgetCentsRemaining, + outputSchema: VERDICT_SCHEMA, }) const { passed, summary } = parseVerdict(result.summary) return { passed, summary, costCents: result.costCents, stoppedReason: result.stoppedReason } diff --git a/packages/agents/src/backends/api.ts b/packages/agents/src/backends/api.ts index b9d201d..c11c5c7 100644 --- a/packages/agents/src/backends/api.ts +++ b/packages/agents/src/backends/api.ts @@ -21,6 +21,15 @@ export class ApiBackend implements WorkerBackend { thinking: { type: "adaptive" }, system: buildSystemPrompt(input), messages: [{ role: "user", content: input.task }], + // Structured outputs (ENG-009): constrain the model to schema-conforming JSON when a schema + // is supplied, so callers parse guaranteed-valid JSON instead of best-effort extraction. + ...(input.outputSchema + ? { + output_config: { + format: { type: "json_schema" as const, schema: input.outputSchema }, + }, + } + : {}), }, { signal }, ) diff --git a/packages/agents/src/backends/cli.test.ts b/packages/agents/src/backends/cli.test.ts index be8c142..eb61865 100644 --- a/packages/agents/src/backends/cli.test.ts +++ b/packages/agents/src/backends/cli.test.ts @@ -3,7 +3,7 @@ import { chmod, mkdir, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, beforeEach, describe, expect, it } from "vitest" -import { CliBackend, createSandbox, resolveWorkspaceRoot } from "./cli" +import { CliBackend, createSandbox, resolveWorkspaceRoot, sandboxEnv } from "./cli" describe("resolveWorkspaceRoot", () => { const prev = process.env.AGENT_WORKSPACE_DIR @@ -42,6 +42,41 @@ describe("createSandbox", () => { }) }) +describe("sandboxEnv", () => { + it("forwards benign vars but withholds host secrets", () => { + const env = sandboxEnv({ + PATH: "/usr/bin", + HOME: "/home/agent", + ANTHROPIC_API_KEY: "sk-secret", + GITHUB_TOKEN: "ghp-secret", + DATABASE_URL: "postgres://secret", + }) + expect(env.PATH).toBe("/usr/bin") + expect(env.HOME).toBe("/home/agent") + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + expect(env.GITHUB_TOKEN).toBeUndefined() + expect(env.DATABASE_URL).toBeUndefined() + }) + + it("supports opt-in passthrough but never leaks unlisted vars", () => { + const env = sandboxEnv({ + PATH: "/usr/bin", + AGENT_SANDBOX_ENV_PASSTHROUGH: "FOO, BAR", + FOO: "1", + BAR: "2", + ANTHROPIC_API_KEY: "sk-secret", + }) + expect(env.FOO).toBe("1") + expect(env.BAR).toBe("2") + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + }) + + it("omits allow-listed vars that are absent from the source", () => { + const env = sandboxEnv({ PATH: "/usr/bin" }) + expect("HOME" in env).toBe(false) + }) +}) + describe("CliBackend sandbox enforcement", () => { let root: string let bin: string diff --git a/packages/agents/src/backends/cli.ts b/packages/agents/src/backends/cli.ts index e9c22f9..398b142 100644 --- a/packages/agents/src/backends/cli.ts +++ b/packages/agents/src/backends/cli.ts @@ -40,6 +40,52 @@ export async function createSandbox(root: string, role: string): Promise return mkdtemp(join(root, `${role}-`)) } +/** + * Env vars forwarded into the agent sandbox (ENG-005). `claude -p` is the full Claude Code agent with + * bash + filesystem tools, so the child must NOT inherit the host environment — secrets like + * `ANTHROPIC_API_KEY`, `GITHUB_TOKEN`, and `DATABASE_URL` would otherwise be readable from the agent's + * shell. Only this benign allow-list is passed; the CLI authenticates via its own login under `HOME`. + */ +const SANDBOX_ENV_ALLOWLIST = [ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TERM", + "TMPDIR", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", +] as const + +/** + * Build the scrubbed environment for a sandboxed agent run: the allow-list above, plus any names + * explicitly opted in via `AGENT_SANDBOX_ENV_PASSTHROUGH` (comma-separated). Everything else — notably + * credentials — is withheld. Pure and side-effect-free for testability. + */ +export function sandboxEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + for (const key of SANDBOX_ENV_ALLOWLIST) { + const value = source[key] + if (value !== undefined) env[key] = value + } + const extra = source.AGENT_SANDBOX_ENV_PASSTHROUGH + if (extra) { + for (const key of extra + .split(",") + .map((s) => s.trim()) + .filter(Boolean)) { + const value = source[key] + if (value !== undefined) env[key] = value + } + } + return env +} + export interface CliBackendOptions { bin?: string workspaceRoot?: string @@ -110,7 +156,8 @@ export class CliBackend implements WorkerBackend { private exec(args: string[], stdin: string, cwd: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { - const child = spawn(this.bin, args, { cwd, signal }) + // Scrubbed env (ENG-005): the agent's tools never see host secrets. + const child = spawn(this.bin, args, { cwd, signal, env: sandboxEnv() }) let stdout = "" let stderr = "" child.stdout.on("data", (chunk) => { diff --git a/packages/agents/src/decompose.ts b/packages/agents/src/decompose.ts index 88dcbb9..f23dfab 100644 --- a/packages/agents/src/decompose.ts +++ b/packages/agents/src/decompose.ts @@ -1,6 +1,7 @@ import type { RoleId, StoppedReason } from "@eng/core" import { ClaudeWorker } from "./claude-worker" import { extractJson } from "./propose" +import { TICKETS_SCHEMA } from "./schemas" /** A ticket proposed by the Lead Engineer when decomposing an epic. */ export interface ProposedTicket { @@ -51,6 +52,7 @@ export async function proposeTickets(input: ProposeTicketsInput): Promise { + it("PROPOSAL_SCHEMA requires summary + files and forbids extra props", () => { + expect(PROPOSAL_SCHEMA.type).toBe("object") + expect(PROPOSAL_SCHEMA.additionalProperties).toBe(false) + expect(PROPOSAL_SCHEMA.required).toEqual(["summary", "files"]) + }) + + it("TICKETS_SCHEMA constrains assigneeRole to the known roles", () => { + const props = TICKETS_SCHEMA.properties as { + tickets: { items: { properties: { assigneeRole: { enum: string[] } } } } + } + const role = props.tickets.items.properties.assigneeRole + expect(role.enum).toContain("staff_engineer") + expect(role.enum).toContain("pm") + expect(role.enum).toContain("qa_test") + }) + + it("VERDICT_SCHEMA requires passed + summary", () => { + expect(VERDICT_SCHEMA.type).toBe("object") + expect(VERDICT_SCHEMA.required).toEqual(["passed", "summary"]) + }) +}) diff --git a/packages/agents/src/schemas.ts b/packages/agents/src/schemas.ts new file mode 100644 index 0000000..f61300f --- /dev/null +++ b/packages/agents/src/schemas.ts @@ -0,0 +1,64 @@ +import { ROLE_IDS } from "@eng/core" + +/** + * JSON Schemas for agent structured outputs (ENG-009). Hand-written (rather than zod-derived) to stay + * within the API's structured-output constraints — every object sets `additionalProperties: false` + * and lists `required`, and no unsupported keywords (min/max length, etc.) are used. Passed to the + * Worker as `outputSchema`; the API backend enforces them, the CLI backend falls back to parsing. + */ + +/** `{ summary, files: [{ path, content }] }` — the coding agent's file changes. */ +export const PROPOSAL_SCHEMA: Record = { + type: "object", + additionalProperties: false, + required: ["summary", "files"], + properties: { + summary: { type: "string" }, + files: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["path", "content"], + properties: { + path: { type: "string" }, + content: { type: "string" }, + }, + }, + }, + }, +} + +/** `{ tickets: [{ title, description, acceptanceCriteria, assigneeRole }] }` — epic decomposition. */ +export const TICKETS_SCHEMA: Record = { + type: "object", + additionalProperties: false, + required: ["tickets"], + properties: { + tickets: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["title", "description", "acceptanceCriteria", "assigneeRole"], + properties: { + title: { type: "string" }, + description: { type: "string" }, + acceptanceCriteria: { type: "array", items: { type: "string" } }, + assigneeRole: { type: "string", enum: [...ROLE_IDS] }, + }, + }, + }, + }, +} + +/** `{ passed, summary }` — the QA verdict. */ +export const VERDICT_SCHEMA: Record = { + type: "object", + additionalProperties: false, + required: ["passed", "summary"], + properties: { + passed: { type: "boolean" }, + summary: { type: "string" }, + }, +} diff --git a/packages/core/src/worker.ts b/packages/core/src/worker.ts index 70a7ca3..8491176 100644 --- a/packages/core/src/worker.ts +++ b/packages/core/src/worker.ts @@ -16,6 +16,12 @@ export interface WorkerInput { task: string /** Remaining budget; the orchestrator stops the session if this is exhausted (invariant #3). */ budgetCentsRemaining: number + /** + * Optional JSON Schema for the response (ENG-009). Runtimes that support structured outputs (the + * API backend) constrain the model to emit schema-conforming JSON; runtimes that don't (the CLI + * backend) ignore it and fall back to prompt-contract parsing. + */ + outputSchema?: Record } export interface ToolCallRecord { diff --git a/project/ENG-004-api-auth-and-approval-identity.md b/project/ENG-004-api-auth-and-approval-identity.md index a5d7104..a09b53e 100644 --- a/project/ENG-004-api-auth-and-approval-identity.md +++ b/project/ENG-004-api-auth-and-approval-identity.md @@ -1,11 +1,24 @@ # ENG-004 — API authentication + approval identity -- **Status:** backlog +- **Status:** done - **Priority:** P0 (Critical — do before any live/shared deployment) - **Stage:** architecture - **Assignee role:** lead_system_design (with lead_architect sign-off) - **Area:** apps/server +> **Outcome (Wave 1):** `apps/server/src/auth.ts` adds bearer-token auth — `authMiddleware` on +> `/api/*` requires `Authorization: Bearer ` on mutating (non-GET) requests +> (timing-safe compare; GET read views stay open), resolving the principal from `X-Actor` (default +> `operator`). The approval endpoints now append an `approval_decided` audit event stamped with the +> authenticated principal (`payload.by`), so the append-only log records *who* released each gate. +> When `API_AUTH_TOKEN` is unset the server runs open "dev mode" and warns loudly at startup +> (`warnIfAuthDisabled`) — set the token to enforce. 9 auth unit tests (pure `checkAuth` + middleware +> via `app.request`). Typecheck + lint + 73 tests green. +> +> *Follow-up (noted):* require-auth-by-default and protecting read views are a hardening decision for +> the operator; first-class persisted approval records land in ENG-006 (where `decidedBy` moves onto +> the approval entity). + ## Problem The HTTP API has no authentication or authorization. Anyone who can reach the server can approve diff --git a/project/ENG-005-scrub-cli-sandbox-env.md b/project/ENG-005-scrub-cli-sandbox-env.md index 4c46c34..8d7538c 100644 --- a/project/ENG-005-scrub-cli-sandbox-env.md +++ b/project/ENG-005-scrub-cli-sandbox-env.md @@ -1,11 +1,18 @@ # ENG-005 — Scrub the environment for CLI agent sandboxes -- **Status:** backlog +- **Status:** done - **Priority:** P1 (High — security hardening) - **Stage:** implementation - **Assignee role:** staff_engineer (security review by lead_system_design) - **Area:** packages/agents +> **Outcome (Wave 1):** `CliBackend` now spawns `claude -p` with a scrubbed, allow-listed env +> (`sandboxEnv()` in `backends/cli.ts`) instead of inheriting the host environment — `ANTHROPIC_API_KEY`, +> `GITHUB_TOKEN`, `DATABASE_URL` and everything else not on the benign allow-list (PATH/HOME/XDG/locale/…) +> are withheld; the CLI still authenticates via its own login under `HOME`. Opt-in widening via +> `AGENT_SANDBOX_ENV_PASSTHROUGH`. Unit tests assert secrets are absent and passthrough works. +> *Deferred to ENG-013:* the PM-agent scoped-token exception. + ## Problem The CLI backend confines an agent's *cwd* to a throwaway sandbox, but it does not scrub the *process diff --git a/project/ENG-009-structured-outputs-for-agents.md b/project/ENG-009-structured-outputs-for-agents.md index ae132cb..1cf4eaa 100644 --- a/project/ENG-009-structured-outputs-for-agents.md +++ b/project/ENG-009-structured-outputs-for-agents.md @@ -1,11 +1,20 @@ # ENG-009 — Use structured outputs for agent response parsing -- **Status:** backlog +- **Status:** done - **Priority:** P1 (High) - **Stage:** implementation - **Assignee role:** staff_engineer - **Area:** packages/agents +> **Outcome (Wave 1):** `WorkerInput` gained an optional `outputSchema`; the **API backend** now emits +> `output_config: { format: { type: "json_schema", schema } }` (SDK 0.105) so responses are +> schema-constrained JSON. Hand-written JSON Schemas (`schemas.ts`: `PROPOSAL_SCHEMA` / `TICKETS_SCHEMA` +> / `VERDICT_SCHEMA`, within the API's structured-output constraints) are passed by +> `proposeFileChanges` / `proposeTickets` / `assess`. The **CLI backend** ignores the schema and keeps +> the prose-parse fallback. `ProposedChanges.parsed` distinguishes a parse failure from an empty change +> set, and `implementTicket` now audits "agent output could not be parsed" vs "no file changes +> proposed". Typecheck green; schema unit tests added; 35 agents+server tests pass. + ## Problem The agent helpers (`proposeFileChanges`, `proposeTickets`, `assess`) rely on prompting the model to diff --git a/project/ROADMAP.md b/project/ROADMAP.md index 553ad5e..6a0d4d3 100644 --- a/project/ROADMAP.md +++ b/project/ROADMAP.md @@ -26,10 +26,12 @@ high-leverage enablers forward even when labelled P1/P2. (SurrealDB — no-go on wholesale replacement; one item awaits the lead: relax the OSI-only license bar? recommended *no* for now). -### Wave 1 — Safety + enablers (independent, high-leverage) -- **ENG-004** — API auth + approval identity (P0 security gate before any live/shared run). -- **ENG-009** — Structured outputs (enabler for ENG-001 quality, ENG-014, ENG-016). -- **ENG-005** — Scrub the CLI sandbox env (security; prerequisite for the repo-context chain). +### Wave 1 — Safety + enablers (independent, high-leverage) — ✅ complete (2026-06-27) +- **ENG-004** ✅ — API auth + approval identity (bearer token on mutating `/api/*`; principal stamped + on `approval_decided` audit events; dev-mode warn when unset). +- **ENG-009** ✅ — Structured outputs (API backend emits `output_config.format`; hand-written schemas; + parse-failure vs empty-set audited distinctly). +- **ENG-005** ✅ — Scrub the CLI sandbox env (allow-listed `sandboxEnv()`; host secrets withheld). ### Wave 2 — Repo-context critical path - **ENG-013** — Repo provisioning (PM `git`/`gh`) + two-root workspace. Needs ENG-005.