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
27 changes: 24 additions & 3 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,12 +17,16 @@ import {
*/
const persistence = persistenceFromEnv()

export const app = new Hono()
export const app = new Hono<AppEnv>()

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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 })
})
70 changes: 70 additions & 0 deletions apps/server/src/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<AppEnv>

beforeEach(() => {
app = new Hono<AppEnv>()
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" })
})
})
78 changes: 78 additions & 0 deletions apps/server/src/auth.ts
Original file line number Diff line number Diff line change
@@ -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 <token>`; 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<AppEnv> {
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.",
)
}
}
2 changes: 2 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
@@ -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}`)

Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/temporal/activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,17 @@ export async function implementTicket(ticketId: string): Promise<PullRequestRef
return null
}
if (!proposed || proposed.files.length === 0) {
// Distinguish a genuine empty change set from a parse failure (ENG-009).
const reason = !proposed
? "agent runtime unavailable"
: proposed.parsed
? "no file changes proposed"
: "agent output could not be parsed"
await persistence.audit.append({
actor: "system",
kind: "delivery_skipped",
ticketId,
payload: { reason: "no file changes proposed" },
payload: { reason },
})
return null
}
Expand Down
2 changes: 2 additions & 0 deletions packages/agents/src/assess.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { RoleId, StoppedReason } from "@eng/core"
import { ClaudeWorker } from "./claude-worker"
import { extractJson } from "./propose"
import { VERDICT_SCHEMA } from "./schemas"

export interface AssessInput {
role: RoleId
Expand Down Expand Up @@ -31,6 +32,7 @@ export async function assess(input: AssessInput): Promise<Assessment> {
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 }
Expand Down
9 changes: 9 additions & 0 deletions packages/agents/src/backends/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
)
Expand Down
37 changes: 36 additions & 1 deletion packages/agents/src/backends/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion packages/agents/src/backends/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,52 @@ export async function createSandbox(root: string, role: string): Promise<string>
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
Expand Down Expand Up @@ -110,7 +156,8 @@ export class CliBackend implements WorkerBackend {

private exec(args: string[], stdin: string, cwd: string, signal?: AbortSignal): Promise<string> {
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) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/agents/src/decompose.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -51,6 +52,7 @@ export async function proposeTickets(input: ProposeTicketsInput): Promise<Propos
goalContext: input.goalContext,
task: `${input.task}\n\n${CONTRACT}`,
budgetCentsRemaining: input.budgetCentsRemaining,
outputSchema: TICKETS_SCHEMA,
})
return {
tickets: parseTickets(result.summary),
Expand Down
Loading