Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions packages/gittensory-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export type FocusManifestSettings = Partial<
| "autoMaintain"
| "agentPaused"
| "agentDryRun"
| "agentGlobalFreezeOverride"
| "commandAuthorization"
| "contributorBlacklist"
| "blacklistLabel"
Expand Down Expand Up @@ -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.`);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2889,9 +2913,10 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource):
}
const record = raw as Record<string, JsonValue>;
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),
Expand All @@ -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),
Expand Down
39 changes: 35 additions & 4 deletions test/unit/focus-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down Expand Up @@ -351,7 +359,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => {
unlinkedIssueGuardrail: "unlinkedIssueGuardrail:",
screenshotTableGate: "screenshotTableGate:",
advisoryAiRouting: "advisoryAiRouting:",
} satisfies Record<Exclude<keyof FocusManifestSettings, (typeof SETTINGS_GATE_ALIASED_FIELDS)[number]>, string>;
} satisfies Record<Exclude<keyof FocusManifestSettings, (typeof SETTINGS_GATE_ALIASED_FIELDS)[number] | (typeof SETTINGS_OPERATOR_ONLY_FIELDS)[number]>, string>;

it.each(Object.entries(SETTINGS_FIELD_TOKENS))("documents settings.%s", (_field, token) => {
expect(exampleContent).toContain(token);
Expand Down Expand Up @@ -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", () => {
Expand Down