diff --git a/.loopover.yml.example b/.loopover.yml.example index e10819c549..a7c17e742f 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -668,13 +668,18 @@ settings: # of this setting. Bool. Default: false. closeOwnerAuthors: false - # Hard manual-review path guardrails are config-as-code only. Configured globs are ADDED to a fixed - # set of built-in invariant guardrails (config-as-code files, CI workflows/scripts, and core - # engine-decision paths — see DEFAULT_HARD_GUARDRAIL_GLOBS in src/review/guardrail-config.ts) that - # always apply and can never be disabled from here. Omit or use [] to rely on just those built-in - # invariants, with no repo-specific additions. + # Hard manual-review path guardrails are config-as-code only. Safe by default (#3943): whatever you list + # here is ADDED to a fixed, built-in invariant set (CI workflows/scripts, deploy config, config-as-code + # files, core engine-decision paths — see DEFAULT_HARD_GUARDRAIL_GLOBS in src/review/guardrail-config.ts) + # that an ordinary edit here can only ever widen, never shrink. Bool. Default: []. hardGuardrailGlobs: [] + # Opt-in escape hatch: set true to make hardGuardrailGlobs above REPLACE (not add to) the built-in + # invariant set, including using [] to disable path guardrails entirely. A deliberate, separately-visible + # decision — only set this if you specifically want full control over your own guardrail list instead of + # the safe-by-default add-only behavior. Bool. Default: false. + hardGuardrailGlobsOverridesInvariants: false + # Require a linked issue on every PR (dashboard equivalent of the toggle that # can auto-promote gate.linkedIssue to block). Bool. Default: false. requireLinkedIssue: false diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 9e9f74e73c..368b79ef1a 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -9635,6 +9635,10 @@ "updatedAt": { "type": "string", "nullable": true + }, + "hardGuardrailGlobsOverridesInvariants": { + "type": "boolean", + "nullable": true } }, "required": [ diff --git a/config/examples/README.md b/config/examples/README.md index 68780c1702..97145b3168 100644 --- a/config/examples/README.md +++ b/config/examples/README.md @@ -218,8 +218,13 @@ Two `autonomy` classes govern every label the bot can apply, and they are **inde All disposition labels are configurable under `settings.*Label`, and explicit `null` disables the label without disabling the underlying merge/close/hold decision. Hard path guardrails are -config-as-code only: omitting `settings.hardGuardrailGlobs` or setting it to `[]` means no path -guardrails, and a concrete list replaces any lower-layer private global default. +config-as-code only and safe by default (#3943): `settings.hardGuardrailGlobs` is ADDED to a fixed, +built-in invariant set (`DEFAULT_HARD_GUARDRAIL_GLOBS` in `src/review/guardrail-config.ts`) — an +ordinary edit can only ever widen guardrail coverage, never shrink it. Set +`settings.hardGuardrailGlobsOverridesInvariants: true` to opt out of that safety net entirely: once +set, `hardGuardrailGlobs` REPLACES (never merges with) the built-in set or any lower-layer private +global default, including an explicit `[]` to disable path guardrails altogether — a deliberate, +separately-visible decision, not a side effect of trimming the list. ```yaml # .loopover.yml (global default) — recommended one-shot baseline diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index 231231f11c..efa0481411 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -682,13 +682,18 @@ settings: # of this setting. Bool. Default: false. closeOwnerAuthors: false - # Hard manual-review path guardrails are config-as-code only. Configured globs are ADDED to a fixed - # set of built-in invariant guardrails (config-as-code files, CI workflows/scripts, and core - # engine-decision paths — see DEFAULT_HARD_GUARDRAIL_GLOBS in src/review/guardrail-config.ts) that - # always apply and can never be disabled from here. Omit or use [] to rely on just those built-in - # invariants, with no repo-specific additions. + # Hard manual-review path guardrails are config-as-code only. Safe by default (#3943): whatever you list + # here is ADDED to a fixed, built-in invariant set (CI workflows/scripts, deploy config, config-as-code + # files, core engine-decision paths — see DEFAULT_HARD_GUARDRAIL_GLOBS in src/review/guardrail-config.ts) + # that an ordinary edit here can only ever widen, never shrink. Bool. Default: []. hardGuardrailGlobs: [] + # Opt-in escape hatch: set true to make hardGuardrailGlobs above REPLACE (not add to) the built-in + # invariant set, including using [] to disable path guardrails entirely. A deliberate, separately-visible + # decision — only set this if you specifically want full control over your own guardrail list instead of + # the safe-by-default add-only behavior. Bool. Default: false. + hardGuardrailGlobsOverridesInvariants: false + # Require a linked issue on every PR (dashboard equivalent of the toggle that # can auto-promote gate.linkedIssue to block). Bool. Default: false. requireLinkedIssue: false diff --git a/config/examples/global.gittensory.yml b/config/examples/global.gittensory.yml index 38f5a028f1..675bcbf082 100644 --- a/config/examples/global.gittensory.yml +++ b/config/examples/global.gittensory.yml @@ -48,9 +48,13 @@ settings: autoCloseExemptLogins: - your-admin-login - # Hard manual-review path guardrails are config-as-code only. Omit or use [] for no path - # guardrails; set a concrete list when you want otherwise-mergeable PRs held for a person. + # Hard manual-review path guardrails are config-as-code only and safe by default (#3943): this list is + # ADDED to a fixed, built-in invariant set (DEFAULT_HARD_GUARDRAIL_GLOBS in + # src/review/guardrail-config.ts), never allowed to shrink it. Set + # hardGuardrailGlobsOverridesInvariants: true below to let this list fully REPLACE the built-in set + # instead (including [] to disable path guardrails entirely) — a deliberate opt-in, not the default. hardGuardrailGlobs: [] + hardGuardrailGlobsOverridesInvariants: false # Disposition label names are config-as-code, not engine identity. Set any value to your repo's # preferred label name, or explicit null to disable that label while preserving the action/hold. diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index 93b42d8696..3ad4b9f7dd 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -417,6 +417,7 @@ export type FocusManifestSettings = Partial< | "reviewNagMonitoredMentions" | "autoCloseExemptLogins" | "hardGuardrailGlobs" + | "hardGuardrailGlobsOverridesInvariants" | "manualReviewLabel" | "readyToMergeLabel" | "changesRequestedLabel" @@ -1856,7 +1857,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", "hardGuardrailGlobsOverridesInvariants"] as const) { const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); if (flag !== null) out[key] = flag; } diff --git a/packages/gittensory-engine/src/review/guardrail-config.ts b/packages/gittensory-engine/src/review/guardrail-config.ts index fc5c12ba26..8898ad1e38 100644 --- a/packages/gittensory-engine/src/review/guardrail-config.ts +++ b/packages/gittensory-engine/src/review/guardrail-config.ts @@ -44,6 +44,9 @@ export const ENGINE_DECISION_GUARDRAIL_GLOBS = [ "src/review/outcomes-wire.ts", ]; +// Default, safe-by-default invariant set (restored by #3943 after the original pure-config-as-code design +// let a `.gittensory.yml` edit silently remove its own guardrail protection). Repo settings can only ADD to +// this set UNLESS the repo explicitly opts in via `hardGuardrailGlobsOverridesInvariants` (below). export const DEFAULT_HARD_GUARDRAIL_GLOBS = [ ...CONFIG_AS_CODE_GUARDRAIL_GLOBS, ...WORKFLOW_AND_RUNTIME_GUARDRAIL_GLOBS, @@ -51,14 +54,24 @@ export const DEFAULT_HARD_GUARDRAIL_GLOBS = [ ]; /** - * Resolve hard-guardrail path globs from the already-effective repo settings. Built-in config-as-code, - * workflow/runtime, and engine decision guardrails are invariants; repo settings may only add globs. + * Resolve hard-guardrail path globs from the already-effective repo settings. + * + * Safe by default (#3943): `DEFAULT_HARD_GUARDRAIL_GLOBS` is an invariant floor, and a repo's configured + * `hardGuardrailGlobs` is ADDED to it (deduplicated), never allowed to shrink it — so an ordinary + * `.gittensory.yml` edit (even a careless or malicious one) can only ever widen guardrail protection. + * + * Full self-hoster control, opt-in (config-as-code mandate): a repo that explicitly sets + * `hardGuardrailGlobsOverridesInvariants: true` takes complete ownership of its guardrail list — + * `hardGuardrailGlobs` is then used EXACTLY as given (including an explicit `[]` to disable path guardrails + * entirely), REPLACING rather than adding to the built-in floor. This is deliberately a second, explicit + * field rather than reusing `hardGuardrailGlobs: []`'s presence/absence, so opting out of the safety net is + * always a conscious, separately-visible decision in the config file, not a side effect of trimming a list. */ export function resolveHardGuardrailGlobs( - settings: Pick | null | undefined, + settings: Pick | null | undefined, ): string[] { const configured = settings?.hardGuardrailGlobs; - return Array.from( - new Set([...DEFAULT_HARD_GUARDRAIL_GLOBS, ...(Array.isArray(configured) ? configured : [])]), - ); + const configuredList = Array.isArray(configured) ? configured : []; + if (settings?.hardGuardrailGlobsOverridesInvariants === true) return [...configuredList]; + return Array.from(new Set([...DEFAULT_HARD_GUARDRAIL_GLOBS, ...configuredList])); } diff --git a/packages/gittensory-engine/src/types/manifest-deps-types.ts b/packages/gittensory-engine/src/types/manifest-deps-types.ts index 6fb9e70d24..828b99e7ab 100644 --- a/packages/gittensory-engine/src/types/manifest-deps-types.ts +++ b/packages/gittensory-engine/src/types/manifest-deps-types.ts @@ -442,9 +442,18 @@ export type RepositorySettings = { * `[]`); optional so existing settings fixtures/callers need not be touched. */ autoCloseExemptLogins?: string[] | undefined; /** Hard manual-review guardrail globs. Config-as-code only: set in private/global or per-repo - * `.gittensory.yml` under `settings.hardGuardrailGlobs`. Absent means no path guardrails. Arrays are - * replacement overlays, so a repo can clear a global default with `[]`. */ + * `.gittensory.yml` under `settings.hardGuardrailGlobs`. Safe by default (#3943): ADDED to the built-in + * invariant floor (`DEFAULT_HARD_GUARDRAIL_GLOBS` in src/review/guardrail-config.ts), never allowed to + * shrink it, unless {@link hardGuardrailGlobsOverridesInvariants} is explicitly `true` — see that field. */ hardGuardrailGlobs?: string[] | null | undefined; + /** Opt-in escape hatch (config-as-code mandate) from {@link hardGuardrailGlobs}'s safe-by-default + * add-only behavior: when explicitly `true`, `hardGuardrailGlobs` is used EXACTLY as configured — + * REPLACING the built-in invariant floor rather than adding to it, including an explicit `[]` to + * disable path guardrails entirely. Deliberately a separate, explicitly-named field (rather than + * overloading `hardGuardrailGlobs: []`'s meaning) so a repo choosing to drop the built-in safety net is + * always a conscious, separately-visible config decision. Default `false`/absent preserves #3943's + * protection: an ordinary `.gittensory.yml` edit can only ever widen guardrail coverage. */ + hardGuardrailGlobsOverridesInvariants?: boolean | null | undefined; /** Label applied when an otherwise-ready PR is held for manual review by a guardrail. Config-as-code only; * `null` disables the label while keeping the hold. Distinct from `review_state_label`, so operators can * apply one manual-review label without enabling ready/changes-requested disposition labels. */ diff --git a/packages/gittensory-engine/src/types/predicted-gate-types.ts b/packages/gittensory-engine/src/types/predicted-gate-types.ts index 64578d2b5b..f941878ffd 100644 --- a/packages/gittensory-engine/src/types/predicted-gate-types.ts +++ b/packages/gittensory-engine/src/types/predicted-gate-types.ts @@ -126,6 +126,7 @@ export type GatePolicyPack = "gittensor" | "oss-anti-slop"; export type RepositorySettings = { repoFullName: string; hardGuardrailGlobs?: string[] | null | undefined; + hardGuardrailGlobsOverridesInvariants?: boolean | null | undefined; }; export type RecentMergedPullRequestRecord = { @@ -331,6 +332,7 @@ export type FocusManifestReviewConfig = { export type FocusManifestSettings = { hardGuardrailGlobs?: string[] | null | undefined; + hardGuardrailGlobsOverridesInvariants?: boolean | null | undefined; }; export type FocusManifest = { diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 34455e037f..814b43ca50 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -822,6 +822,7 @@ export const RepositorySettingsSchema = z reviewNagMonitoredMentions: z.array(z.string()).optional(), autoCloseExemptLogins: z.array(z.string()).optional(), hardGuardrailGlobs: z.array(z.string()).nullable().optional(), + hardGuardrailGlobsOverridesInvariants: z.boolean().nullable().optional(), manualReviewLabel: z.string().nullable().optional(), readyToMergeLabel: z.string().nullable().optional(), changesRequestedLabel: z.string().nullable().optional(), diff --git a/src/review/guardrail-config.ts b/src/review/guardrail-config.ts index bdd48cf262..1bc722bf07 100644 --- a/src/review/guardrail-config.ts +++ b/src/review/guardrail-config.ts @@ -44,6 +44,9 @@ export const ENGINE_DECISION_GUARDRAIL_GLOBS = [ "src/review/outcomes-wire.ts", ]; +// Default, safe-by-default invariant set (restored by #3943 after the original pure-config-as-code design +// let a `.gittensory.yml` edit silently remove its own guardrail protection). Repo settings can only ADD to +// this set UNLESS the repo explicitly opts in via `hardGuardrailGlobsOverridesInvariants` (below). export const DEFAULT_HARD_GUARDRAIL_GLOBS = [ ...CONFIG_AS_CODE_GUARDRAIL_GLOBS, ...WORKFLOW_AND_RUNTIME_GUARDRAIL_GLOBS, @@ -51,14 +54,24 @@ export const DEFAULT_HARD_GUARDRAIL_GLOBS = [ ]; /** - * Resolve hard-guardrail path globs from the already-effective repo settings. Built-in config-as-code, - * workflow/runtime, and engine decision guardrails are invariants; repo settings may only add globs. + * Resolve hard-guardrail path globs from the already-effective repo settings. + * + * Safe by default (#3943): `DEFAULT_HARD_GUARDRAIL_GLOBS` is an invariant floor, and a repo's configured + * `hardGuardrailGlobs` is ADDED to it (deduplicated), never allowed to shrink it — so an ordinary + * `.gittensory.yml` edit (even a careless or malicious one) can only ever widen guardrail protection. + * + * Full self-hoster control, opt-in (config-as-code mandate): a repo that explicitly sets + * `hardGuardrailGlobsOverridesInvariants: true` takes complete ownership of its guardrail list — + * `hardGuardrailGlobs` is then used EXACTLY as given (including an explicit `[]` to disable path guardrails + * entirely), REPLACING rather than adding to the built-in floor. This is deliberately a second, explicit + * field rather than reusing `hardGuardrailGlobs: []`'s presence/absence, so opting out of the safety net is + * always a conscious, separately-visible decision in the config file, not a side effect of trimming a list. */ export function resolveHardGuardrailGlobs( - settings: Pick | null | undefined, + settings: Pick | null | undefined, ): string[] { const configured = settings?.hardGuardrailGlobs; - return Array.from( - new Set([...DEFAULT_HARD_GUARDRAIL_GLOBS, ...(Array.isArray(configured) ? configured : [])]), - ); + const configuredList = Array.isArray(configured) ? configured : []; + if (settings?.hardGuardrailGlobsOverridesInvariants === true) return [...configuredList]; + return Array.from(new Set([...DEFAULT_HARD_GUARDRAIL_GLOBS, ...configuredList])); } diff --git a/src/types.ts b/src/types.ts index 5f112dc78d..357391367d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1043,9 +1043,18 @@ export type RepositorySettings = { * `[]`); optional so existing settings fixtures/callers need not be touched. */ autoCloseExemptLogins?: string[] | undefined; /** Hard manual-review guardrail globs. Config-as-code only: set in private/global or per-repo - * `.gittensory.yml` under `settings.hardGuardrailGlobs`. Absent means no path guardrails. Arrays are - * replacement overlays, so a repo can clear a global default with `[]`. */ + * `.gittensory.yml` under `settings.hardGuardrailGlobs`. Safe by default (#3943): ADDED to the built-in + * invariant floor (`DEFAULT_HARD_GUARDRAIL_GLOBS` in src/review/guardrail-config.ts), never allowed to + * shrink it, unless {@link hardGuardrailGlobsOverridesInvariants} is explicitly `true` — see that field. */ hardGuardrailGlobs?: string[] | null | undefined; + /** Opt-in escape hatch (config-as-code mandate) from {@link hardGuardrailGlobs}'s safe-by-default + * add-only behavior: when explicitly `true`, `hardGuardrailGlobs` is used EXACTLY as configured — + * REPLACING the built-in invariant floor rather than adding to it, including an explicit `[]` to + * disable path guardrails entirely. Deliberately a separate, explicitly-named field (rather than + * overloading `hardGuardrailGlobs: []`'s meaning) so a repo choosing to drop the built-in safety net is + * always a conscious, separately-visible config decision. Default `false`/absent preserves #3943's + * protection: an ordinary `.gittensory.yml` edit can only ever widen guardrail coverage. */ + hardGuardrailGlobsOverridesInvariants?: boolean | null | undefined; /** Label applied when an otherwise-ready PR is held for manual review by a guardrail. Config-as-code only; * `null` disables the label while keeping the hold. Distinct from `review_state_label`, so operators can * apply one manual-review label without enabling ready/changes-requested disposition labels. */ diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 0837b9cc2f..c4217936f2 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -342,6 +342,7 @@ describe(".loopover.yml.example field-exhaustiveness (#1670)", () => { reviewNagMonitoredMentions: "reviewNagMonitoredMentions:", autoCloseExemptLogins: "autoCloseExemptLogins:", hardGuardrailGlobs: "hardGuardrailGlobs:", + hardGuardrailGlobsOverridesInvariants: "hardGuardrailGlobsOverridesInvariants:", manualReviewLabel: "manualReviewLabel:", readyToMergeLabel: "readyToMergeLabel:", changesRequestedLabel: "changesRequestedLabel:", @@ -2320,6 +2321,20 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(invalidArray.warnings.some((w) => /did not contain any valid path globs/.test(w))).toBe(true); }); + it("parses + resolves hardGuardrailGlobsOverridesInvariants as a plain boolean flag", () => { + const enabled = parseFocusManifest({ settings: { hardGuardrailGlobsOverridesInvariants: true } }); + expect(enabled.settings.hardGuardrailGlobsOverridesInvariants).toBe(true); + const eff = resolveEffectiveSettings({ hardGuardrailGlobsOverridesInvariants: false } as unknown as RepositorySettings, enabled); + expect(eff.hardGuardrailGlobsOverridesInvariants).toBe(true); + + const omitted = resolveEffectiveSettings({ hardGuardrailGlobsOverridesInvariants: true } as unknown as RepositorySettings, parseFocusManifest({})); + expect(omitted.hardGuardrailGlobsOverridesInvariants).toBe(true); + + const malformed = parseFocusManifest({ settings: { hardGuardrailGlobsOverridesInvariants: "yes" as never } }); + expect(malformed.settings.hardGuardrailGlobsOverridesInvariants).toBeUndefined(); + expect(malformed.warnings.some((w) => /settings\.hardGuardrailGlobsOverridesInvariants/.test(w))).toBe(true); + }); + it("#label-scoping: parses + resolves reviewNagMonitoredMentions from the settings: block, overlaying the DB", () => { const manifest = parseFocusManifest({ settings: { reviewNagMonitoredMentions: ["JSONbored", "Some-Maintainer"] } }); expect(manifest.settings.reviewNagMonitoredMentions).toEqual(["JSONbored", "Some-Maintainer"]); diff --git a/test/unit/guardrail-config.test.ts b/test/unit/guardrail-config.test.ts index bdd1f5bd2f..ffc4b3865a 100644 --- a/test/unit/guardrail-config.test.ts +++ b/test/unit/guardrail-config.test.ts @@ -22,7 +22,7 @@ describe("resolveHardGuardrailGlobs", () => { expect(resolveHardGuardrailGlobs({ hardGuardrailGlobs: null })).toEqual(DEFAULT_HARD_GUARDRAIL_GLOBS); }); - it("adds configured guardrail globs without allowing them to replace invariants", () => { + it("adds configured guardrail globs without allowing them to replace invariants by default", () => { const configured = ["src/custom/**", ".github/workflows/**"]; const resolved = resolveHardGuardrailGlobs({ hardGuardrailGlobs: configured }); @@ -33,7 +33,30 @@ describe("resolveHardGuardrailGlobs", () => { expect(configured).toEqual(["src/custom/**", ".github/workflows/**"]); }); - it("keeps invariant guardrails when configured globs are explicitly empty", () => { + it("keeps invariant guardrails when configured globs are explicitly empty and override is not set", () => { expect(resolveHardGuardrailGlobs({ hardGuardrailGlobs: [] })).toEqual(DEFAULT_HARD_GUARDRAIL_GLOBS); + expect(resolveHardGuardrailGlobs({ hardGuardrailGlobs: [], hardGuardrailGlobsOverridesInvariants: false })).toEqual( + DEFAULT_HARD_GUARDRAIL_GLOBS, + ); + }); + + it("REPLACES (not adds to) invariants when hardGuardrailGlobsOverridesInvariants is true", () => { + const configured = ["src/custom/**"]; + const resolved = resolveHardGuardrailGlobs({ hardGuardrailGlobs: configured, hardGuardrailGlobsOverridesInvariants: true }); + + expect(resolved).toEqual(["src/custom/**"]); + expect(resolved).not.toContain(".github/workflows/**"); + expect(resolved).not.toBe(configured); + + resolved.push("mutated/**"); + expect(configured).toEqual(["src/custom/**"]); + }); + + it("disables path guardrails entirely when override is true and configured globs are explicitly empty", () => { + expect(resolveHardGuardrailGlobs({ hardGuardrailGlobs: [], hardGuardrailGlobsOverridesInvariants: true })).toEqual([]); + }); + + it("returns an empty list when override is true but hardGuardrailGlobs itself is unset", () => { + expect(resolveHardGuardrailGlobs({ hardGuardrailGlobsOverridesInvariants: true })).toEqual([]); }); });