From f49ea741314e7520fbfc18fae1a61e5a117eba5f Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:32:20 -0700 Subject: [PATCH 1/2] feat(settings): per-repo override of the global agent-freeze kill-switch global_agent_controls.frozen has no per-repo scoping today -- flipping it affects every repo at once, which caused a real multi-repo incident where live merge/close actions fired for repos meant to stay paused. Adds agentGlobalFreezeOverride (RepositorySettings + .gittensory.yml settings: block, global-default + per-repo-override like every other gate setting): when a repo opts in, it bypasses the DB-backed freeze while every other repo stays frozen. The AGENT_ACTIONS_PAUSED env var and a repo's own agentPaused still always win over the override. Threaded through every real enforcement path: the executor's AgentActionExecutionContext/IssueActionExecutionContext, the shared resolveRepoActionMode helper, and all direct resolveAgentActionMode call sites in processors.ts, agent-approval-queue.ts, contributor-issue-draft.ts, and the MCP automation-state tool. --- .gittensory.yml.example | 7 +++ apps/gittensory-ui/public/openapi.json | 3 + config/examples/gittensory.full.yml | 7 +++ .../0127_agent_global_freeze_override.sql | 14 +++++ .../gittensory-engine/src/focus-manifest.ts | 3 +- .../src/types/manifest-deps-types.ts | 5 ++ src/api/routes.ts | 1 + src/db/repositories.ts | 17 ++++++ src/db/schema.ts | 4 ++ src/github/client.ts | 11 ++-- src/mcp/server.ts | 4 +- src/openapi/schemas.ts | 1 + src/queue/processors.ts | 47 ++++++++------ src/services/agent-action-executor.ts | 12 +++- src/services/agent-approval-queue.ts | 1 + src/services/contributor-issue-draft.ts | 8 ++- src/types.ts | 5 ++ test/unit/agent-action-executor.test.ts | 61 ++++++++++++++++++- test/unit/data-spine.test.ts | 6 ++ test/unit/focus-manifest.test.ts | 6 ++ test/unit/github-client.test.ts | 9 +++ 21 files changed, 197 insertions(+), 35 deletions(-) create mode 100644 migrations/0127_agent_global_freeze_override.sql diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 155867777e..c51523b111 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -668,6 +668,13 @@ settings: # Bool. Default: false. agentDryRun: false + # Per-repo override of the GLOBAL DB-backed agent freeze (the operator kill-switch an operator flips with + # one row, no redeploy): when true, THIS repo's actions execute even while the global freeze is on, so an + # operator can re-activate one repo at a time without lifting the fleet-wide brake. Never overrides the + # AGENT_ACTIONS_PAUSED env var (that hard stop always wins), and agentPaused above on this same repo still + # wins over this too. Bool. Default: false. + agentGlobalFreezeOverride: false + # Four independent label families, none of which gates or silently disables another (#label-decoupling, # #label-scoping): # 1. Context label (`gittensorLabel`, gated by `autoLabelEnabled` above) — the base per-PR marker shown diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index efb1b52ef2..351165c060 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -9317,6 +9317,9 @@ "copycatGateMinScore": { "type": "number", "nullable": true + }, + "agentGlobalFreezeOverride": { + "type": "boolean" } }, "required": [ diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index da732e84eb..519562c94f 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -681,6 +681,13 @@ settings: # Bool. Default: false. agentDryRun: false + # Per-repo override of the GLOBAL DB-backed agent freeze (the operator kill-switch an operator flips with + # one row, no redeploy): when true, THIS repo's actions execute even while the global freeze is on, so an + # operator can re-activate one repo at a time without lifting the fleet-wide brake. Never overrides the + # AGENT_ACTIONS_PAUSED env var (that hard stop always wins), and agentPaused above on this same repo still + # wins over this too. Bool. Default: false. + agentGlobalFreezeOverride: false + # Four independent label families, none of which gates or silently disables another (#label-decoupling, # #label-scoping): # 1. Context label (`gittensorLabel`, gated by `autoLabelEnabled` above) — the base per-PR marker shown diff --git a/migrations/0127_agent_global_freeze_override.sql b/migrations/0127_agent_global_freeze_override.sql new file mode 100644 index 0000000000..d8781429b2 --- /dev/null +++ b/migrations/0127_agent_global_freeze_override.sql @@ -0,0 +1,14 @@ +-- Per-repo override of the DB-backed global agent freeze (#4372, incident follow-up): `global_agent_controls` +-- (migrations/0044-adjacent singleton, see isGlobalAgentFrozen/setGlobalAgentFrozen in src/db/repositories.ts) +-- has no per-repo scoping today -- flipping it affects every repo at once, which is what caused a real +-- multi-repo incident (live merges/closes fired for repos that were meant to stay paused). This column lets an +-- operator keep the global DB kill-switch ON as the safe default while opting ONE repo at a time back into live +-- execution via that repo's `.gittensory.yml` (`settings.agentGlobalFreezeOverride: true`), the same +-- global-default + per-repo-override shape every other gittensory setting already uses. +-- +-- Deliberately does NOT touch `AGENT_ACTIONS_PAUSED` (isGlobalAgentPause): that env-var hard stop is checked +-- independently and remains absolute -- no per-repo setting may ever bypass it. A repo's own `agent_paused = +-- true` also still always wins over this override (the pausing direction stays deny-toward-safety; only the +-- un-pausing direction becomes something a repo can opt into). Default 0 (off) -- additive, every existing repo +-- keeps today's behavior (global frozen ⇒ frozen everywhere) until explicitly opted in. +ALTER TABLE repository_settings ADD COLUMN agent_global_freeze_override INTEGER NOT NULL DEFAULT 0; diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index 8d94069faa..9db3b977ed 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -329,6 +329,7 @@ export type FocusManifestSettings = Partial< | "autoMaintain" | "agentPaused" | "agentDryRun" + | "agentGlobalFreezeOverride" | "commandAuthorization" | "contributorBlacklist" | "blacklistLabel" @@ -1654,7 +1655,7 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) } 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", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "agentPaused", "agentDryRun"] as const) { + for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "agentPaused", "agentDryRun", "agentGlobalFreezeOverride"] as const) { const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); if (flag !== null) out[key] = flag; } diff --git a/packages/gittensory-engine/src/types/manifest-deps-types.ts b/packages/gittensory-engine/src/types/manifest-deps-types.ts index 0195be3fda..8e93b4b061 100644 --- a/packages/gittensory-engine/src/types/manifest-deps-types.ts +++ b/packages/gittensory-engine/src/types/manifest-deps-types.ts @@ -461,6 +461,11 @@ export type RepositorySettings = { /** 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; + /** Per-repo override of the global DB-backed agent freeze (#4372): when true, this repo's actions execute + * even while `global_agent_controls.frozen` is set, so an operator can re-activate one repo at a time + * without lifting the fleet-wide brake. Never overrides the `AGENT_ACTIONS_PAUSED` env var, and + * {@link agentPaused} on this same repo still wins over it. Default false. */ + agentGlobalFreezeOverride?: boolean | undefined; /** Moderation-rules engine (#selfhost-mod-engine): whether the whole layer runs on THIS repo. `"inherit"` * (the DB default) defers to `global_moderation_config.enabled`; `"off"`/`"enabled"` force this repo * regardless of the global default. Always populated by the DB layer; optional so existing settings diff --git a/src/api/routes.ts b/src/api/routes.ts index 6869489a4f..53066a5c8b 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -746,6 +746,7 @@ const maintainerSettingsSchema = z publicQualityMetrics: z.boolean(), agentPaused: z.boolean(), agentDryRun: z.boolean(), + agentGlobalFreezeOverride: z.boolean(), requireFreshRebaseWindowMinutes: z.number().int().positive().nullable(), commandAuthorization: z.object({ default: 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 bc284e3d68..89b0b8ad34 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -540,6 +540,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise publicQualityMetrics: false, agentPaused: false, agentDryRun: false, + agentGlobalFreezeOverride: false, commandAuthorization: normalizeCommandAuthorizationPolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY).policy, contributorBlacklist: [], autonomy: {}, @@ -619,6 +620,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise publicQualityMetrics: row.publicQualityMetrics, agentPaused: row.agentPaused, agentDryRun: row.agentDryRun, + agentGlobalFreezeOverride: row.agentGlobalFreezeOverride, commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson), contributorBlacklist: parseContributorBlacklist(row.contributorBlacklistJson), autonomy: parseAutonomyPolicy(row.autonomyJson), @@ -740,6 +742,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial { } } +/** Per-repo override of the DB-backed global kill-switch (#4372, incident follow-up): lets an operator keep + * `global_agent_controls.frozen` ON as the fleet-wide safe default while opting ONE repo at a time back into + * live execution via that repo's `agentGlobalFreezeOverride` setting — the same global-default + + * per-repo-override shape every other gittensory setting already uses. Deliberately does NOT take the + * `AGENT_ACTIONS_PAUSED` env var into account: callers must still OR this result with {@link isGlobalAgentPause} + * themselves (matching every existing `resolveAgentActionMode({ globalPaused: ... })` call site), so the env + * var stays an absolute, non-overridable hard stop no repo setting can ever bypass. */ +export async function isDbFrozenForRepo(env: Env, agentGlobalFreezeOverride: boolean | null | undefined): Promise { + if (agentGlobalFreezeOverride === true) return false; + return isGlobalAgentFrozen(env); +} + /** Atomic re-gate fan-out dedup (#audit-fanout-dedup): claim the global fan-out slot for this window. The * conditional UPDATE on the singleton matches only when the last fan-out is unset or older than `windowMs`. D1 * serializes writes, so when a BURST of fan-out jobs runs at once (a deploy-restart cron catch-up, or fan-out diff --git a/src/db/schema.ts b/src/db/schema.ts index f97672b901..9d70fdc523 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -116,6 +116,10 @@ export const repositorySettings = sqliteTable("repository_settings", { autoMaintainJson: text("auto_maintain_json").notNull().default("{}"), agentPaused: integer("agent_paused", { mode: "boolean" }).notNull().default(false), agentDryRun: integer("agent_dry_run", { mode: "boolean" }).notNull().default(false), + // Per-repo override of the global DB-backed agent freeze (#4372): when true, THIS repo bypasses + // isGlobalAgentFrozen while the global kill-switch stays frozen for every other repo. Never bypasses the + // AGENT_ACTIONS_PAUSED env var, and agentPaused above still wins over this if both are set. Default false. + agentGlobalFreezeOverride: integer("agent_global_freeze_override", { mode: "boolean" }).notNull().default(false), // Per-contributor open PR/issue caps (#2270, anti-abuse): null = no cap (default). Enforcement lands separately. contributorOpenPrCap: integer("contributor_open_pr_cap"), contributorOpenIssueCap: integer("contributor_open_issue_cap"), diff --git a/src/github/client.ts b/src/github/client.ts index 484bef9622..3b24c4d25a 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -1,5 +1,5 @@ import { Octokit } from "@octokit/core"; -import { isGlobalAgentFrozen, recordAuditEvent } from "../db/repositories"; +import { isDbFrozenForRepo, recordAuditEvent } from "../db/repositories"; import { isGlobalAgentPause, resolveAgentActionMode, type AgentActionMode } from "../settings/agent-execution"; import { incr } from "../selfhost/metrics"; import type { RepositorySettings } from "../types"; @@ -582,12 +582,13 @@ const WRITE_METHODS = new Set(["POST", "PATCH", "PUT", "DELETE"]); /** * Resolve a repo's agent action mode the SAME way the executor does: the env emergency brake OR the DB global - * freeze OR the per-repo pause/dry-run. Call this ONCE per review and thread the result into every surface write - * — it performs one isGlobalAgentFrozen() read, so it must never sit on a per-write hot path. + * freeze (unless THIS repo's `agentGlobalFreezeOverride` opts out of it) OR the per-repo pause/dry-run. Call + * this ONCE per review and thread the result into every surface write — it performs one isDbFrozenForRepo() + * read, so it must never sit on a per-write hot path. */ -export async function resolveRepoActionMode(env: Env, settings: Pick | null | undefined): Promise { +export async function resolveRepoActionMode(env: Env, settings: Pick | null | undefined): Promise { return resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings?.agentGlobalFreezeOverride)), agentPaused: settings?.agentPaused, agentDryRun: settings?.agentDryRun, }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 87a2e2af99..a4c4a3b67a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -37,7 +37,7 @@ import { getPendingAgentAction, getPullRequest, getRepository, - isGlobalAgentFrozen, + isDbFrozenForRepo, getRepoQueueTrendSnapshot, listAgentAuditEvents, listCheckSummaries, @@ -3097,7 +3097,7 @@ export class GittensoryMcp { const autonomy = settings.autonomy; const actingActionClasses = AGENT_ACTION_CLASSES.filter((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass))); const installation = repo?.installationId ? await getInstallation(this.env, repo.installationId) : null; - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(this.env) || (await isGlobalAgentFrozen(this.env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(this.env) || (await isDbFrozenForRepo(this.env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); const permissionReadiness = resolveAgentPermissionReadiness({ autonomy, installationPermissions: installation?.permissions ?? null }); return { summary: `Agent automation for ${fullName}: mode=${mode}, ${actingActionClasses.length} acting class(es), ${pendingActionCount} pending approval(s).`, diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index d9715803ca..cb618ceaf6 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -743,6 +743,7 @@ export const RepositorySettingsSchema = z autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(), agentPaused: z.boolean().optional(), agentDryRun: z.boolean().optional(), + agentGlobalFreezeOverride: z.boolean().optional(), contributorOpenPrCap: z.number().int().positive().max(MAX_CONTRIBUTOR_OPEN_ITEM_CAP).nullable().optional(), contributorOpenIssueCap: z.number().int().positive().max(MAX_CONTRIBUTOR_OPEN_ITEM_CAP).nullable().optional(), contributorCapLabel: z.string().nullable().optional(), diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 3f91c38da8..85978d416b 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -69,7 +69,7 @@ import { recordGateBlockOutcome, getGateBlockOutcome, hasActiveReviewForHeadSha, - isGlobalAgentFrozen, + isDbFrozenForRepo, listReviewSuppressions, markGateOutcomeOverridden, markPullRequestLinkedIssueHardRuleViolated, @@ -1806,7 +1806,7 @@ async function sweepRepoRegate( ) return; const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), // env brake OR DB kill-switch (#audit-§5.2) + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), // env brake OR DB kill-switch (#audit-§5.2) agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -2127,7 +2127,7 @@ async function sweepRepoBacklogConvergence( const settings = await resolveRepositorySettings(env, repoFullName); if (!(isConvergenceRepoAllowed(env, repoFullName) || isAgentConfigured(settings.autonomy))) return; const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -2906,7 +2906,7 @@ async function runAgentMaintenancePlanAndExecute( } if (isNewAccount && resolveAutonomy(settings.autonomy, "review_state_label") === "auto") { const newAccountMode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -3268,6 +3268,7 @@ async function runAgentMaintenancePlanAndExecute( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, + agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, installationPermissions, authorLogin: pr.authorLogin, mergeTrainMode: settings.mergeTrainMode, @@ -3584,6 +3585,7 @@ async function prReadyForReview( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, + agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, installationPermissions: installation?.permissions ?? null, authorLogin: pr.authorLogin, }, @@ -3855,6 +3857,7 @@ async function maybeForceFreshRebase( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, + agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, /* v8 ignore next -- an installed-App PR webhook always carries an installation record; the null is defensive (mirrors runAgentMaintenancePlanAndExecute's own identical merge-time read). */ installationPermissions: installation?.permissions ?? null, authorLogin: pr.authorLogin, @@ -5388,6 +5391,7 @@ async function maybeCloseIssueOverContributorCap( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, + agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, authorLogin, moderationSettings: { moderationGateMode: settings.moderationGateMode, moderationRules: settings.moderationRules, moderationWarningLabel: settings.moderationWarningLabel, moderationBannedLabel: settings.moderationBannedLabel }, }, @@ -5470,6 +5474,7 @@ async function maybeCloseIssueOverContributorCap( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, + agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, authorLogin, moderationSettings: { moderationGateMode: settings.moderationGateMode, moderationRules: settings.moderationRules, moderationWarningLabel: settings.moderationWarningLabel, moderationBannedLabel: settings.moderationBannedLabel }, }, @@ -6293,7 +6298,7 @@ async function processGitHubWebhook( if (await isBelowAccountAgeThreshold(env, installationId, authorLogin, accountAgeThresholdDays)) { if (resolveAutonomy(issueSettings.autonomy, "review_state_label") === "auto") { const newAccountMode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, issueSettings.agentGlobalFreezeOverride)), agentPaused: issueSettings.agentPaused, agentDryRun: issueSettings.agentDryRun, }); @@ -9164,7 +9169,7 @@ async function maybePublishPrPublicSurface( // the cost of a maintainer choosing to ask twice). const alreadyTriggered = await hasAuditEventForHeadSha(env, "github_app.e2e_tests_generation", e2eTargetKey, pr.headSha); if (!alreadyTriggered) { - const e2eMode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + const e2eMode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); if (e2eMode === "live") { await runE2eTestGenerationAndDeliver(env, { repoFullName, @@ -11099,7 +11104,7 @@ async function maybeProcessGateOverrideCommand( // an operator's pause or the DB kill-switch does not stop a maintainer's @gittensory gate-override from // flipping the live Gate check-run to neutral and posting a real confirmation comment. const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -11265,7 +11270,7 @@ async function maybeProcessResolveCommand(env: Env, deliveryId: string, payload: const gate = evaluateGateCheck(advisory, gateCheckPolicy(settings, null, undefined, pr.slopRisk ?? null)); const selection = selectWarningsForResolve(gate.warnings, findingRef); if (selection.reason === "finding_not_found") { await recordAuditEvent(env, { eventType: "github_app.finding_resolved_skipped", actor: req.actor, targetKey, outcome: "completed", detail: selection.reason, metadata: { deliveryId, repoFullName: req.repoFullName, reason: selection.reason } }); await recordGithubProductUsage(env, "finding_resolved_skipped", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "skipped", metadata: { reason: selection.reason } }); return true; } - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); if (mode !== "live") { const skipReason = mode === "dry_run" ? "dry_run" : "agent_paused"; await recordAuditEvent(env, { eventType: "github_app.finding_resolved_skipped", actor: req.actor, targetKey, outcome: "completed", detail: skipReason, metadata: { deliveryId, repoFullName: req.repoFullName, reason: skipReason } }); await recordGithubProductUsage(env, "finding_resolved_skipped", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "skipped", metadata: { reason: skipReason } }); return true; } const reviewManifest = await loadRepoFocusManifest(env, req.repoFullName).catch(() => null); const reviewMemoryEnabled = shouldApplyReviewMemory(env, resolveReviewMemoryManifestToggle(reviewManifest)); @@ -11316,7 +11321,7 @@ async function maybeProcessReviewCommand(env: Env, deliveryId: string, payload: } // Same dry-run/paused gate every other action command respects (pause/resolve/explain/gate-override/ // generate-tests) -- a paused or dry-run repo must not dispatch a live re-review or post a confirmation. - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); if (mode !== "live") { await recordReviewCommandSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, mode === "dry_run" ? "dry_run" : "agent_paused"); return true; @@ -11568,7 +11573,7 @@ async function maybeProcessGenerateTestsCommand(env: Env, deliveryId: string, pa } // Same dry-run/paused gate every other action command respects (mirrors maybeProcessResolveCommand's own // resolveAgentActionMode check) — an agent-paused or dry-run repo gets no generated content posted at all. - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); if (mode !== "live") { const skipReason = mode === "dry_run" ? "dry_run" : "agent_paused"; await recordGenerateTestsSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, skipReason); @@ -11776,7 +11781,7 @@ async function maybeProcessConfigurationCommand( return true; } const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -11899,7 +11904,7 @@ async function maybeProcessPlanCommand( // incurs the AI cost speculatively — mirroring how the reopen-reclose handler skips its write uniformly for // both dry_run and paused, not just paused. const planMode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -12459,7 +12464,7 @@ async function closeDraftDodgeAttemptIfBlocked( // and a dry-run records the would-be close without touching GitHub. const draftMode = resolveAgentActionMode({ globalPaused: - isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -12691,7 +12696,7 @@ async function recloseDisallowedReopenIfNeeded( // the re-close would genuinely reach GitHub on a repo that never authorized any action (#review-audit). if (!isAgentConfigured(reopenSettings.autonomy)) return false; const reopenMode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, reopenSettings.agentGlobalFreezeOverride)), agentPaused: reopenSettings.agentPaused, agentDryRun: reopenSettings.agentDryRun, }); @@ -12874,7 +12879,7 @@ async function closeReviewEvasionSelfCloseIfActive( const targetKey = `${repoFullName}#${pr.number}`; const evasionMode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -13126,7 +13131,7 @@ async function closeReviewEvasionDraftConversionIfActive( const targetKey = `${repoFullName}#${pr.number}`; const evasionMode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -13344,7 +13349,7 @@ async function closeRepeatedDraftCyclingIfDetected( const targetKey = `${repoFullName}#${pr.number}`; const evasionMode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -13591,7 +13596,7 @@ async function maybeThrottleReviewNagPing( if (pingCount <= maxPings) return false; // under threshold — normal command processing proceeds unchanged const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -13673,6 +13678,7 @@ async function maybeThrottleReviewNagPing( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, + agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, installationPermissions: installation?.permissions ?? null, authorLogin: pr.authorLogin, moderationSettings: { moderationGateMode: settings.moderationGateMode, moderationRules: settings.moderationRules, moderationWarningLabel: settings.moderationWarningLabel, moderationBannedLabel: settings.moderationBannedLabel }, @@ -13783,7 +13789,7 @@ async function maybeThrottleMonitoredMentions( if (pingCount <= maxPings) return false; const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -13858,6 +13864,7 @@ async function maybeThrottleMonitoredMentions( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, + agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, installationPermissions: installation?.permissions ?? null, authorLogin: pr.authorLogin, moderationSettings: { moderationGateMode: settings.moderationGateMode, moderationRules: settings.moderationRules, moderationWarningLabel: settings.moderationWarningLabel, moderationBannedLabel: settings.moderationBannedLabel }, @@ -14105,7 +14112,7 @@ async function maybeProcessGittensoryMentionCommand( // Respect pause/dry-run/global-freeze like every other agent-driven write in this file (#2258) — the answer // card is a live public comment post, same as gate-override's confirmation comment. const mentionMode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index 733092628f..71fae8e225 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -5,7 +5,7 @@ import { getGlobalContributorBlacklist, getGlobalModerationConfig, insertNotificationDeliveryIfAbsent, - isGlobalAgentFrozen, + isDbFrozenForRepo, listOtherOpenPullRequests, listRepoPullRequestFilePaths, markPullRequestApproved, @@ -157,6 +157,9 @@ export type AgentActionExecutionContext = { autonomy: AutonomyPolicy | null | undefined; agentPaused?: boolean | undefined; agentDryRun?: boolean | undefined; + // Per-repo override of the DB-backed global freeze (#4372) -- resolved by the CALLER from + // RepositorySettings, same "the executor has no settings access" shape as agentPaused/agentDryRun above. + agentGlobalFreezeOverride?: boolean | undefined; installationPermissions: Record | null | undefined; // PR author login — surfaced as the "Submitter" in the per-repo Discord action notification. authorLogin?: string | null | undefined; @@ -253,7 +256,7 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE const targetKey = `${ctx.repoFullName}#${ctx.pullNumber}`; // globalPaused folds the env-var brake AND the DB-backed kill-switch (#audit-§5.2) so an operator can halt the // fleet instantly via one DB row, without a redeploy. - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, ctx.agentGlobalFreezeOverride)), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun }); for (const action of planned) { // #label-scoping: a `label` action may be authorized by a class OTHER than `label` itself (an anti-abuse @@ -731,6 +734,9 @@ export type IssueActionExecutionContext = { autonomy: AutonomyPolicy | null | undefined; agentPaused?: boolean | undefined; agentDryRun?: boolean | undefined; + // Per-repo override of the DB-backed global freeze (#4372) -- resolved by the CALLER from + // RepositorySettings, same "the executor has no settings access" shape as agentPaused/agentDryRun above. + agentGlobalFreezeOverride?: boolean | undefined; // Issue author login -- needed for the moderation-rules engine's violation ledger (#selfhost-mod-engine). authorLogin?: string | null | undefined; moderationSettings?: ModerationContextSettings | undefined; @@ -753,7 +759,7 @@ export type IssueActionExecutionContext = { export async function executeIssueMaintenanceActions(env: Env, ctx: IssueActionExecutionContext, planned: PlannedAgentAction[]): Promise { const outcomes: AgentActionOutcome[] = []; const targetKey = `${ctx.repoFullName}#${ctx.issueNumber}`; - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, ctx.agentGlobalFreezeOverride)), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun }); for (const action of planned) { // #label-scoping: a `label` action may be authorized by a class OTHER than `label` itself (an anti-abuse diff --git a/src/services/agent-approval-queue.ts b/src/services/agent-approval-queue.ts index a4a6756eaf..d499d9b585 100644 --- a/src/services/agent-approval-queue.ts +++ b/src/services/agent-approval-queue.ts @@ -406,6 +406,7 @@ export async function decidePendingAgentAction(env: Env, input: { id: string; de autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, + agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, installationPermissions: installation ? installation.permissions : null, mergeTrainMode: settings.mergeTrainMode, pullRequestCreatedAt: pr?.createdAt, diff --git a/src/services/contributor-issue-draft.ts b/src/services/contributor-issue-draft.ts index 450eb89ede..5de3904a32 100644 --- a/src/services/contributor-issue-draft.ts +++ b/src/services/contributor-issue-draft.ts @@ -10,7 +10,7 @@ import { countOpenIssues, countOpenPullRequests, getLatestRepoGithubTotalsSnapshot, - isGlobalAgentFrozen, + isDbFrozenForRepo, listUpstreamDriftReports, recordAuditEvent, } from "../db/repositories"; @@ -257,13 +257,15 @@ export async function generateContributorIssueDrafts( repoFullName: string, options: ContributorIssueDraftOptions = {}, ): Promise { + const context = await loadContributorIssueDraftContext(env, repoFullName); // The caller's dryRun flag, OVERLAID with the global agent kill-switch: a paused/frozen agent must not file // contributor issues even when a caller passes {dryRun:false}. These POSTs use a raw token outside the // installation-Octokit dry-run chokepoint (#dry-run-chokepoint), so the brake is applied here. (#audit-rawfetch-pause) - const dryRun = options.dryRun !== false || isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)); + // isDbFrozenForRepo (#4372) lets THIS repo's agentGlobalFreezeOverride bypass the DB freeze while other + // repos stay frozen -- the env-var hard stop (isGlobalAgentPause) is never overridable. + const dryRun = options.dryRun !== false || isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, context.settings.agentGlobalFreezeOverride)); const createRequested = options.create === true; const limit = Math.min(MAX_LIMIT, Math.max(1, options.limit ?? DEFAULT_LIMIT)); - const context = await loadContributorIssueDraftContext(env, repoFullName); const candidates = buildContributorIssueDraftCandidates(context).slice(0, limit); const drafts: ContributorIssueDraft[] = []; let proposed = 0; diff --git a/src/types.ts b/src/types.ts index e9f9c9eab7..3287e1041a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1035,6 +1035,11 @@ export type RepositorySettings = { /** 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; + /** Per-repo override of the global DB-backed agent freeze (#4372): when true, this repo's actions execute + * even while `global_agent_controls.frozen` is set, so an operator can re-activate one repo at a time + * without lifting the fleet-wide brake. Never overrides the `AGENT_ACTIONS_PAUSED` env var, and + * {@link agentPaused} on this same repo still wins over it. Default false. */ + agentGlobalFreezeOverride?: boolean | undefined; /** Moderation-rules engine (#selfhost-mod-engine): whether the whole layer runs on THIS repo. `"inherit"` * (the DB default) defers to `global_moderation_config.enabled`; `"off"`/`"enabled"` force this repo * regardless of the global default. Always populated by the DB layer; optional so existing settings diff --git a/test/unit/agent-action-executor.test.ts b/test/unit/agent-action-executor.test.ts index 43000e8dee..2eb28023fe 100644 --- a/test/unit/agent-action-executor.test.ts +++ b/test/unit/agent-action-executor.test.ts @@ -72,7 +72,7 @@ import { import type { PlannedAgentAction } from "../../src/settings/agent-actions"; import { STRUCTURED_CLOSE_REASONS_MAX_COUNT } from "../../src/settings/agent-execution"; import { AGENT_LABEL_PENDING_CLOSURE } from "../../src/review/linked-issue-hard-rules"; -import { clearProcessLocalGlobalAgentFrozenCacheForTest, getGlobalContributorBlacklist, isGlobalAgentFrozen, setGlobalAgentFrozen, upsertGlobalModerationConfig, upsertPullRequestFile, upsertPullRequestFromGitHub } from "../../src/db/repositories"; +import { clearProcessLocalGlobalAgentFrozenCacheForTest, getGlobalContributorBlacklist, isDbFrozenForRepo, isGlobalAgentFrozen, setGlobalAgentFrozen, upsertGlobalModerationConfig, upsertPullRequestFile, upsertPullRequestFromGitHub } from "../../src/db/repositories"; import * as repositoriesModule from "../../src/db/repositories"; import * as sentryModule from "../../src/selfhost/sentry"; import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics"; @@ -910,6 +910,40 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => { expect(mergePullRequest).toHaveBeenCalled(); }); + it("REGRESSION (#4372): a repo's agentGlobalFreezeOverride lets IT execute while the global DB freeze stays on", async () => { + const env = createTestEnv({}); // env-var brake OFF + await setGlobalAgentFrozen(env, true, "operator"); + const overridden = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false, agentGlobalFreezeOverride: true }), [merge]); + expect(overridden[0]?.outcome).toBe("completed"); + expect(mergePullRequest).toHaveBeenCalled(); + }); + + it("REGRESSION (#4372): a sibling repo WITHOUT the override stays denied while the global DB freeze is on, even though another repo opted out of it", async () => { + const env = createTestEnv({}); // env-var brake OFF + await setGlobalAgentFrozen(env, true, "operator"); + // The incident this override exists to prevent recurring: an operator meant only ONE repo to resume, so a + // sibling repo with no override (or an explicit false) must stay fully halted by the same global freeze. + const stillFrozen = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false, agentGlobalFreezeOverride: false }), [merge]); + expect(stillFrozen[0]?.outcome).toBe("denied"); + expect(mergePullRequest).not.toHaveBeenCalled(); + }); + + it("REGRESSION (#4372): the AGENT_ACTIONS_PAUSED env var stays absolute -- no per-repo override can bypass it", async () => { + const env = createTestEnv({ AGENT_ACTIONS_PAUSED: "true" }); + await setGlobalAgentFrozen(env, false); // DB freeze OFF -- only the env var is on + const outcomes = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false, agentGlobalFreezeOverride: true }), [merge]); + expect(outcomes[0]?.outcome).toBe("denied"); + expect(mergePullRequest).not.toHaveBeenCalled(); + }); + + it("REGRESSION (#4372): a repo's OWN agentPaused still wins over its agentGlobalFreezeOverride", async () => { + const env = createTestEnv({}); + await setGlobalAgentFrozen(env, false); + const outcomes = await executeAgentMaintenanceActions(env, ctx({ agentPaused: true, agentGlobalFreezeOverride: true }), [merge]); + expect(outcomes[0]?.outcome).toBe("denied"); + expect(mergePullRequest).not.toHaveBeenCalled(); + }); + it("isGlobalAgentFrozen fails open (false) on a read error — a D1 hiccup never freezes the fleet by itself", async () => { clearProcessLocalGlobalAgentFrozenCacheForTest(); const broken = { ...createTestEnv({}), DB: null } as unknown as Env; @@ -965,6 +999,31 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => { expect(await isGlobalAgentFrozen(broken)).toBe(false); }); + describe("isDbFrozenForRepo (#4372, incident follow-up)", () => { + it("agentGlobalFreezeOverride=true bypasses the DB freeze entirely, without even reading it", async () => { + const env = createTestEnv({}); + await setGlobalAgentFrozen(env, true, "operator"); + // Poison the DB so a read would throw/fail-closed if it were attempted -- the override must short-circuit. + const poisoned = { ...env, DB: null } as unknown as Env; + expect(await isDbFrozenForRepo(poisoned, true)).toBe(false); + }); + + it("agentGlobalFreezeOverride=false still reflects the DB freeze state", async () => { + const env = createTestEnv({}); + await setGlobalAgentFrozen(env, true, "operator"); + expect(await isDbFrozenForRepo(env, false)).toBe(true); + await setGlobalAgentFrozen(env, false, "operator"); + expect(await isDbFrozenForRepo(env, false)).toBe(false); + }); + + it("agentGlobalFreezeOverride=null/undefined (unset) still reflects the DB freeze state, same as false", async () => { + const env = createTestEnv({}); + await setGlobalAgentFrozen(env, true, "operator"); + expect(await isDbFrozenForRepo(env, null)).toBe(true); + expect(await isDbFrozenForRepo(env, undefined)).toBe(true); + }); + }); + it("auto_with_approval: stages the action (queued) instead of executing", async () => { const env = createTestEnv({}); const outcomes = await executeAgentMaintenanceActions(env, ctx(), [{ ...merge, requiresApproval: true }]); diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index 55ef920eef..851e4f40eb 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -304,6 +304,12 @@ describe("data spine repositories", () => { 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 + // #4372 per-repo global-freeze-override round-trip (insert + update) and default false. + await upsertRepositorySettings(env, { repoFullName: "owner/saferepo", agentGlobalFreezeOverride: true }); + expect((await getRepositorySettings(env, "owner/saferepo")).agentGlobalFreezeOverride).toBe(true); + await upsertRepositorySettings(env, { repoFullName: "owner/saferepo", agentGlobalFreezeOverride: false }); + expect((await getRepositorySettings(env, "owner/saferepo")).agentGlobalFreezeOverride).toBe(false); // update persists + expect((await getRepositorySettings(env, "owner/defaultpack")).agentGlobalFreezeOverride).toBe(false); // default // #2270 per-contributor open PR/issue caps: no row and no cap set both default to null (disabled). expect(await getRepositorySettings(env, "missing/repo")).toMatchObject({ contributorOpenPrCap: null, contributorOpenIssueCap: null }); expect(await getRepositorySettings(env, "owner/defaultpack")).toMatchObject({ contributorOpenPrCap: null, contributorOpenIssueCap: null }); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index ac972d98ae..d7cf41a0f2 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -312,6 +312,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => { autoMaintain: "autoMaintain:", agentPaused: "agentPaused:", agentDryRun: "agentDryRun:", + agentGlobalFreezeOverride: "agentGlobalFreezeOverride:", commandAuthorization: "commandAuthorization:", contributorBlacklist: "contributorBlacklist:", blacklistLabel: "blacklistLabel:", @@ -1832,6 +1833,7 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = includeMaintainerAuthors: true, requireLinkedIssue: true, backfillEnabled: false, + agentGlobalFreezeOverride: true, }, }); expect(m.present).toBe(true); @@ -1853,7 +1855,11 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = includeMaintainerAuthors: true, requireLinkedIssue: true, backfillEnabled: false, + agentGlobalFreezeOverride: true, }); + // #4372: the yml override wins over the DB value via resolveEffectiveSettings's spread, same as every + // other generic settings: field. + expect(resolveEffectiveSettings({ agentGlobalFreezeOverride: false } as unknown as RepositorySettings, m).agentGlobalFreezeOverride).toBe(true); }); it("drops invalid settings values with warnings and keeps the valid ones", () => { diff --git a/test/unit/github-client.test.ts b/test/unit/github-client.test.ts index 752c1fe51d..bf01061a8c 100644 --- a/test/unit/github-client.test.ts +++ b/test/unit/github-client.test.ts @@ -152,6 +152,15 @@ describe("resolveRepoActionMode", () => { await setGlobalAgentFrozen(env, true); expect(await resolveRepoActionMode(env, { agentPaused: false, agentDryRun: false })).toBe("paused"); // DB freeze wins }); + + it("REGRESSION (#4372): agentGlobalFreezeOverride lets a repo bypass the DB freeze but never the env brake, and its own agentPaused still wins", async () => { + const env = createTestEnv(); + await setGlobalAgentFrozen(env, true); + expect(await resolveRepoActionMode(env, { agentPaused: false, agentDryRun: false, agentGlobalFreezeOverride: true })).toBe("live"); + expect(await resolveRepoActionMode(env, { agentPaused: false, agentDryRun: false, agentGlobalFreezeOverride: false })).toBe("paused"); + expect(await resolveRepoActionMode({ ...env, AGENT_ACTIONS_PAUSED: "true" }, { agentPaused: false, agentDryRun: false, agentGlobalFreezeOverride: true })).toBe("paused"); // env brake still wins + expect(await resolveRepoActionMode(env, { agentPaused: true, agentDryRun: false, agentGlobalFreezeOverride: true })).toBe("paused"); // own pause still wins + }); }); describe("githubRateLimitAdmissionKeyForToken — the single token→admission-key resolver (no duplication, no drift)", () => { From b324dad53dde0c144764cc5e734b894c7d5ba48c Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:32:41 -0700 Subject: [PATCH 2/2] fix(api): keep agentGlobalFreezeOverride out of the maintainer settings API agentGlobalFreezeOverride is an operator-only emergency lever (set via the private .gittensory.yml, never a repo maintainer) but had landed in maintainerSettingsSchema, whose PUT /v1/repos/:owner/:repo/settings endpoint only requires repo write access -- any maintainer could set it directly, bypassing the operator-only restriction the field exists for. Drop it from that schema; the yml-only config-as-code path is the sole intended write mechanism. --- src/api/routes.ts | 1 - test/integration/api.test.ts | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/api/routes.ts b/src/api/routes.ts index 53066a5c8b..6869489a4f 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -746,7 +746,6 @@ const maintainerSettingsSchema = z publicQualityMetrics: z.boolean(), agentPaused: z.boolean(), agentDryRun: z.boolean(), - agentGlobalFreezeOverride: z.boolean(), requireFreshRebaseWindowMinutes: z.number().int().positive().nullable(), commandAuthorization: z.object({ default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4).optional(), diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index a0f7daa28a..8dfa93f6a6 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -907,6 +907,20 @@ describe("api routes", () => { const settingsMalformed = await app.request("/v1/repos/entrius/allways-ui/settings", { method: "PUT", headers: apiHeaders(env), body: "{" }, env); expect(settingsMalformed.status).toBe(400); + // REGRESSION (#4372 security finding): agentGlobalFreezeOverride is an operator-only emergency lever + // (set via the private .gittensory.yml, never the maintainer-facing settings API) — a maintainer PUT + // must silently strip it, not persist it, even when explicitly sent alongside otherwise-valid fields. + const freezeOverrideAttempt = await app.request( + "/v1/repos/entrius/allways-ui/settings", + { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ firstTimeContributorGrace: false, agentGlobalFreezeOverride: true }) }, + env, + ); + expect(freezeOverrideAttempt.status).toBe(200); + const freezeOverrideBody = (await freezeOverrideAttempt.json()) as Record; + expect(freezeOverrideBody.agentGlobalFreezeOverride).not.toBe(true); + const freezeOverrideRefetch = await app.request("/v1/repos/entrius/allways-ui/settings", { headers: apiHeaders(env) }, env); + await expect(freezeOverrideRefetch.json()).resolves.toMatchObject({ agentGlobalFreezeOverride: false }); + const registrationReadiness = await app.request("/v1/repos/entrius/allways-ui/registration-readiness", { headers: apiHeaders(env) }, env); expect(registrationReadiness.status).toBe(200); await expect(registrationReadiness.json()).resolves.toMatchObject({