Skip to content
Closed
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
3 changes: 2 additions & 1 deletion app/api/alike/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { requireSession } from "@/lib/auth";
import { audit } from "@/lib/flow";
import { body, handle } from "@/lib/http";
import { body, handle, requireSameOrigin } from "@/lib/http";
import { HttpError, mutate } from "@/lib/store";


Expand All @@ -9,6 +9,7 @@ import { HttpError, mutate } from "@/lib/store";
export async function POST(req: Request) {
return handle(async () => {
const who = requireSession(req);
requireSameOrigin(req); // cross-port CSRF (review M4)
const b = await body<Record<string, string>>(req);
for (const k of ["decision_id", "summary", "recorded_at", "retrieved_in_session", "plan_before", "plan_after"])
if (!b[k]?.trim()) throw new HttpError(400, `Missing ${k}`);
Expand Down
26 changes: 18 additions & 8 deletions app/api/directives/ack/route.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import { agentFrom } from "@/lib/agent-token";
import { agentOrRefuse, assertSessionInGrant } from "@/lib/agent-token";
import { markDelivered } from "@/lib/directives";
import { bus } from "@/lib/events";
import { body, handle } from "@/lib/http";
import { cappedJson, handle } from "@/lib/http";
import { assertLocal } from "@/lib/local-guard";
import { HttpError, mutate, mutateWs, now } from "@/lib/store";

// The watcher confirms it handed a directive to the agent (and is about to wake it).
const ID = /^[\w.:*-]{1,200}$/;

// The watcher confirms it handed a directive to the agent (and is about to wake it). The body is small and capped;
// only an authority event that exists is recorded (markDelivered), so an ack can't grow a workspace's state. With an
// agent token, the session must be the token's own harness and repo.
export async function POST(req: Request) {
return handle(async () => {
assertLocal(req);
const b = await body<{ session?: string; event_id?: string }>(req);
if (!b.session || !b.event_id) throw new HttpError(400, "session and event_id are required");
const agent = agentFrom(req); // an agent token acknowledges in its own workspace
const mark = (s: Parameters<typeof markDelivered>[0]) => markDelivered(s, b.session!, b.event_id!);
const b = await cappedJson<{ session?: unknown; event_id?: unknown }>(req, 4 * 1024);
if (typeof b.session !== "string" || typeof b.event_id !== "string" || !ID.test(b.session) || !ID.test(b.event_id))
throw new HttpError(400, "session and event_id are required");
const session = b.session;
const eventId = b.event_id;
const agent = agentOrRefuse(req); // an agent token acknowledges in its own workspace
const mark = (s: Parameters<typeof markDelivered>[0]) => {
if (agent) assertSessionInGrant(s, agent, session);
return markDelivered(s, session, eventId);
};
const fresh = agent ? await mutateWs(agent.ws, mark) : await mutate(mark);
if (fresh && !agent) bus.emit("delivered", { session: b.session, event_id: b.event_id, at: now() }); // the wire view is the default store's
if (fresh && !agent) bus.emit("delivered", { session, event_id: eventId, at: now() }); // the wire view is the default store's
return { ok: true, fresh };
});
}
11 changes: 7 additions & 4 deletions app/api/directives/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { agentFrom } from "@/lib/agent-token";
import { agentOrRefuse, assertSessionInGrant } from "@/lib/agent-token";
import { pendingFor } from "@/lib/directives";
import { handle } from "@/lib/http";
import { assertLocal } from "@/lib/local-guard";
import { HttpError, readState } from "@/lib/store";

// Pending authority directives for one agent session (polling fallback for the SSE stream). With an agent token,
// the session is looked up in that token's workspace; without one, in the default store, as before.
// the session is looked up in that token's workspace and must be the token's own harness and repo; a token that no
// longer verifies is 401, never the default store. Without one, the default store, as before.
export async function GET(req: Request) {
return handle(async () => {
assertLocal(req);
const session = new URL(req.url).searchParams.get("session");
if (!session) throw new HttpError(400, "session is required");
const agent = agentFrom(req);
return { session, pending: pendingFor(await readState(agent?.ws), session) };
const agent = agentOrRefuse(req);
const s = await readState(agent?.ws);
if (agent) assertSessionInGrant(s, agent, session);
return { session, pending: pendingFor(s, session) };
});
}
19 changes: 14 additions & 5 deletions app/api/directives/stream/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { agentFrom } from "@/lib/agent-token";
import { agentOrRefuse, assertSessionInGrant } from "@/lib/agent-token";
import { describeEvent, pendingFor, recipientsFor } from "@/lib/directives";
import { bus, listening, type AuthorityEvent } from "@/lib/events";
import { assertLocal } from "@/lib/local-guard";
Expand All @@ -14,10 +14,19 @@ export async function GET(req: Request) {
return Response.json({ error: (e as HttpError).message }, { status: 403 });
}
const sid = new URL(req.url).searchParams.get("session") || "*";
// With an agent token, this stream serves that workspace's session only: the wire view (*) is the default store's.
const agent = agentFrom(req);
// With an agent token, this stream serves that workspace's session only (the wire view (*) is the default store's),
// and only a session of the token's own harness and repo. A token that no longer verifies is 401, never the default.
let agent: ReturnType<typeof agentOrRefuse>;
try {
agent = agentOrRefuse(req);
if (agent && sid !== "*") assertSessionInGrant(await readState(agent.ws), agent, sid);
} catch (e) {
return Response.json({ error: (e as HttpError).message }, { status: (e as HttpError).status ?? 500 });
}
if (agent && sid === "*") return Response.json({ error: "An agent token streams one session, not the wire view" }, { status: 400 });
const ws = agent?.ws;
// `listening` is the default store's live view (GET /api/sessions, the wire view): workspace streams stay out of it.
const tracksListening = sid !== "*" && !ws;
const enc = new TextEncoder();
let cleanup = () => {};

Expand All @@ -42,7 +51,7 @@ export async function GET(req: Request) {
send("directive", d);
}
};
if (sid !== "*") listening.set(sid, now());
if (tracksListening) listening.set(sid, now());
await flush();

const onAuthority = async (e: AuthorityEvent) => {
Expand Down Expand Up @@ -73,7 +82,7 @@ export async function GET(req: Request) {
bus.off("authority", onAuthority);
bus.off("delivered", onDelivered);
clearInterval(ping);
if (sid !== "*") listening.delete(sid);
if (tracksListening) listening.delete(sid);
};
req.signal.addEventListener("abort", () => {
cleanup();
Expand Down
3 changes: 2 additions & 1 deletion app/api/mappings/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { connection } from "next/server";
import { requireSession } from "@/lib/auth";
import { body, handle } from "@/lib/http";
import { body, handle, requireSameOrigin } from "@/lib/http";
import { mapperStatus, remapAll } from "@/lib/mapper";
import { activeRegistry } from "@/lib/registry";
import { HttpError, readState } from "@/lib/store";
Expand All @@ -20,6 +20,7 @@ export async function POST(req: Request) {
// B1-4: remapping sends every captured session to the external model, so it is leadership only.
const who = requireSession(req);
if (who.actor !== "leadership") throw new HttpError(403, "Only leadership can remap sessions");
requireSameOrigin(req); // cross-port CSRF (review M4)
const b = await body<{ action?: string }>(req);
if (b.action !== "remap") throw new HttpError(400, "Unknown action");
// B1-5: with the control off or no key, nothing is attempted and the reason is reported.
Expand Down
2 changes: 1 addition & 1 deletion app/api/mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const handler = createMcpHandler(registerAlign, {
function verifyToken(_req: Request, bearerToken?: string): AuthInfo | undefined {
if (!bearerToken) return undefined;
const agent = readAgentToken(bearerToken);
if (agent) return { token: bearerToken, clientId: `agent:${agent.ws}`, scopes: ["goals:read"], extra: { ws: agent.ws } };
if (agent) return { token: bearerToken, clientId: `agent:${agent.ws}`, scopes: ["goals:read"], extra: { ws: agent.ws, harness: agent.harness, repo: agent.repo } };
const expected = process.env.ALIGN_MCP_TOKEN;
if (!expected) return undefined;
const a = Buffer.from(bearerToken);
Expand Down
3 changes: 2 additions & 1 deletion app/api/org/push/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { requireSession } from "@/lib/auth";
import { body, handle } from "@/lib/http";
import { body, handle, requireSameOrigin } from "@/lib/http";
import { pushCompanyGoal } from "@/lib/org";
import { mutate } from "@/lib/store";
import type { OrgDraft } from "@/lib/types";
Expand All @@ -8,6 +8,7 @@ import { emitAuthority } from "@/lib/events";
export async function POST(req: Request) {
return handle(async () => {
const who = requireSession(req);
requireSameOrigin(req); // cross-port CSRF (review M4)
const edits = await body<Partial<OrgDraft>>(req);
const out = await mutate((s) => {
const r = pushCompanyGoal(s, who, edits);
Expand Down
2 changes: 2 additions & 0 deletions app/api/reset/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { requireWsSession } from "@/lib/auth";
import { bodyFor, handle } from "@/lib/http";
import { teamOf } from "@/lib/labels";
import { takeWorkspaceAction } from "@/lib/ratelimit";
import { HttpError, resetState } from "@/lib/store";
import { resetWorkspace } from "@/lib/workspace-setup";

Expand All @@ -15,6 +16,7 @@ export async function POST(req: Request) {
// A custom workspace restarts its own conflict from the saved setup input. Leadership only.
const s = await who.read();
if (who.actor !== "leadership") throw new HttpError(403, `Only ${teamOf(s, "ceo")} can restart this scenario`);
takeWorkspaceAction(who.ws, "restart"); // a Restart loop can't churn the audit archive (review M5)
await resetWorkspace(who.ws, who);
return { ok: true };
}
Expand Down
17 changes: 12 additions & 5 deletions app/api/session/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { bindWs, DISPLAY, login, loginWorkspace, sessionCookie, sessionFrom, wsSessionFrom, type WsSession } from "@/lib/auth";
import { body, handle, requireJson } from "@/lib/http";
import { config } from "@/lib/config";
import { body, handle, requireJson, requireSameOrigin } from "@/lib/http";
import { slotOf } from "@/lib/labels";
import { checkLoginBackoff, clearLoginFailures, clientOf, recordLoginFailure } from "@/lib/ratelimit";
import { parseCustomWs, workspaceExists } from "@/lib/workspace";
import { checkLoginBackoff, clearLoginFailures, clientOf, recordLoginFailure, withSigninSlot } from "@/lib/ratelimit";
import { isRole, parseCustomWs, workspaceExists } from "@/lib/workspace";

/** A custom workspace shows the person named for the role's team at setup. */
async function customName(w: WsSession) {
Expand All @@ -21,6 +22,7 @@ export async function GET(req: Request) {

export async function POST(req: Request) {
return handle(async () => {
requireSameOrigin(req); // cross-port CSRF (review M4)
const b = await body<{ role?: string; passcode?: string; workspace?: unknown }>(req);
// A primitive JSON body (42, "x", null) has no keys: it falls through to Northstar's 400, as before L0.
if (b && typeof b === "object" && "workspace" in b) {
Expand All @@ -30,12 +32,17 @@ export async function POST(req: Request) {
// await, so parallel tries can't all pass the check; success clears it.
requireJson(req);
const ws = parseCustomWs(b.workspace);
const key = ws && workspaceExists(ws) ? `ws:${ws}:${String(b.role).slice(0, 20)}:${clientOf(req)}` : null;
const client = clientOf(req);
// Keys exist only for a real workspace and a real role, so junk ids and junk role strings can't evict entries.
const key = ws && workspaceExists(ws) && isRole(b.role) ? `ws:${ws}:${b.role}:${client}` : null;
if (key) {
checkLoginBackoff(key);
recordLoginFailure(key);
}
const out = await loginWorkspace(b.workspace, b.role, b.passcode);
// Hosted: one client may hold only a couple of scrypt slots at once, correct passcodes included (review M1).
// Mode L has one client key ("local") for everyone on the machine, so it isn't capped there.
const signIn = () => loginWorkspace(b.workspace, b.role, b.passcode);
const out = config().hosted ? await withSigninSlot(client, signIn) : await signIn();
if (key) clearLoginFailures(key);
const res = Response.json({ actor: out.session.actor, name: await customName(out.session), sid: out.session.sid, workspace: out.session.ws });
res.headers.append("set-cookie", sessionCookie(out.token));
Expand Down
4 changes: 3 additions & 1 deletion app/api/sessions/consent/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { requireSession, type Session } from "@/lib/auth";
import { body, handle, requireJson } from "@/lib/http";
import { body, handle, requireJson, requireSameOrigin } from "@/lib/http";
import { captureConsents, HttpError, id, mutate, now, readState, setCaptureConsents, type CaptureHarness } from "@/lib/store";

// Capture consent (B1-1): a server-side record per harness and repo. /api/sessions/events refuses events
Expand Down Expand Up @@ -35,6 +35,7 @@ export async function POST(req: Request) {
return handle(async () => {
const who = requireLeadership(req);
requireJson(req);
requireSameOrigin(req); // cross-port CSRF (review M4)
const { harness, repo } = parse(await body(req));
return mutate((s) => {
const list = captureConsents(s).filter((c) => !(c.harness === harness && c.repo === repo));
Expand All @@ -50,6 +51,7 @@ export async function DELETE(req: Request) {
return handle(async () => {
const who = requireLeadership(req);
requireJson(req);
requireSameOrigin(req); // cross-port CSRF (review M4)
const { harness, repo } = parse(await body(req));
return mutate((s) => {
const before = captureConsents(s);
Expand Down
21 changes: 12 additions & 9 deletions app/api/sessions/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { handle } from "@/lib/http";
import { scheduleMapping } from "@/lib/mapper";
import { markActed } from "@/lib/directives";
import { agentFrom } from "@/lib/agent-token";
import { originIsThisServer } from "@/lib/local-guard";
import { hasCaptureConsent, HttpError, mutate, mutateWs, now, readState } from "@/lib/store";
import type { AgentSession, AppState, SessionOriginKind } from "@/lib/types";

Expand All @@ -13,7 +14,6 @@ const MAX_BODY = 16 * 1024;
const MAX_TEXT = 1000;
const MAX_FILES = 50;
const MAX_PROMPTS = 200;
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);

interface CaptureEvent {
harness?: unknown;
Expand All @@ -32,13 +32,8 @@ interface CaptureEvent {
function assertLocalOrigin(req: Request) {
const origin = req.headers.get("origin");
if (!origin) return;
let host = "";
try {
host = new URL(origin).hostname;
} catch {
// "null" or malformed origins are not local.
}
if (!LOCAL_HOSTS.has(host)) throw new HttpError(403, "Capture events are accepted from this machine only");
// Another localhost port is another site: only this server's own origin (or no Origin, i.e. a script) may post.
if (!originIsThisServer(req, origin)) throw new HttpError(403, "Capture events are accepted from this machine only");
}

async function readCapped(req: Request): Promise<string> {
Expand Down Expand Up @@ -211,7 +206,15 @@ export async function POST(req: Request) {
return { scenario: s.scenario };
};
if (agent) {
await mutateWs(agent.ws, upsert); // custom workspaces are never mapped
// B1-1 inside a workspace: the token is not the consent. The workspace's consent record for this harness and
// repo must still exist (Restart and DELETE /api/workspaces/agent-token remove it), checked under the write lock.
await mutateWs(agent.ws, (s) => {
if (!hasCaptureConsent(s, agent.harness, agent.repo)) throw new HttpError(403, "No capture consent for this harness and repo");
// A token writes only its own repo's sessions: never adopt (and re-label) a session another repo started.
const existing = s.sessions.find((x) => x.id === id);
if (existing && existing.repo !== agent.repo) throw new HttpError(403, `This session belongs to ${existing.repo}, not ${agent.repo}`);
return upsert(s);
}); // custom workspaces are never mapped
return { ok: true, id };
}

Expand Down
Loading