From 2bd4408ea076a7183a19ecd2929d3e315eb0a7d3 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:40:17 -0700 Subject: [PATCH] feat(agent): kill-switch + dry-run + action audit (#776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-0 safety controls the action layer (#778) must consult before any action — the second gate alongside resolveAutonomy. - src/settings/agent-execution.ts (pure, 100% covered): - resolveAgentActionMode -> paused | dry_run | live. Safest wins: a global OR per-repo pause halts everything; else dry-run logs without mutating; else live. agentActionModeExecutes is true only for live. - isGlobalAgentPause: the operator emergency brake via env AGENT_ACTIONS_PAUSED (truthy-string idiom). - buildAgentActionAudit: a structured who/what/why/outcome/mode audit record (eventType agent.action.) so live actions AND dry-run shadows record on one shape — extends the existing audit-event infra. - Per-repo settings agentPaused + agentDryRun (migration 0044, default false), wired like badgeEnabled across types/schema/repositories/openapi /settings-preview yml block, plus the maintainer PUT /settings. - Dashboard: kill-switch + dry-run toggles in the auto-maintain section. Deferred to #778 (needs real actions): the action layer honoring the mode, the dry-run feed into the recommendation-outcome loop, and revert-where- possible. NOTE: migration 0044 follows #773/#774's 0042/0043, ahead of the open #833. --- apps/gittensory-ui/public/openapi.json | 6 ++ .../site/app-panels/maintainer-settings.tsx | 20 ++++++ migrations/0044_agent_kill_switch.sql | 6 ++ src/api/routes.ts | 2 + src/db/repositories.ts | 10 +++ src/db/schema.ts | 2 + src/env.d.ts | 2 + src/openapi/schemas.ts | 2 + src/settings/agent-execution.ts | 59 +++++++++++++++ src/signals/focus-manifest.ts | 4 +- src/types.ts | 6 ++ test/integration/api.test.ts | 6 +- test/unit/agent-execution.test.ts | 71 +++++++++++++++++++ test/unit/data-spine.test.ts | 6 ++ 14 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 migrations/0044_agent_kill_switch.sql create mode 100644 src/settings/agent-execution.ts create mode 100644 test/unit/agent-execution.test.ts diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 537eddf78a..a282fa0de3 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8110,6 +8110,12 @@ "requireApprovals", "mergeMethod" ] + }, + "agentPaused": { + "type": "boolean" + }, + "agentDryRun": { + "type": "boolean" } }, "required": [ diff --git a/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx b/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx index 19e45bc238..92d6d93c5f 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx @@ -42,6 +42,8 @@ type MaintainerSettings = { commandAuthorization: CommandAuthorization; autonomy: Partial>; autoMaintain: { requireApprovals: number; mergeMethod: AutoMergeMethod }; + agentPaused: boolean; + agentDryRun: boolean; }; type AutonomyLevel = "observe" | "suggest" | "propose" | "auto_with_approval" | "auto"; @@ -108,6 +110,8 @@ const EDITABLE_KEYS: Array = [ "commandAuthorization", "autonomy", "autoMaintain", + "agentPaused", + "agentDryRun", ]; type SelectFieldDef = { @@ -307,6 +311,8 @@ export function MaintainerSettings({ reviewability }: { reviewability: Array<{ p ? { ...result.data, autonomy: result.data.autonomy ?? {}, + agentPaused: result.data.agentPaused ?? false, + agentDryRun: result.data.agentDryRun ?? false, autoMaintain: result.data.autoMaintain ?? { requireApprovals: 1, mergeMethod: "squash", @@ -547,6 +553,20 @@ export function MaintainerSettings({ reviewability }: { reviewability: Array<{ p +
+ setField("agentPaused", v)} + /> + setField("agentDryRun", v)} + /> +
diff --git a/migrations/0044_agent_kill_switch.sql b/migrations/0044_agent_kill_switch.sql new file mode 100644 index 0000000000..24c42426bd --- /dev/null +++ b/migrations/0044_agent_kill_switch.sql @@ -0,0 +1,6 @@ +-- Agent-layer safety controls (#776, Wave 2 Phase 0). Per-repo kill-switch + dry-run/shadow mode the action +-- layer (#778) consults via resolveAgentActionMode (alongside the GLOBAL env switch AGENT_ACTIONS_PAUSED). +-- `agent_paused` = take NO action on this repo; `agent_dry_run` = log what would happen without mutating. +-- Both default 0 (off) — additive, existing repos are unaffected. +ALTER TABLE repository_settings ADD COLUMN agent_paused INTEGER NOT NULL DEFAULT 0; +ALTER TABLE repository_settings ADD COLUMN agent_dry_run INTEGER NOT NULL DEFAULT 0; diff --git a/src/api/routes.ts b/src/api/routes.ts index d17297b704..dd8fb9eaae 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -622,6 +622,8 @@ const maintainerSettingsSchema = z includeMaintainerAuthors: z.boolean(), requireLinkedIssue: z.boolean(), badgeEnabled: z.boolean(), + agentPaused: z.boolean(), + agentDryRun: z.boolean(), commandAuthorization: z.object({ default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4).optional(), commands: z.record(z.string().trim().min(1).max(64), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4)).optional(), diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 936f693c43..270d1738f4 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -424,6 +424,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise backfillEnabled: true, privateTrustEnabled: true, badgeEnabled: false, + agentPaused: false, + agentDryRun: false, commandAuthorization: normalizeCommandAuthorizationPolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY).policy, autonomy: {}, autoMaintain: { ...DEFAULT_AUTO_MAINTAIN_POLICY }, @@ -461,6 +463,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise backfillEnabled: row.backfillEnabled, privateTrustEnabled: row.privateTrustEnabled, badgeEnabled: row.badgeEnabled, + agentPaused: row.agentPaused, + agentDryRun: row.agentDryRun, commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson), autonomy: parseAutonomyPolicy(row.autonomyJson), autoMaintain: parseAutoMaintainPolicy(row.autoMaintainJson), @@ -502,6 +506,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), }); diff --git a/src/env.d.ts b/src/env.d.ts index 6c1ebfe65e..c5e11d5bbc 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -30,6 +30,8 @@ declare global { GITHUB_PUBLIC_TOKEN?: string; /** #703: owner-gated global to apply upstream sigmoid time-decay in score previews. Default off. */ SCORING_TIME_DECAY_ENABLED?: string; + /** #776 agent-layer GLOBAL kill-switch — when truthy, halts ALL agent actions across every repo. */ + AGENT_ACTIONS_PAUSED?: string; GITTENSORY_AUTO_FILE_DRIFT_ISSUES?: string; GITTENSORY_DRIFT_ISSUE_REPO?: string; GITTENSORY_DRIFT_ISSUE_TOKEN?: string; diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 2640d7e41d..448d3a90e3 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -585,6 +585,8 @@ export const RepositorySettingsSchema = z .record(z.enum(["review", "request_changes", "approve", "merge", "close", "label"]), z.enum(["observe", "suggest", "propose", "auto_with_approval", "auto"])) .optional(), autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(), + agentPaused: z.boolean().optional(), + agentDryRun: z.boolean().optional(), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), }) diff --git a/src/settings/agent-execution.ts b/src/settings/agent-execution.ts new file mode 100644 index 0000000000..d08a083477 --- /dev/null +++ b/src/settings/agent-execution.ts @@ -0,0 +1,59 @@ +import type { AgentActionClass, AuditEventRecord, AutonomyLevel } from "../types"; + +// Whether the agent actually executes an action, only logs what it WOULD do, or is halted entirely (#776). +export type AgentActionMode = "paused" | "dry_run" | "live"; + +/** + * The GLOBAL kill-switch — an operator emergency brake (env `AGENT_ACTIONS_PAUSED`) that halts ALL agent + * actions across every repo, regardless of per-repo config. Same truthy-string idiom as the other env flags. + */ +export function isGlobalAgentPause(env: { AGENT_ACTIONS_PAUSED?: string | undefined }): boolean { + return /^(1|true|yes|on)$/i.test(env.AGENT_ACTIONS_PAUSED ?? ""); +} + +/** + * THE single gate the action layer (#778) consults before executing any action, alongside resolveAutonomy. + * Precedence (safest wins): a global OR per-repo pause halts everything (`paused`); else a per-repo dry-run + * logs what would happen without executing (`dry_run`); else `live`. Deny-toward-safety. Pure. + */ +export function resolveAgentActionMode(input: { globalPaused: boolean; agentPaused?: boolean | null | undefined; agentDryRun?: boolean | null | undefined }): AgentActionMode { + if (input.globalPaused || input.agentPaused === true) return "paused"; + if (input.agentDryRun === true) return "dry_run"; + return "live"; +} + +/** True only for `live` — the only mode that performs a real GitHub mutation. `paused` does nothing; + * `dry_run` records a shadow action but never mutates. */ +export function agentActionModeExecutes(mode: AgentActionMode): boolean { + return mode === "live"; +} + +/** + * Build the structured audit record for an agent action (who / what / why / outcome / mode). The action + * layer passes this to the existing recordAuditEvent so live actions AND dry-run shadows are both recorded + * on one consistent event shape (#776 "extend the existing audit-event infra"). Pure. + */ +export function buildAgentActionAudit(input: { + actionClass: AgentActionClass; + autonomyLevel: AutonomyLevel; + mode: AgentActionMode; + outcome: AuditEventRecord["outcome"]; + repoFullName: string; + targetKey?: string | null | undefined; + actor?: string | null | undefined; + reason?: string | null | undefined; +}): AuditEventRecord { + return { + eventType: `agent.action.${input.actionClass}`, + actor: input.actor ?? null, + targetKey: input.targetKey ?? input.repoFullName, + outcome: input.outcome, + detail: input.reason ?? null, + metadata: { + repoFullName: input.repoFullName, + actionClass: input.actionClass, + autonomyLevel: input.autonomyLevel, + mode: input.mode, + }, + }; +} diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 9fc0d2e4bb..aa9709d2a2 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -69,6 +69,8 @@ export type FocusManifestSettings = Partial< | "privateTrustEnabled" | "autonomy" | "autoMaintain" + | "agentPaused" + | "agentDryRun" > >; @@ -424,7 +426,7 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) if (gittensorLabel !== null) out.gittensorLabel = gittensorLabel; const publicSurface = normalizeOptionalEnum(r.publicSurface, "settings.publicSurface", ["off", "comment_and_label", "comment_only", "label_only"] as const, warnings); if (publicSurface !== null) out.publicSurface = publicSurface; - for (const key of ["aiReviewByok", "autoLabelEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled"] as const) { + for (const key of ["aiReviewByok", "autoLabelEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) { const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); if (flag !== null) out[key] = flag; } diff --git a/src/types.ts b/src/types.ts index a67ec75751..293b63eac5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -465,6 +465,12 @@ export type RepositorySettings = { /** Auto-maintain policy (#774): merge method + approval count. Always populated by the DB layer with * defaults (squash / 1 approval); optional so existing settings fixtures/callers need not be touched. */ autoMaintain?: AutoMaintainPolicy | undefined; + /** Per-repo agent kill-switch (#776): when true, the action layer takes NO action on this repo (the + * global env switch overrides this too). Default false. */ + agentPaused?: boolean | undefined; + /** Per-repo dry-run/shadow mode (#776): when true, the action layer records what it WOULD do without + * performing any GitHub mutation. Default false. */ + agentDryRun?: boolean | undefined; createdAt?: string | null | undefined; updatedAt?: string | null | undefined; }; diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index b2d1288b58..d65dbd775f 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -2215,8 +2215,8 @@ describe("api routes", () => { { method: "PUT", headers: ownerHeaders, - // #773/#774: the agent-layer config is settable here; the DB layer drops an unknown action class. - body: JSON.stringify({ gateCheckMode: "enabled", slopGateMode: "block", slopGateMinScore: 55, autonomy: { merge: "auto_with_approval", deploy: "auto" }, autoMaintain: { requireApprovals: 2, mergeMethod: "rebase" } }), + // #773/#774/#776: the agent-layer config is settable here; the DB layer drops an unknown action class. + body: JSON.stringify({ gateCheckMode: "enabled", slopGateMode: "block", slopGateMinScore: 55, autonomy: { merge: "auto_with_approval", deploy: "auto" }, autoMaintain: { requireApprovals: 2, mergeMethod: "rebase" }, agentPaused: true, agentDryRun: true }), }, ownerEnv, ); @@ -2227,6 +2227,8 @@ describe("api routes", () => { slopGateMinScore: 55, autonomy: { merge: "auto_with_approval" }, // unknown action class dropped by the DB normalizer autoMaintain: { requireApprovals: 2, mergeMethod: "rebase" }, + agentPaused: true, // #776 kill-switch + agentDryRun: true, }); // requireApprovals is bounded at the API boundary — an out-of-range value is rejected, not silently clamped. const settingsBadApprovals = await app.request( diff --git a/test/unit/agent-execution.test.ts b/test/unit/agent-execution.test.ts new file mode 100644 index 0000000000..79f0857d35 --- /dev/null +++ b/test/unit/agent-execution.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + agentActionModeExecutes, + buildAgentActionAudit, + isGlobalAgentPause, + resolveAgentActionMode, +} from "../../src/settings/agent-execution"; + +describe("resolveAgentActionMode (#776 safety gate)", () => { + it("a global OR per-repo pause halts everything (safest wins)", () => { + expect(resolveAgentActionMode({ globalPaused: true })).toBe("paused"); + expect(resolveAgentActionMode({ globalPaused: true, agentDryRun: true })).toBe("paused"); // pause beats dry-run + expect(resolveAgentActionMode({ globalPaused: false, agentPaused: true })).toBe("paused"); + expect(resolveAgentActionMode({ globalPaused: false, agentPaused: true, agentDryRun: true })).toBe("paused"); + }); + + it("dry-run wins over live when not paused", () => { + expect(resolveAgentActionMode({ globalPaused: false, agentDryRun: true })).toBe("dry_run"); + expect(resolveAgentActionMode({ globalPaused: false, agentPaused: false, agentDryRun: true })).toBe("dry_run"); + }); + + it("defaults to live only when nothing is set", () => { + expect(resolveAgentActionMode({ globalPaused: false })).toBe("live"); + expect(resolveAgentActionMode({ globalPaused: false, agentPaused: false, agentDryRun: false })).toBe("live"); + expect(resolveAgentActionMode({ globalPaused: false, agentPaused: null, agentDryRun: null })).toBe("live"); + }); + + it("only live actually executes", () => { + expect(agentActionModeExecutes("live")).toBe(true); + expect(agentActionModeExecutes("dry_run")).toBe(false); + expect(agentActionModeExecutes("paused")).toBe(false); + }); +}); + +describe("isGlobalAgentPause", () => { + it("recognizes the truthy-string forms and treats everything else as not paused", () => { + for (const v of ["1", "true", "TRUE", "yes", "on"]) expect(isGlobalAgentPause({ AGENT_ACTIONS_PAUSED: v })).toBe(true); + for (const v of ["0", "false", "no", "off", "", "maybe"]) expect(isGlobalAgentPause({ AGENT_ACTIONS_PAUSED: v })).toBe(false); + expect(isGlobalAgentPause({})).toBe(false); + }); +}); + +describe("buildAgentActionAudit", () => { + it("produces a structured who/what/why/outcome/mode audit record", () => { + const audit = buildAgentActionAudit({ + actionClass: "merge", + autonomyLevel: "auto_with_approval", + mode: "dry_run", + outcome: "completed", + repoFullName: "owner/repo", + targetKey: "owner/repo#7", + actor: "gittensory", + reason: "merge-readiness met", + }); + expect(audit).toMatchObject({ + eventType: "agent.action.merge", + actor: "gittensory", + targetKey: "owner/repo#7", + outcome: "completed", + detail: "merge-readiness met", + metadata: { repoFullName: "owner/repo", actionClass: "merge", autonomyLevel: "auto_with_approval", mode: "dry_run" }, + }); + }); + + it("falls back to the repo as the target key and null actor/reason", () => { + const audit = buildAgentActionAudit({ actionClass: "label", autonomyLevel: "auto", mode: "live", outcome: "completed", repoFullName: "owner/repo" }); + expect(audit.targetKey).toBe("owner/repo"); + expect(audit.actor).toBeNull(); + expect(audit.detail).toBeNull(); + }); +}); diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index fe459d6354..bf7460141b 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -273,6 +273,12 @@ describe("data spine repositories", () => { await upsertRepositorySettings(env, { repoFullName: "owner/automaintainrepo", autoMaintain: { requireApprovals: 0, mergeMethod: "merge" } }); expect((await getRepositorySettings(env, "owner/automaintainrepo")).autoMaintain).toEqual({ requireApprovals: 0, mergeMethod: "merge" }); // update persists expect((await getRepositorySettings(env, "owner/defaultpack")).autoMaintain).toEqual({ requireApprovals: 1, mergeMethod: "squash" }); // defaults + // #776 kill-switch + dry-run round-trip (insert + update) and default false. + await upsertRepositorySettings(env, { repoFullName: "owner/saferepo", agentPaused: true, agentDryRun: true }); + expect(await getRepositorySettings(env, "owner/saferepo")).toMatchObject({ agentPaused: true, agentDryRun: true }); + await upsertRepositorySettings(env, { repoFullName: "owner/saferepo", agentPaused: false }); + expect((await getRepositorySettings(env, "owner/saferepo")).agentPaused).toBe(false); // update persists + expect(await getRepositorySettings(env, "owner/defaultpack")).toMatchObject({ agentPaused: false, agentDryRun: false }); // defaults expect(updated.slopAiAdvisory).toBe(false); expect(await getRepoSyncState(env, "missing/repo")).toBeNull(); expect(await getPullRequest(env, "owner/repo", 404)).toBeNull();