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
55 changes: 55 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ import {
getRepositoryAiKeyStatus,
upsertRepositoryAiKey,
deleteRepositoryAiKey,
getGlobalAgentFrozenState,
setGlobalAgentFrozen,
} from "../db/repositories";
import { pruneExpiredRecords, RETENTION_POLICY } from "../db/retention";
import {
Expand Down Expand Up @@ -769,6 +771,12 @@ const commandFeedbackSchema = z
})
.strict();

const killSwitchUpdateSchema = z
.object({
frozen: z.boolean(),
})
.strict();

const digestSubscriptionSchema = z
.object({
email: z.string().email().max(320),
Expand Down Expand Up @@ -1332,6 +1340,53 @@ export function createApp() {
return c.json(await buildOperatorDashboardPayload(c.env));
});

// Global agent kill-switch (#2359): the write side (setGlobalAgentFrozen) previously had zero callers — the
// only way to flip it was raw SQL. isGlobalAgentFrozen's fail-open read is right for the enforcement hot path,
// but wrong here: getGlobalAgentFrozenState throws instead, so a read failure surfaces as a clear error rather
// than a falsely reassuring "unfrozen".
app.get("/v1/app/kill-switch", async (c) => {
const forbidden = await requireAppRole(c, ["operator"]);
if (forbidden) return forbidden;
try {
const state = await getGlobalAgentFrozenState(c.env);
return c.json({ ...state, generatedAt: nowIso() });
} catch (error) {
return c.json({ error: "kill_switch_read_failed", message: errorMessage(error) }, 503);
}
});

app.post("/v1/app/kill-switch", async (c) => {
const forbidden = await requireAppRole(c, ["operator"]);
if (forbidden) return forbidden;
const identity = await authenticateRequestIdentity(c);
/* v8 ignore next -- requireAppRole already rejects an unauthenticated caller before this handler runs. */
if (!identity) return c.json({ error: "unauthorized" }, 401);
const body = await c.req.json().catch(() => null);
const parsed = killSwitchUpdateSchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_kill_switch_update", issues: parsed.error.issues }, 400);
const actorLogin = identity.actor;
await setGlobalAgentFrozen(c.env, parsed.data.frozen, actorLogin);
// Read-after-write verification (#2359): confirm the write actually landed before telling the caller it
// succeeded, rather than trusting the INSERT/UPDATE call not to have silently no-opped under a degraded D1.
let verified: { frozen: boolean; updatedAt: string | null; updatedBy: string | null };
try {
verified = await getGlobalAgentFrozenState(c.env);
} catch (error) {
return c.json({ error: "kill_switch_verify_failed", message: errorMessage(error) }, 503);
}
if (verified.frozen !== parsed.data.frozen) {
return c.json({ error: "kill_switch_write_unconfirmed", requested: parsed.data.frozen, observed: verified.frozen }, 502);
}
await recordAuditEvent(c.env, {
eventType: "operator.kill_switch_set",
actor: actorLogin,
targetKey: "global_agent_controls#singleton",
outcome: "completed",
metadata: { frozen: verified.frozen, identityKind: identity.kind },
});
return c.json({ ok: true, ...verified });
});

app.get("/v1/app/notification-model", async (c) => {
const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]);
if (forbidden) return forbidden;
Expand Down
15 changes: 15 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2110,6 +2110,21 @@ export async function setGlobalAgentFrozen(env: Env, frozen: boolean, updatedBy?
.run();
}

/** Strict (non-fail-open) read of the kill-switch row, for the operator route's read-after-write verification
* (#2359) and for surfacing current state. Unlike {@link isGlobalAgentFrozen} — deliberately fail-open on the
* enforcement hot path so a D1 hiccup never silently freezes the fleet — this THROWS on a driver error or a
* missing singleton row, because here a swallowed error must surface as "could not verify", never be silently
* reported as "unfrozen". */
export async function getGlobalAgentFrozenState(env: Env): Promise<{ frozen: boolean; updatedAt: string | null; updatedBy: string | null }> {
const row = await env.DB.prepare("SELECT frozen, updated_at, updated_by FROM global_agent_controls WHERE id = 'singleton'").first<{
frozen: number;
updated_at: string | null;
updated_by: string | null;
}>();
if (!row) throw new Error("global_agent_controls has no singleton row — re-run migrations or re-seed the row");
return { frozen: row.frozen === 1, updatedAt: row.updated_at, updatedBy: row.updated_by };
}

export async function recordAuditEvent(env: Env, event: AuditEventRecord): Promise<void> {
const db = getDb(env.DB);
await db.insert(auditEvents).values({
Expand Down
145 changes: 145 additions & 0 deletions test/unit/routes-kill-switch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("../../src/db/repositories", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../src/db/repositories")>();
return {
...actual,
getGlobalAgentFrozenState: vi.fn(actual.getGlobalAgentFrozenState),
setGlobalAgentFrozen: vi.fn(actual.setGlobalAgentFrozen),
};
});

import { createApp } from "../../src/api/routes";
import { createSessionForGitHubUser } from "../../src/auth/security";
import { getGlobalAgentFrozenState, setGlobalAgentFrozen } from "../../src/db/repositories";
import { createTestEnv } from "../helpers/d1";

// #2359: setGlobalAgentFrozen previously had zero callers anywhere in src/ — the only way to flip the DB-backed
// global kill-switch was raw SQL. These tests cover the new operator-only route pair that makes it operable.

function apiHeaders(env: Env): Record<string, string> {
return { authorization: `Bearer ${env.GITTENSORY_API_TOKEN}`, "content-type": "application/json" };
}

async function auditRows(env: Env): Promise<Array<{ actor: string; outcome: string; metadata_json: string }>> {
const result = (await env.DB.prepare("select actor, outcome, metadata_json from audit_events where event_type = 'operator.kill_switch_set' order by created_at desc").all()) as {
results: Array<{ actor: string; outcome: string; metadata_json: string }>;
};
return result.results;
}

describe("kill-switch operator route (#2359)", () => {
beforeEach(() => {
vi.mocked(getGlobalAgentFrozenState).mockClear();
vi.mocked(setGlobalAgentFrozen).mockClear();
});

it("GET returns the seeded-default unfrozen state for a trusted static token", async () => {
const app = createApp();
const env = createTestEnv();
const res = await app.request("/v1/app/kill-switch", { headers: apiHeaders(env) }, env);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({ frozen: false, updatedBy: null });
});

it("GET is forbidden for an authenticated session without the operator role", async () => {
const app = createApp();
const env = createTestEnv();
const { token } = await createSessionForGitHubUser(env, { login: "not-an-operator", id: 501 });
const res = await app.request("/v1/app/kill-switch", { headers: { cookie: `gittensory_session=${token}` } }, env);
expect(res.status).toBe(403);
});

it("GET is unauthorized with no identity at all", async () => {
const app = createApp();
const env = createTestEnv();
const res = await app.request("/v1/app/kill-switch", {}, env);
expect(res.status).toBe(401);
});

it("GET surfaces a clear 503 (never a falsely reassuring unfrozen) when the singleton row is missing", async () => {
const app = createApp();
const env = createTestEnv();
await env.DB.prepare("DELETE FROM global_agent_controls WHERE id = 'singleton'").run();
const res = await app.request("/v1/app/kill-switch", { headers: apiHeaders(env) }, env);
expect(res.status).toBe(503);
await expect(res.json()).resolves.toMatchObject({ error: "kill_switch_read_failed" });
});

it("POST freezes and unfreezes the fleet for an operator session, verifying the write and auditing it", async () => {
const app = createApp();
const env = createTestEnv();
const { token } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 1 });
const headers = { cookie: `gittensory_session=${token}`, "content-type": "application/json" };

const freeze = await app.request("/v1/app/kill-switch", { method: "POST", headers, body: JSON.stringify({ frozen: true }) }, env);
expect(freeze.status).toBe(200);
await expect(freeze.json()).resolves.toMatchObject({ ok: true, frozen: true, updatedBy: "jsonbored" });

const readBack = await app.request("/v1/app/kill-switch", { headers: apiHeaders(env) }, env);
await expect(readBack.json()).resolves.toMatchObject({ frozen: true });

const unfreeze = await app.request("/v1/app/kill-switch", { method: "POST", headers, body: JSON.stringify({ frozen: false }) }, env);
expect(unfreeze.status).toBe(200);
await expect(unfreeze.json()).resolves.toMatchObject({ ok: true, frozen: false, updatedBy: "jsonbored" });

const audits = await auditRows(env);
expect(audits).toHaveLength(2);
expect(audits.map((row) => JSON.parse(row.metadata_json).frozen)).toEqual([false, true]);
expect(audits.every((row) => row.actor === "jsonbored" && row.outcome === "completed")).toBe(true);
});

it("POST rejects a schema-invalid body instead of silently coercing it", async () => {
const app = createApp();
const env = createTestEnv();
const { token } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 1 });
const headers = { cookie: `gittensory_session=${token}`, "content-type": "application/json" };
const res = await app.request("/v1/app/kill-switch", { method: "POST", headers, body: JSON.stringify({ frozen: "yes" }) }, env);
expect(res.status).toBe(400);
await expect(res.json()).resolves.toMatchObject({ error: "invalid_kill_switch_update" });
expect(setGlobalAgentFrozen).not.toHaveBeenCalled();
});

it("POST rejects a body that isn't valid JSON at all", async () => {
const app = createApp();
const env = createTestEnv();
const res = await app.request("/v1/app/kill-switch", { method: "POST", headers: apiHeaders(env), body: "{" }, env);
expect(res.status).toBe(400);
await expect(res.json()).resolves.toMatchObject({ error: "invalid_kill_switch_update" });
expect(setGlobalAgentFrozen).not.toHaveBeenCalled();
});

it("POST is forbidden for a non-operator session and unauthorized with no identity", async () => {
const app = createApp();
const env = createTestEnv();
const { token } = await createSessionForGitHubUser(env, { login: "not-an-operator", id: 501 });
const forbidden = await app.request(
"/v1/app/kill-switch",
{ method: "POST", headers: { cookie: `gittensory_session=${token}`, "content-type": "application/json" }, body: JSON.stringify({ frozen: true }) },
env,
);
expect(forbidden.status).toBe(403);
const unauthorized = await app.request("/v1/app/kill-switch", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ frozen: true }) }, env);
expect(unauthorized.status).toBe(401);
expect(setGlobalAgentFrozen).not.toHaveBeenCalled();
});

it("POST surfaces a 503 (not a false success) when the post-write verification read fails", async () => {
const app = createApp();
const env = createTestEnv();
vi.mocked(getGlobalAgentFrozenState).mockRejectedValueOnce(new Error("D1 hiccup"));
const res = await app.request("/v1/app/kill-switch", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ frozen: true }) }, env);
expect(res.status).toBe(503);
await expect(res.json()).resolves.toMatchObject({ error: "kill_switch_verify_failed" });
expect(setGlobalAgentFrozen).toHaveBeenCalledTimes(1);
});

it("POST surfaces a 502 (not a false success) when the read-after-write observes a value that doesn't match the write", async () => {
const app = createApp();
const env = createTestEnv();
vi.mocked(getGlobalAgentFrozenState).mockResolvedValueOnce({ frozen: false, updatedAt: null, updatedBy: null });
const res = await app.request("/v1/app/kill-switch", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ frozen: true }) }, env);
expect(res.status).toBe(502);
await expect(res.json()).resolves.toMatchObject({ error: "kill_switch_write_unconfirmed", requested: true, observed: false });
});
});
Loading