diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index 19f3f9a932..5e278047b0 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -331,6 +331,7 @@ export type FocusManifestSettings = Partial< | "autoMaintain" | "agentPaused" | "agentDryRun" + | "agentGlobalFreezeOverride" | "commandAuthorization" | "contributorBlacklist" | "blacklistLabel" @@ -1597,7 +1598,7 @@ const MAX_REVIEW_NAG_COOLDOWN_DAYS = 365; * Parse the optional `settings:` mapping — a partial repository-settings override. Only recognized * fields are kept; unknown/invalid values are dropped with a warning and never throw. */ -function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]): FocusManifestSettings { +function parseSettingsOverride(value: JsonValue | undefined, warnings: string[], source?: FocusManifestSource): FocusManifestSettings { if (value === undefined || value === null) return {}; if (typeof value !== "object" || Array.isArray(value)) { warnings.push(`Manifest field "settings" must be a mapping; ignoring it.`); @@ -1663,6 +1664,29 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); if (flag !== null) out[key] = flag; } + // agentGlobalFreezeOverride is deliberately NOT in the generic boolean loop above (#4372/#4391/operator-only- + // freeze-fix): it is an OPERATOR-ONLY emergency lever ("re-activate this one repo while the fleet-wide kill- + // switch stays on elsewhere"), and every OTHER settings field in that loop is readable from BOTH the public, + // maintainer-owned `.gittensory.yml` committed in the repo's own git history (source: "repo_file") AND the + // operator's private, container-local self-host config (source: "api_record") -- see loadRepoFocusManifestWithCachePolicy + // in focus-manifest-loader.ts for how each source is produced. A repo MAINTAINER must never be able to grant + // their own repo an exemption from the operator's fleet-wide freeze via their own committed yml (that is + // exactly the "scope leak" #4391 closed by stripping this field from the shared loop entirely). But the + // OPERATOR's own private config source is a fundamentally different trust boundary -- it is edited only by + // whoever has filesystem access to the container's private config directory, not by any repo's maintainers -- + // and #4391 over-corrected by also removing the operator's own legitimate, config-as-code path for this lever, + // forcing raw undocumented DB writes as the only remaining mechanism (violating this project's config-as-code + // convention: every operator-facing control belongs in the global-default + per-repo-override config files, + // env vars are for bootstrap only). Restore it, gated STRICTLY to the private source. + if (source === "api_record") { + const agentGlobalFreezeOverride = normalizeOptionalBoolean(r.agentGlobalFreezeOverride, "settings.agentGlobalFreezeOverride", warnings); + if (agentGlobalFreezeOverride !== null) out.agentGlobalFreezeOverride = agentGlobalFreezeOverride; + } else if (r.agentGlobalFreezeOverride !== undefined) { + // A public/maintainer-owned manifest attempting to set this is silently dropped, not surfaced as a normal + // "invalid value" warning -- warnings are public-safe text that can reach a contributor-facing preview, and + // this should not teach a non-operator that the field exists or that they almost bypassed the fleet freeze. + warnings.push("Ignored settings.agentGlobalFreezeOverride: operator-only, not settable from a repo-owned manifest."); + } // Agent-layer autonomy dial (#773): `settings.autonomy` maps each action class to a level. Only set it // when at least one valid class→level pair survives normalization, so a malformed block never blanks the // DB-configured policy via the resolver's `{...dbSettings, ...manifest.settings}` overlay. @@ -2889,9 +2913,10 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): } const record = raw as Record; const warnings: string[] = []; + const resolvedSource = normalizeSource(source, record.source, warnings); const manifest: FocusManifest = { present: true, - source: normalizeSource(source, record.source, warnings), + source: resolvedSource, wantedPaths: normalizeStringList(record.wantedPaths, "wantedPaths", warnings), preferredLabels: normalizeStringList(record.preferredLabels, "preferredLabels", warnings), linkedIssuePolicy: normalizeEnum(record.linkedIssuePolicy, "linkedIssuePolicy", ["required", "preferred", "optional"] as const, "optional", warnings), @@ -2900,7 +2925,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): maintainerNotes: normalizeStringList(record.maintainerNotes, "maintainerNotes", warnings), publicNotes: normalizeStringList(record.publicNotes, "publicNotes", warnings).filter(isFocusManifestPublicSafe), gate: parseGateConfig(record.gate, warnings), - settings: parseSettingsOverride(record.settings, warnings), + settings: parseSettingsOverride(record.settings, warnings, resolvedSource), review: parseReviewConfig(record.review, warnings), features: parseFeaturesConfig(record.features, warnings), contentLane: parseContentLaneConfig(record.contentLane, warnings), diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index d63e49b6d9..bee35b14a6 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -287,6 +287,14 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => { // silently vanishing from the exhaustiveness check. const SETTINGS_GATE_ALIASED_FIELDS = ["gateCheckMode", "linkedIssueGateMode", "duplicatePrGateMode", "selfAuthoredLinkedIssueGateMode", "qualityGateMode", "qualityGateMinScore", "aiReviewMode", "aiReviewByok", "aiReviewProvider", "aiReviewModel", "aiReviewAllAuthors"] as const; + // Settings fields that are DELIBERATELY absent from `.gittensory.yml.example` (unlike the gate-aliased fields + // above, these are never documented anywhere in the public template): agentGlobalFreezeOverride is an + // operator-only emergency lever, settable only from the operator's own private self-host config (source: + // "api_record" in parseSettingsOverride, focus-manifest.ts) -- never from a repo's own committed, maintainer- + // owned manifest (#4391's scope-leak fix). Documenting it in the PUBLIC example would misleadingly suggest a + // repo maintainer can set it themselves. + const SETTINGS_OPERATOR_ONLY_FIELDS = ["agentGlobalFreezeOverride"] as const; + const SETTINGS_FIELD_TOKENS = { commentMode: "commentMode:", publicAudienceMode: "publicAudienceMode:", @@ -351,7 +359,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => { unlinkedIssueGuardrail: "unlinkedIssueGuardrail:", screenshotTableGate: "screenshotTableGate:", advisoryAiRouting: "advisoryAiRouting:", - } satisfies Record, string>; + } satisfies Record, string>; it.each(Object.entries(SETTINGS_FIELD_TOKENS))("documents settings.%s", (_field, token) => { expect(exampleContent).toContain(token); @@ -1855,10 +1863,33 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = includeMaintainerAuthors: true, requireLinkedIssue: true, backfillEnabled: false, + agentGlobalFreezeOverride: true, + }); + // parseFocusManifest with no explicit `source` (and no `record.source` field, as here) defaults to + // "api_record" (normalizeSource, focus-manifest.ts) -- the operator-private-config trust level -- so + // agentGlobalFreezeOverride parses through and can overlay the DB value. See the dedicated + // "agentGlobalFreezeOverride: operator-only" describe block below for the source-gating itself (an + // explicit source: "repo_file" manifest, mirroring a real repo-owned `.gittensory.yml`, drops it instead). + expect(resolveEffectiveSettings({ agentGlobalFreezeOverride: false } as unknown as RepositorySettings, m).agentGlobalFreezeOverride).toBe(true); + }); + + describe("agentGlobalFreezeOverride: operator-only, never settable from a repo-owned manifest (#4391)", () => { + it("source: api_record (the operator's own private self-host config) — parses it and lets it overlay the DB value", () => { + const m = parseFocusManifest({ source: "api_record", settings: { agentGlobalFreezeOverride: true } }); + expect(m.settings.agentGlobalFreezeOverride).toBe(true); + expect(m.warnings).toEqual([]); + expect(resolveEffectiveSettings({ agentGlobalFreezeOverride: false } as unknown as RepositorySettings, m).agentGlobalFreezeOverride).toBe(true); + }); + + it("source: repo_file (a real repo-owned .gittensory.yml) — drops it with an operator-only warning; the DB value survives", () => { + const m = parseFocusManifest({ source: "repo_file", settings: { agentGlobalFreezeOverride: true } }); + expect(m.settings.agentGlobalFreezeOverride).toBeUndefined(); + expect(m.warnings).toContain("Ignored settings.agentGlobalFreezeOverride: operator-only, not settable from a repo-owned manifest."); + // A repo maintainer's own committed manifest must never be able to grant an exemption from the operator's + // fleet-wide freeze (the #4391 scope-leak this field's source-gating exists to prevent) -- the DB's `false` + // (fleet-wide frozen, no repo-level override) survives untouched. + expect(resolveEffectiveSettings({ agentGlobalFreezeOverride: false } as unknown as RepositorySettings, m).agentGlobalFreezeOverride).toBe(false); }); - // Operator-only freeze overrides are deliberately not config-as-code fields; a maintainer-owned - // manifest must not be able to bypass the DB-backed global freeze. - expect(resolveEffectiveSettings({ agentGlobalFreezeOverride: false } as unknown as RepositorySettings, m).agentGlobalFreezeOverride).toBe(false); }); it("drops invalid settings values with warnings and keeps the valid ones", () => {