Skip to content

Commit 2193bde

Browse files
committed
fix(gate): close the qualityGateMode: block loophole in settings/dashboard/API
normalizeReadinessGateMode downgrades gate.readiness.mode: block to advisory at parse time, but that only covered the YAML gate: mapping. The identically named qualityGateMode field reachable through .gittensory.yml's settings: override, the maintainer dashboard save, and the internal settings write endpoint all bypassed it, so a value supplied through any of those paths still persisted and resolved as "block" — preserving the exact false belief that a real quality floor is enforced. Close it at every point the value can enter or be read: - parseSettingsOverride now normalizes settings.qualityGateMode the same way as gate.readiness.mode (exported for reuse). - Both settings-write API routes (maintainer PATCH-style save and the internal full-settings write) downgrade a written "block" before it reaches the DB. - resolveEffectiveSettings downgrades "block" as a final step regardless of source, so a repo whose DB row already has quality_gate_mode = "block" from before these guards existed is also corrected wherever effective settings are read (the review/gate pipeline and the settings-preview dashboard both go through this same resolver).
1 parent 6d0e249 commit 2193bde

4 files changed

Lines changed: 85 additions & 13 deletions

File tree

src/api/routes.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ import { isRagEnabled } from "../review/rag-wire";
238238
import { getPublicStats, isPublicStatsEnabled } from "../review/public-stats";
239239
import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard";
240240
import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics";
241-
import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest";
241+
import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES, normalizeReadinessGateMode } from "../signals/focus-manifest";
242242
import { resolveRepositorySettings } from "../settings/repository-settings";
243243
import { loadPublicRepoFocusManifest, loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader";
244244
import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack";
@@ -265,6 +265,7 @@ import type {
265265
PullRequestRecord,
266266
RepoSyncSegmentRecord,
267267
RepositoryRecord,
268+
RepositorySettings,
268269
} from "../types";
269270
import { errorMessage, nowIso } from "../utils/json";
270271

@@ -686,6 +687,21 @@ const maintainerSettingsSchema = z
686687
})
687688
.partial();
688689

690+
/** Readiness/quality can never hard-block a PR (buildQualityGateWarning is always advisory-severity;
691+
* isConfiguredGateBlocker has no branch for it) — downgrade a settings-write's `qualityGateMode: "block"` to
692+
* `"advisory"` here too, mirroring the same downgrade `.gittensory.yml`'s `gate.readiness.mode` /
693+
* `settings.qualityGateMode` already get in normalizeReadinessGateMode, so the dashboard/API save path can
694+
* never persist a value that implies enforcement it doesn't have (#2267). Callers check for `undefined`
695+
* (a PATCH-style save that didn't touch this field) before calling — this only handles the defined case, so
696+
* the return type never needs `undefined` under `exactOptionalPropertyTypes`. The warnings array is scratch;
697+
* these routes have no warnings-response protocol. */
698+
function downgradeQualityGateMode(mode: "off" | "advisory" | "block"): "off" | "advisory" {
699+
/* v8 ignore next -- never null for a Zod-validated "off"|"advisory"|"block" input (normalizeReadinessGateMode's
700+
"must be one of" branch only fires for a value outside that set); the fallback only satisfies the shared
701+
parser's broader return type. */
702+
return (normalizeReadinessGateMode(mode, "qualityGateMode", []) as "off" | "advisory" | null) ?? "advisory";
703+
}
704+
689705
// Maintainer BYOK provider key. Write-only: the key is encrypted at rest and never returned. A loose
690706
// prefix check catches the common provider/key mismatch (e.g. pasting an OpenAI key under Anthropic)
691707
// without coupling to exact provider key formats: Anthropic keys start with `sk-ant-`; OpenAI keys
@@ -2105,7 +2121,8 @@ export function createApp() {
21052121
const parsed = maintainerSettingsSchema.safeParse(body);
21062122
if (!parsed.success) return c.json({ error: "invalid_repository_settings", issues: parsed.error.issues }, 400);
21072123
const current = await getRepositorySettings(c.env, fullName);
2108-
const changes = Object.fromEntries(Object.entries(parsed.data).filter(([, value]) => value !== undefined));
2124+
const changes = Object.fromEntries(Object.entries(parsed.data).filter(([, value]) => value !== undefined)) as Partial<RepositorySettings>;
2125+
if (changes.qualityGateMode !== undefined) changes.qualityGateMode = downgradeQualityGateMode(changes.qualityGateMode);
21092126
const updated = await upsertRepositorySettings(c.env, { ...current, ...changes, repoFullName: fullName });
21102127
await recordAuditEvent(c.env, {
21112128
eventType: "repo.settings_updated",
@@ -3424,7 +3441,7 @@ export function createApp() {
34243441
gatePack: parsed.data.gatePack,
34253442
linkedIssueGateMode: parsed.data.linkedIssueGateMode,
34263443
duplicatePrGateMode: parsed.data.duplicatePrGateMode,
3427-
qualityGateMode: parsed.data.qualityGateMode,
3444+
qualityGateMode: downgradeQualityGateMode(parsed.data.qualityGateMode),
34283445
qualityGateMinScore: parsed.data.qualityGateMinScore,
34293446
aiReviewMode: parsed.data.aiReviewMode,
34303447
aiReviewByok: parsed.data.aiReviewByok,

src/signals/focus-manifest.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -357,14 +357,15 @@ function normalizeOptionalGateMode(value: JsonValue | undefined, field: string,
357357
return null;
358358
}
359359

360-
/** `gate.readiness.mode` is documented and parsed as the shared off/advisory/block tri-state, but
361-
* buildQualityGateWarning (src/rules/advisory.ts) always produces a warning-severity finding — never a
362-
* blocker — and isConfiguredGateBlocker has no branch for it: readiness/quality is intentionally
363-
* informational-only and can never hard-block a PR. Without this, a maintainer who sets `mode: block`
364-
* believes a real quality floor is enforced when the effective behavior is silently advisory-only (#2267).
365-
* Downgrade "block" to "advisory" here, with a clear deprecation warning, so the parsed config always
366-
* matches what the gate actually does. */
367-
function normalizeReadinessGateMode(value: JsonValue | undefined, field: string, warnings: string[]): GateRuleMode | null {
360+
/** `gate.readiness.mode` (and its `settings.qualityGateMode` alias below) is documented and parsed as the shared
361+
* off/advisory/block tri-state, but buildQualityGateWarning (src/rules/advisory.ts) always produces a
362+
* warning-severity finding — never a blocker — and isConfiguredGateBlocker has no branch for it: readiness/
363+
* quality is intentionally informational-only and can never hard-block a PR. Without this, a maintainer who
364+
* sets `mode: block` believes a real quality floor is enforced when the effective behavior is silently
365+
* advisory-only (#2267). Downgrade "block" to "advisory" here, with a clear deprecation warning, so the parsed
366+
* config always matches what the gate actually does. Exported so the settings-write API routes (the
367+
* dashboard/API path for the SAME `qualityGateMode` field) can apply the identical downgrade before persisting. */
368+
export function normalizeReadinessGateMode(value: JsonValue | undefined, field: string, warnings: string[]): GateRuleMode | null {
368369
const mode = normalizeOptionalGateMode(value, field, warnings);
369370
if (mode !== "block") return mode;
370371
warnings.push(`Manifest gate field "${field}" no longer accepts "block" — readiness/quality is informational-only and can never hard-block a PR; downgrading to "advisory". Use gate.manifestPolicy or another enforceable gate for a real quality floor.`);
@@ -598,7 +599,10 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
598599
if (duplicatePrGateMode !== null) out.duplicatePrGateMode = duplicatePrGateMode;
599600
const selfAuthoredLinkedIssueGateMode = normalizeOptionalGateMode(r.selfAuthoredLinkedIssueGateMode, "settings.selfAuthoredLinkedIssueGateMode", warnings);
600601
if (selfAuthoredLinkedIssueGateMode !== null) out.selfAuthoredLinkedIssueGateMode = selfAuthoredLinkedIssueGateMode;
601-
const qualityGateMode = normalizeOptionalGateMode(r.qualityGateMode, "settings.qualityGateMode", warnings);
602+
// Same tri-state field as gate.readiness.mode above (the friendly gate alias overlays onto it in
603+
// resolveEffectiveSettings) — apply the identical "block" → "advisory" downgrade here too, so a maintainer
604+
// setting `settings.qualityGateMode: block` directly hits the same deprecation warning (#2267).
605+
const qualityGateMode = normalizeReadinessGateMode(r.qualityGateMode, "settings.qualityGateMode", warnings);
602606
if (qualityGateMode !== null) out.qualityGateMode = qualityGateMode;
603607
const qualityGateMinScore = normalizeOptionalScore(r.qualityGateMinScore, "settings.qualityGateMinScore", warnings);
604608
if (qualityGateMinScore !== null) out.qualityGateMinScore = qualityGateMinScore;
@@ -997,6 +1001,14 @@ export function resolveEffectiveSettings(
9971001
if (effective.requireLinkedIssue && effective.linkedIssueGateMode === "off") {
9981002
effective.linkedIssueGateMode = "block";
9991003
}
1004+
// Readiness/quality can never hard-block a PR (buildQualityGateWarning is always advisory-severity;
1005+
// isConfiguredGateBlocker has no branch for it). The write-time guards (the settings.qualityGateMode /
1006+
// gate.readiness.mode parsers above, and the settings-write API routes) stop a NEW "block" value from being
1007+
// introduced, but a repo whose DB row already has quality_gate_mode = "block" from before those guards
1008+
// existed would still resolve to it here. Downgrade it at this single resolver too, so the EFFECTIVE settings
1009+
// the gate/review pipeline AND the settings-preview dashboard read (both call this function) can never carry
1010+
// a value that implies enforcement it doesn't have, regardless of when or where it was written (#2267).
1011+
if (effective.qualityGateMode === "block") effective.qualityGateMode = "advisory";
10001012
effective.contributorBlacklist = mergeContributorBlacklists(effective.contributorBlacklist ?? [], sharedContributorBlacklist);
10011013
return effective;
10021014
}

test/integration/api.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,20 @@ describe("api routes", () => {
269269
await expect(response.json()).resolves.toMatchObject({ repoFullName: "acme/badged", badgeEnabled: true });
270270
});
271271

272+
it("downgrades qualityGateMode: block to advisory through the internal settings write endpoint too (#2267)", async () => {
273+
// Readiness/quality can never hard-block a PR — the internal full-settings write path (used by tooling,
274+
// not just the maintainer dashboard) gets the identical downgrade so it can't persist "block" either.
275+
const app = createApp();
276+
const env = createTestEnv();
277+
const response = await app.request(
278+
"/v1/internal/repos/acme/readiness-block/settings",
279+
{ method: "POST", headers: { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ qualityGateMode: "block" }) },
280+
env,
281+
);
282+
expect(response.status).toBe(200);
283+
await expect(response.json()).resolves.toMatchObject({ repoFullName: "acme/readiness-block", qualityGateMode: "advisory" });
284+
});
285+
272286
it("rejects invalid public GitHub repo stats paths before calling GitHub", async () => {
273287
const app = createApp();
274288
const env = createTestEnv();
@@ -2274,7 +2288,10 @@ describe("api routes", () => {
22742288
method: "PUT",
22752289
headers: ownerHeaders,
22762290
// #773/#774/#776: the agent-layer config is settable here; the DB layer drops an unknown action class.
2277-
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 }),
2291+
// #2267: qualityGateMode: "block" is downgraded to "advisory" on write — readiness/quality can never
2292+
// hard-block a PR, so the dashboard/API save path can't persist a value implying enforcement it doesn't
2293+
// have. slopGateMode: "block" is a DIFFERENT, legitimately-blockable dimension and is left untouched.
2294+
body: JSON.stringify({ gateCheckMode: "enabled", slopGateMode: "block", slopGateMinScore: 55, qualityGateMode: "block", autonomy: { merge: "auto_with_approval", deploy: "auto" }, autoMaintain: { requireApprovals: 2, mergeMethod: "rebase" }, agentPaused: true, agentDryRun: true }),
22782295
},
22792296
ownerEnv,
22802297
);
@@ -2283,6 +2300,7 @@ describe("api routes", () => {
22832300
gateCheckMode: "enabled",
22842301
slopGateMode: "block",
22852302
slopGateMinScore: 55,
2303+
qualityGateMode: "advisory", // #2267: downgraded, not persisted as "block"
22862304
autonomy: { merge: "auto_with_approval" }, // unknown action class dropped by the DB normalizer
22872305
autoMaintain: { requireApprovals: 2, mergeMethod: "rebase" },
22882306
agentPaused: true, // #776 kill-switch

test/unit/focus-manifest.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,20 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () =
10891089
expect(parseFocusManifest({ settings: { commentMode: "off" } }).present).toBe(true);
10901090
});
10911091

1092+
it("downgrades settings.qualityGateMode: block to advisory with a deprecation warning, same as gate.readiness.mode (#2267)", () => {
1093+
// The generic settings: override is the SAME dashboard/API-facing qualityGateMode field, read through a
1094+
// different manifest path than gate.readiness.mode — it must get the identical downgrade, not just a
1095+
// "must be one of" pass-through, or a maintainer using this path keeps the false-enforcement belief.
1096+
const m = parseFocusManifest({ settings: { qualityGateMode: "block" } });
1097+
expect(m.settings.qualityGateMode).toBe("advisory");
1098+
expect(m.warnings.some((w) => /settings\.qualityGateMode.*no longer accepts "block"/.test(w))).toBe(true);
1099+
// Genuinely invalid values still take the ORIGINAL "must be one of" warning path, unchanged.
1100+
const bad = parseFocusManifest({ settings: { qualityGateMode: "sometimes" } });
1101+
expect(bad.settings.qualityGateMode).toBeUndefined();
1102+
expect(bad.warnings.some((w) => /settings\.qualityGateMode.*must be one of/.test(w))).toBe(true);
1103+
expect(bad.warnings.some((w) => /no longer accepts "block"/.test(w))).toBe(false);
1104+
});
1105+
10921106
it("round-trips settings through settingsOverrideToJson and serializes empty as null", () => {
10931107
const original = parseFocusManifest({ settings: { commentMode: "all_prs", qualityGateMinScore: 40 } });
10941108
const reparsed = parseFocusManifest({ settings: settingsOverrideToJson(original.settings) });
@@ -1169,6 +1183,17 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () =
11691183
);
11701184
expect(eff.linkedIssueGateMode).toBe("block");
11711185
});
1186+
1187+
it("REGRESSION: downgrades a pre-existing DB qualityGateMode: block to advisory, even with no gate.readiness.mode override (#2267)", () => {
1188+
// Simulates a repo whose DB row already has quality_gate_mode = "block" from before the write-time guards
1189+
// (the settings.qualityGateMode parser, the settings-write API routes) existed — the dashboard/API path's
1190+
// "still survives" loophole this resolver-level guard closes for good, regardless of source or vintage.
1191+
const db = { qualityGateMode: "block" } as unknown as RepositorySettings;
1192+
expect(resolveEffectiveSettings(db, parseFocusManifest(null)).qualityGateMode).toBe("advisory");
1193+
// A non-"block" value is untouched — the downgrade only ever fires for "block".
1194+
const dbAdvisory = { qualityGateMode: "advisory" } as unknown as RepositorySettings;
1195+
expect(resolveEffectiveSettings(dbAdvisory, parseFocusManifest(null)).qualityGateMode).toBe("advisory");
1196+
});
11721197
});
11731198

11741199
describe("parseFocusManifest review config", () => {

0 commit comments

Comments
 (0)