diff --git a/packages/loopover-engine/src/settings/global-contributor-cap.ts b/packages/loopover-engine/src/settings/global-contributor-cap.ts new file mode 100644 index 0000000000..5a30b980c8 --- /dev/null +++ b/packages/loopover-engine/src/settings/global-contributor-cap.ts @@ -0,0 +1,55 @@ +// Install-wide contributor open-item cap (#2562, anti-abuse): a self-hosted install that gates multiple repos +// shares ONE database, but the per-repo contributorOpenPrCap/contributorOpenIssueCap (repository-settings.ts) +// only ever counts open items on the SAME repo -- an actor spreading low-volume spam/farming PRs across several +// gated repos in that install never trips any single repo's cap. This is cross-REPO-within-one-install only (no +// federation, no cross-instance privacy design): a same-database aggregate against every repo this install +// already tracks. Deliberately an env var (not a per-repo `.loopover.yml`/DB field like the caps above) -- +// this setting aggregates ACROSS repos, so it cannot be "this repo's" setting; it belongs to the install as a +// whole, mirroring how global_contributor_blacklist is a tenant-free singleton rather than a per-repo column. +// +// #4511 (AMS-readiness follow-up): "unset ⇒ null ⇒ no cap" was the ONLY defense against one identity farming +// PRs across every gated repo in an install, and it was off unless an operator proactively opted in AND +// remembered to pre-size it. That's backwards for a fleet-scale actor -- fail-safe means a sane cap exists by +// default, not that protection is silently absent until someone configures it. So: unset/malformed now falls +// back to a real default (DEFAULT_GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP) rather than "no cap" -- this IS a behavior +// change for any install that never set the env var. An operator who genuinely wants no cap sets the env var +// to the literal string "off" (a load-bearing explicit opt-out, distinct from "unset"), mirroring the +// explicit-null-means-something idiom used elsewhere in this codebase (e.g. blacklistLabel). +const GLOBAL_ENV_KEY = "GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP"; +const GLOBAL_MINER_ENV_KEY = "GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP_MINER"; +const OFF_SENTINEL = "off"; + +/** Default install-wide cap for a non-miner actor when {@link GLOBAL_ENV_KEY} is unset or malformed (#4511). */ +export const DEFAULT_GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP = 20; +/** Default install-wide cap for a CONFIRMED official Gittensor miner (#4511): higher than the human default + * because a legitimate fleet spread across many repos in one install is expected to run more concurrent open + * items than a single human contributor, without being unlimited. Applies ONLY once the author is verified + * via the same official-miner-detection path the rest of the codebase already trusts for this purpose + * (getCachedOfficialMinerDetection) -- an unverified/unconfirmed actor always gets the human default. */ +export const DEFAULT_GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP_MINER = 50; + +function resolveCapEnv(raw: string | undefined, fallback: number): number | null { + if (typeof raw !== "string" || raw.trim() === "") return fallback; + if (raw.trim().toLowerCase() === OFF_SENTINEL) return null; + const parsed = Number(raw); + // A malformed value (fractional/non-positive/non-numeric) falls back to the SAME default an unset env var + // would use, not to "no cap" -- a typo in an operator's .env must never silently disable this defense. + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) return fallback; + return parsed; +} + +/** Resolve the install-wide open-item cap for an ordinary (non-miner) actor. `null` means explicitly disabled + * (env var set to `"off"`) -- everything else, including unset, resolves to a real number. Never throws. + * Unlike the per-repo cap, this install-wide cap is not clamped to the per-repo live-check budget because the + * install-wide verifier loads and verifies a larger row set. */ +export function resolveGlobalContributorOpenItemCap(env: { GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP?: string | undefined }): number | null { + return resolveCapEnv(env[GLOBAL_ENV_KEY], DEFAULT_GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP); +} + +/** Resolve the install-wide open-item cap for a CONFIRMED official Gittensor miner (#4511) -- same shape and + * `"off"` escape hatch as {@link resolveGlobalContributorOpenItemCap}, but with a fleet-appropriate default. + * Callers must only use this once the actor's miner status is independently verified; this function does not + * itself check identity. */ +export function resolveGlobalContributorOpenItemCapForMiner(env: { GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP_MINER?: string | undefined }): number | null { + return resolveCapEnv(env[GLOBAL_MINER_ENV_KEY], DEFAULT_GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP_MINER); +} diff --git a/src/settings/auto-close-exempt.ts b/src/settings/auto-close-exempt.ts index 7e4f0614dc..c419cda55a 100644 --- a/src/settings/auto-close-exempt.ts +++ b/src/settings/auto-close-exempt.ts @@ -1,57 +1,5 @@ -// Shared repo-scoped exemption list (#2463) for gittensory's deterministic anti-abuse auto-close/throttle -// mechanisms — currently the review-nag cooldown; intended to be reused by the per-contributor open-item cap -// (#2270) once that lands, rather than each feature growing its own duplicate whitelist. A maintainer-named -// GitHub login here is NEVER throttled or closed by either mechanism, on top of the standing owner/admin/ -// automation-bot exemption every such mechanism already honors. Config-driven and layered the same as other -// settings (`.loopover.yml` > DB), never hard-coded for any repo. Mirrors contributor-blacklist.ts's shape -// (normalize → validated list + warnings), minus the reason/evidence metadata a ban carries that an exemption -// doesn't need. -// A trailing `[bot]` is a real, common GitHub App-actor login shape (e.g. `dependabot[bot]`, `sentry[bot]`) -- -// this is exactly the kind of third-party automation identity a maintainer needs to exempt (a repo-specific bot -// integration the hardcoded, install-wide well-known-bot set in agent-actions.ts has no way to know about), so -// the base GitHub-login pattern (1-39 chars, alphanumeric/single-hyphens) is extended with an optional literal -// `[bot]` suffix rather than rejecting every bot-shaped login outright. -const GITHUB_LOGIN = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}(?:\[bot\])?$/; -const MAX_ENTRIES = 500; - -/** Normalize a raw exempt-logins value (DB JSON or `.loopover.yml`) into a validated, de-duplicated list of - * GitHub logins. Never throws: malformed entries are dropped with a warning. De-dup is case-insensitive (the - * FIRST occurrence's casing is kept). */ -export function normalizeAutoCloseExemptLogins(input: unknown): { logins: string[]; warnings: string[] } { - const warnings: string[] = []; - if (input === undefined || input === null) return { logins: [], warnings }; - if (!Array.isArray(input)) { - warnings.push("autoCloseExemptLogins must be a list of GitHub logins; ignoring it."); - return { logins: [], warnings }; - } - const logins: string[] = []; - const seen = new Set(); - for (const [index, raw] of input.entries()) { - if (logins.length >= MAX_ENTRIES) { - warnings.push(`autoCloseExemptLogins is capped at ${MAX_ENTRIES} entries; dropping the rest.`); - break; - } - if (typeof raw !== "string") { - warnings.push(`autoCloseExemptLogins[${index}] must be a string login; ignoring it.`); - continue; - } - const login = raw.trim(); - if (!GITHUB_LOGIN.test(login)) { - warnings.push(`autoCloseExemptLogins[${index}] is not a valid GitHub login; ignoring it.`); - continue; - } - const key = login.toLowerCase(); - if (seen.has(key)) continue; // first occurrence wins - seen.add(key); - logins.push(login); - } - return { logins, warnings }; -} - -/** Case-insensitive membership check against the resolved exempt-logins list. Absent/empty list ⇒ never exempt - * (the safe default — an unconfigured repo exempts no one beyond the standing owner/admin/bot rule). */ -export function isAutoCloseExempt(login: string | null | undefined, exemptLogins: readonly string[] | undefined): boolean { - if (!login) return false; - const lower = login.toLowerCase(); - return (exemptLogins ?? []).some((entry) => entry.toLowerCase() === lower); -} +// auto-close-exempt, converged onto @loopover/engine (#4879). This src/ file was a hand-maintained twin of the +// engine copy; it is now a thin re-export shim so the single implementation lives at +// packages/loopover-engine/src/settings/auto-close-exempt.ts (imported via relative source path, not the published +// package, to match this repo's existing engine-consumption convention — see src/signals/check-summary.ts). +export * from "../../packages/loopover-engine/src/settings/auto-close-exempt"; diff --git a/src/settings/global-contributor-cap.ts b/src/settings/global-contributor-cap.ts index 5a30b980c8..a84d2b13ac 100644 --- a/src/settings/global-contributor-cap.ts +++ b/src/settings/global-contributor-cap.ts @@ -1,55 +1,4 @@ -// Install-wide contributor open-item cap (#2562, anti-abuse): a self-hosted install that gates multiple repos -// shares ONE database, but the per-repo contributorOpenPrCap/contributorOpenIssueCap (repository-settings.ts) -// only ever counts open items on the SAME repo -- an actor spreading low-volume spam/farming PRs across several -// gated repos in that install never trips any single repo's cap. This is cross-REPO-within-one-install only (no -// federation, no cross-instance privacy design): a same-database aggregate against every repo this install -// already tracks. Deliberately an env var (not a per-repo `.loopover.yml`/DB field like the caps above) -- -// this setting aggregates ACROSS repos, so it cannot be "this repo's" setting; it belongs to the install as a -// whole, mirroring how global_contributor_blacklist is a tenant-free singleton rather than a per-repo column. -// -// #4511 (AMS-readiness follow-up): "unset ⇒ null ⇒ no cap" was the ONLY defense against one identity farming -// PRs across every gated repo in an install, and it was off unless an operator proactively opted in AND -// remembered to pre-size it. That's backwards for a fleet-scale actor -- fail-safe means a sane cap exists by -// default, not that protection is silently absent until someone configures it. So: unset/malformed now falls -// back to a real default (DEFAULT_GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP) rather than "no cap" -- this IS a behavior -// change for any install that never set the env var. An operator who genuinely wants no cap sets the env var -// to the literal string "off" (a load-bearing explicit opt-out, distinct from "unset"), mirroring the -// explicit-null-means-something idiom used elsewhere in this codebase (e.g. blacklistLabel). -const GLOBAL_ENV_KEY = "GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP"; -const GLOBAL_MINER_ENV_KEY = "GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP_MINER"; -const OFF_SENTINEL = "off"; - -/** Default install-wide cap for a non-miner actor when {@link GLOBAL_ENV_KEY} is unset or malformed (#4511). */ -export const DEFAULT_GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP = 20; -/** Default install-wide cap for a CONFIRMED official Gittensor miner (#4511): higher than the human default - * because a legitimate fleet spread across many repos in one install is expected to run more concurrent open - * items than a single human contributor, without being unlimited. Applies ONLY once the author is verified - * via the same official-miner-detection path the rest of the codebase already trusts for this purpose - * (getCachedOfficialMinerDetection) -- an unverified/unconfirmed actor always gets the human default. */ -export const DEFAULT_GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP_MINER = 50; - -function resolveCapEnv(raw: string | undefined, fallback: number): number | null { - if (typeof raw !== "string" || raw.trim() === "") return fallback; - if (raw.trim().toLowerCase() === OFF_SENTINEL) return null; - const parsed = Number(raw); - // A malformed value (fractional/non-positive/non-numeric) falls back to the SAME default an unset env var - // would use, not to "no cap" -- a typo in an operator's .env must never silently disable this defense. - if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) return fallback; - return parsed; -} - -/** Resolve the install-wide open-item cap for an ordinary (non-miner) actor. `null` means explicitly disabled - * (env var set to `"off"`) -- everything else, including unset, resolves to a real number. Never throws. - * Unlike the per-repo cap, this install-wide cap is not clamped to the per-repo live-check budget because the - * install-wide verifier loads and verifies a larger row set. */ -export function resolveGlobalContributorOpenItemCap(env: { GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP?: string | undefined }): number | null { - return resolveCapEnv(env[GLOBAL_ENV_KEY], DEFAULT_GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP); -} - -/** Resolve the install-wide open-item cap for a CONFIRMED official Gittensor miner (#4511) -- same shape and - * `"off"` escape hatch as {@link resolveGlobalContributorOpenItemCap}, but with a fleet-appropriate default. - * Callers must only use this once the actor's miner status is independently verified; this function does not - * itself check identity. */ -export function resolveGlobalContributorOpenItemCapForMiner(env: { GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP_MINER?: string | undefined }): number | null { - return resolveCapEnv(env[GLOBAL_MINER_ENV_KEY], DEFAULT_GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP_MINER); -} +// global-contributor-cap, extracted to @loopover/engine (#4879). Thin re-export shim; the implementation lives at +// packages/loopover-engine/src/settings/global-contributor-cap.ts (imported via relative source path, not the +// published package, to match this repo's existing engine-consumption convention — see src/signals/check-summary.ts). +export * from "../../packages/loopover-engine/src/settings/global-contributor-cap"; diff --git a/src/settings/moderation-rules.ts b/src/settings/moderation-rules.ts index 787bcb4d24..cdd79e698c 100644 --- a/src/settings/moderation-rules.ts +++ b/src/settings/moderation-rules.ts @@ -1,128 +1,5 @@ -// Centralized moderation-rules engine (generic self-host feature, #selfhost-mod-engine). A single modular -// layer over the three EXISTING anti-abuse mechanisms (contributor cap, blacklist, review-nag) that already -// short-circuit a PR's disposition: every time one of them fires against a non-exempt contributor, it counts -// toward that login's install-wide violation tally (the shared `audit_events` ledger, keyed by actor). At -// >=1 lifetime violation the contributor is labeled with `warningLabel`; at >=`banThreshold` they are labeled -// `bannedLabel` and (when `autoBlacklistOnBan`) auto-added to the existing global contributor blacklist -- -// the SAME "permanent two-strikes" enforcement an already-banned login gets. -// -// Config-as-code, layered the same as every other setting: a global default (the whole layer can be off, -// which rules count, the label text, the threshold, whether a ban auto-enforces) with a PER-REPO override -// that can turn the layer off/on for just that repo and override which rules feed IT specifically. NEVER -// hard-coded for any one repo -- a self-hoster's own `.loopover.yml`/dashboard settings choose everything. - -/** The anti-abuse mechanisms this engine can count violations from -- the three ORIGINAL mechanisms - * (contributor cap, blacklist, review-nag) plus review-evasion (#review-evasion-protection: a contributor - * closing/converting-to-draft their own PR to dodge an active review). Kept as a closed union (not an open - * string) so an unrecognized value is always a normalization error, never silently accepted. */ -export type ModerationRuleType = "contributor_cap" | "blacklist" | "review_nag" | "review_evasion"; - -const ALL_MODERATION_RULE_TYPES: readonly ModerationRuleType[] = ["contributor_cap", "blacklist", "review_nag", "review_evasion"]; - -/** The `audit_events.event_type` recorded for each rule's violation -- namespaced under `moderation.violation.*` - * so a cross-eventType, cross-repo count query (see `db/repositories.ts`) can scope to exactly this family. */ -export const MODERATION_VIOLATION_EVENT_TYPE: Record = { - contributor_cap: "moderation.violation.contributor_cap", - blacklist: "moderation.violation.blacklist", - review_nag: "moderation.violation.review_nag", - review_evasion: "moderation.violation.review_evasion", -}; - -export const DEFAULT_MODERATION_WARNING_LABEL = "mod:warning"; -export const DEFAULT_MODERATION_BANNED_LABEL = "mod:banned"; -export const DEFAULT_MODERATION_BAN_THRESHOLD = 5; -// Keep the decay lookback operationally bounded, mirroring MAX_REVIEW_NAG_COOLDOWN_DAYS -- repo-controlled -// config cannot overflow Date arithmetic. -export const MAX_MODERATION_VIOLATION_DECAY_DAYS = 3650; - -const MAX_LABEL_CHARS = 100; - -export type GlobalModerationConfig = { - enabled: boolean; - rules: ModerationRuleType[]; - warningLabel: string; - bannedLabel: string; - banThreshold: number; - // null = permanent/lifetime tally (never decays), matching the existing global-blacklist's permanent-ban - // philosophy. A positive integer = only violations within that many days count toward the threshold. - violationDecayDays: number | null; - autoBlacklistOnBan: boolean; -}; - -export const DEFAULT_GLOBAL_MODERATION_CONFIG: GlobalModerationConfig = { - enabled: false, - rules: [...ALL_MODERATION_RULE_TYPES], - warningLabel: DEFAULT_MODERATION_WARNING_LABEL, - bannedLabel: DEFAULT_MODERATION_BANNED_LABEL, - banThreshold: DEFAULT_MODERATION_BAN_THRESHOLD, - violationDecayDays: null, - autoBlacklistOnBan: true, -}; - -/** Normalize a raw moderation-rules list (DB JSON or `.loopover.yml`) into a validated, de-duplicated list - * of known rule types. Never throws: an unknown/malformed entry is dropped with a warning, matching the - * normalize-with-warnings shape every other settings list in this codebase already uses. */ -export function normalizeModerationRules(input: unknown): { rules: ModerationRuleType[]; warnings: string[] } { - const warnings: string[] = []; - if (input === undefined || input === null) return { rules: [], warnings }; - if (!Array.isArray(input)) { - warnings.push("moderationRules must be a list of rule type strings; ignoring it."); - return { rules: [], warnings }; - } - const rules: ModerationRuleType[] = []; - const seen = new Set(); - for (const [index, raw] of input.entries()) { - if (typeof raw !== "string" || !(ALL_MODERATION_RULE_TYPES as readonly string[]).includes(raw)) { - warnings.push(`moderationRules[${index}] is not a recognized rule type (expected one of ${ALL_MODERATION_RULE_TYPES.join(", ")}); ignoring it.`); - continue; - } - const rule = raw as ModerationRuleType; - if (seen.has(rule)) continue; - seen.add(rule); - rules.push(rule); - } - return { rules, warnings }; -} - -/** Normalize a raw moderation label value: empty/whitespace-only collapses to undefined (falls back to the - * caller's default), overlong is truncated. Never throws. Mirrors blacklistLabel/contributorCapLabel's - * shape, minus the explicit-null-means-"no label" case those close-coupled labels use -- a moderation label - * is always applied when the tier is reached, never suppressible to "no label at all". */ -export function normalizeModerationLabel(input: unknown): string | undefined { - if (typeof input !== "string") return undefined; - const trimmed = input.trim(); - if (trimmed.length === 0) return undefined; - return trimmed.slice(0, MAX_LABEL_CHARS); -} - -/** Effective rule set for one repo: an explicit per-repo override REPLACES the global list entirely (not a - * union) -- a repo opting out of counting review-nag toward the shared tally, for example, must be able to - * do so without also losing the ability to opt out of the others. Absent/undefined override ⇒ inherit the - * global list unchanged. */ -export function resolveEffectiveModerationRules(globalRules: readonly ModerationRuleType[], perRepoOverride: readonly ModerationRuleType[] | null | undefined): ModerationRuleType[] { - return perRepoOverride ? [...perRepoOverride] : [...globalRules]; -} - -export type ModerationGateMode = "inherit" | "off" | "enabled"; - -/** Whether the WHOLE moderation layer runs for one repo: the global master switch is authoritative; - * `off` lets a repo opt out while the global layer is enabled, and `enabled`/`inherit` both require the - * global switch to be on. */ -export function resolveModerationGateEnabled(globalEnabled: boolean, gateMode: ModerationGateMode): boolean { - if (!globalEnabled) return false; - if (gateMode === "off") return false; - return true; -} - -export type ModerationTier = "none" | "warning" | "banned"; - -/** Pure escalation decision: given the actor's TOTAL violation count (including the one that just fired, - * already recorded by the caller) and the configured ban threshold, which tier applies. A non-positive - * threshold (malformed config) can never be reached by a real count, so it degrades to "always banned once - * any violation exists" rather than throwing -- still a safe, non-silent failure mode for a misconfigured - * threshold, not a crash. */ -export function moderationTierForViolationCount(count: number, banThreshold: number): ModerationTier { - if (count <= 0) return "none"; - if (count >= banThreshold) return "banned"; - return "warning"; -} +// moderation-rules, converged onto @loopover/engine (#4879). This src/ file was a hand-maintained twin of the +// engine copy; it is now a thin re-export shim so the single implementation lives at +// packages/loopover-engine/src/settings/moderation-rules.ts (imported via relative source path, not the published +// package, to match this repo's existing engine-consumption convention — see src/signals/check-summary.ts). +export * from "../../packages/loopover-engine/src/settings/moderation-rules";