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
25 changes: 25 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8970,6 +8970,31 @@
"typeLabelsEnabled": {
"type": "boolean"
},
"moderationGateMode": {
"type": "string",
"enum": [
"inherit",
"off",
"enabled"
]
},
"moderationRules": {
"type": "array",
"items": {
"type": "string",
"enum": [
"contributor_cap",
"blacklist",
"review_nag"
]
}
},
"moderationWarningLabel": {
"type": "string"
},
"moderationBannedLabel": {
"type": "string"
},
"typeLabels": {
"type": "object",
"properties": {
Expand Down
21 changes: 21 additions & 0 deletions migrations/0104_global_moderation_config.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Global moderation-rules engine config (#selfhost-mod-engine): singleton row, mirroring
-- global_contributor_blacklist and global_agent_controls's shape (one row, id = 'singleton'). Off by default
-- (enabled = 0) -- zero behavior change for an install that hasn't opted in. rules_json is the DEFAULT set of
-- the three existing anti-abuse mechanisms (contributor cap, blacklist, review-nag) that count toward a
-- contributor's shared, cross-repo violation tally; a repo can override its own participating rules via
-- repository_settings.moderation_rules_json. violation_decay_days is nullable: null = a permanent, never-
-- decaying lifetime tally (the default, matching the existing global-blacklist's permanent-ban philosophy);
-- a positive integer = only violations within that many days count toward ban_threshold.
CREATE TABLE IF NOT EXISTS global_moderation_config (
id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
rules_json TEXT NOT NULL DEFAULT '["contributor_cap","blacklist","review_nag"]',
warning_label TEXT NOT NULL DEFAULT 'mod:warning',
banned_label TEXT NOT NULL DEFAULT 'mod:banned',
ban_threshold INTEGER NOT NULL DEFAULT 5,
violation_decay_days INTEGER,
auto_blacklist_on_ban INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by TEXT
);
INSERT OR IGNORE INTO global_moderation_config (id) VALUES ('singleton');
10 changes: 10 additions & 0 deletions migrations/0105_repository_moderation_settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Per-repo overrides for the moderation-rules engine (#selfhost-mod-engine), layered over
-- global_moderation_config (0104). moderation_gate_mode defaults to 'inherit' (defers to the global master
-- switch) -- 'off'/'enabled' let one repo opt out of or into the whole layer regardless of the global default,
-- e.g. an operator piloting the feature on a single repo before flipping the global default on. The three
-- override columns are nullable: NULL means "inherit the global value", never "unset to empty/off" -- an
-- explicit repo-level empty rules list would be indistinguishable from "not configured" otherwise.
ALTER TABLE repository_settings ADD COLUMN moderation_gate_mode TEXT NOT NULL DEFAULT 'inherit';
ALTER TABLE repository_settings ADD COLUMN moderation_rules_json TEXT;
ALTER TABLE repository_settings ADD COLUMN moderation_warning_label TEXT;
ALTER TABLE repository_settings ADD COLUMN moderation_banned_label TEXT;
1 change: 1 addition & 0 deletions scripts/check-schema-drift.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const MIGRATIONS_DIR = process.env.CHECK_SCHEMA_DRIFT_DIR || "migrations";
export const RAW_SQL_ONLY_TABLES = new Set([
"global_agent_controls",
"global_contributor_blacklist",
"global_moderation_config",
"orb_enrollments",
"orb_export_cursor",
"orb_github_installations",
Expand Down
167 changes: 167 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ import { classifyMcpClientVersion, LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SUPPO
import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization";
import { normalizeContributorBlacklist } from "../settings/contributor-blacklist";
import { normalizeAutoCloseExemptLogins } from "../settings/auto-close-exempt";
import { DEFAULT_GLOBAL_MODERATION_CONFIG, MAX_MODERATION_VIOLATION_DECAY_DAYS, normalizeModerationLabel, normalizeModerationRules, type GlobalModerationConfig, type ModerationRuleType } from "../settings/moderation-rules";
import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy, DEFAULT_AUTO_MAINTAIN_POLICY } from "../settings/autonomy";
import { DEFAULT_TYPE_LABELS, normalizeTypeLabelSet } from "../settings/pr-type-label";
import { DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION, normalizeLinkedIssueLabelPropagationConfig } from "../review/linked-issue-label-propagation";
Expand Down Expand Up @@ -525,6 +526,10 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
commandRateLimitMaxPerWindow: 20,
commandRateLimitAiMaxPerWindow: 5,
commandRateLimitWindowHours: 24,
moderationGateMode: "inherit",
moderationRules: undefined,
moderationWarningLabel: undefined,
moderationBannedLabel: undefined,
};
}
return {
Expand Down Expand Up @@ -589,6 +594,10 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
commandRateLimitMaxPerWindow: normalizePositiveIntWithDefault(row.commandRateLimitMaxPerWindow, 20),
commandRateLimitAiMaxPerWindow: normalizePositiveIntWithDefault(row.commandRateLimitAiMaxPerWindow, 5),
commandRateLimitWindowHours: normalizePositiveIntWithDefault(row.commandRateLimitWindowHours, 24),
moderationGateMode: normalizeModerationGateMode(row.moderationGateMode),
moderationRules: parseModerationRulesColumn(row.moderationRulesJson),
moderationWarningLabel: normalizeModerationLabel(row.moderationWarningLabel),
moderationBannedLabel: normalizeModerationLabel(row.moderationBannedLabel),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
Expand Down Expand Up @@ -689,6 +698,10 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
commandRateLimitMaxPerWindow: normalizePositiveIntWithDefault(settings.commandRateLimitMaxPerWindow, 20),
commandRateLimitAiMaxPerWindow: normalizePositiveIntWithDefault(settings.commandRateLimitAiMaxPerWindow, 5),
commandRateLimitWindowHours: normalizePositiveIntWithDefault(settings.commandRateLimitWindowHours, 24),
moderationGateMode: normalizeModerationGateMode(settings.moderationGateMode),
moderationRules: settings.moderationRules,
moderationWarningLabel: normalizeModerationLabel(settings.moderationWarningLabel),
moderationBannedLabel: normalizeModerationLabel(settings.moderationBannedLabel),
} satisfies RepositorySettings;
const db = getDb(env.DB);
await db
Expand Down Expand Up @@ -755,6 +768,10 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
commandRateLimitMaxPerWindow: resolved.commandRateLimitMaxPerWindow,
commandRateLimitAiMaxPerWindow: resolved.commandRateLimitAiMaxPerWindow,
commandRateLimitWindowHours: resolved.commandRateLimitWindowHours,
moderationGateMode: resolved.moderationGateMode,
moderationRulesJson: resolved.moderationRules === undefined ? null : jsonString(resolved.moderationRules),
moderationWarningLabel: resolved.moderationWarningLabel ?? null,
moderationBannedLabel: resolved.moderationBannedLabel ?? null,
updatedAt: nowIso(),
})
.onConflictDoUpdate({
Expand Down Expand Up @@ -822,6 +839,10 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
commandRateLimitMaxPerWindow: resolved.commandRateLimitMaxPerWindow,
commandRateLimitAiMaxPerWindow: resolved.commandRateLimitAiMaxPerWindow,
commandRateLimitWindowHours: resolved.commandRateLimitWindowHours,
moderationGateMode: resolved.moderationGateMode,
moderationRulesJson: resolved.moderationRules === undefined ? null : jsonString(resolved.moderationRules),
moderationWarningLabel: resolved.moderationWarningLabel ?? null,
moderationBannedLabel: resolved.moderationBannedLabel ?? null,
updatedAt: nowIso(),
},
});
Expand Down Expand Up @@ -2345,6 +2366,139 @@ export async function countRecentAuditEventsForActorAndTarget(env: Env, actor: s
return row.count;
}

/** Moderation-rules engine (#selfhost-mod-engine): the actor's TOTAL violation count across every rule type in
* `eventTypes` and EVERY repo this install tracks (no targetKey/route scoping -- `audit_events` carries no
* repo/installation column at all, so this is inherently install-wide, mirroring the install-wide contributor
* cap's own use of this same table). `sinceIso` is optional: omitted ⇒ the PERMANENT lifetime tally (the
* default moderation-decay behavior); provided ⇒ only violations within that rolling window count, for an
* operator who configured `violationDecayDays`. */
export async function countModerationViolationsForActor(env: Env, actor: string, eventTypes: string[], sinceIso?: string): Promise<number> {
const db = getDb(env.DB);
const conditions = [eq(auditEvents.actor, actor), inArray(auditEvents.eventType, eventTypes)];
if (sinceIso !== undefined) conditions.push(gte(auditEvents.createdAt, sinceIso));
const [row] = await db
.select({ count: sql<number>`count(*)` })
.from(auditEvents)
.where(and(...conditions));
/* v8 ignore next -- count(*) always returns exactly one row; the empty-array guard only satisfies the destructure type. */
if (!row) return 0;
return row.count;
}

/** Moderation-rules engine: whether a violation has ALREADY been recorded for this EXACT (actor, eventType,
* targetKey) tuple. Deliberately NO time window (unlike hasRecentAuditEvent's sinceIso) -- "this PR/issue
* already contributed a violation of this kind to the tally" is permanently true once recorded, not
* something that should re-count on a later replay just because time has passed. */
export async function hasModerationViolationForTarget(env: Env, actor: string, eventType: string, targetKey: string): Promise<boolean> {
const db = getDb(env.DB);
const rows = await db
.select({ id: auditEvents.id })
.from(auditEvents)
.where(and(eq(auditEvents.actor, actor), eq(auditEvents.eventType, eventType), eq(auditEvents.targetKey, targetKey)))
.limit(1);
return rows.length > 0;
}

/** Moderation-rules engine: record one violation for `actor` under the given rule's `eventType` (see
* `MODERATION_VIOLATION_EVENT_TYPE` in settings/moderation-rules.ts). `targetKey` carries the repo#number,
* and -- unlike the COUNT query above, which deliberately does not scope by it -- IS the idempotency key here
* (#gate-flagged): a webhook redelivery or queue retry that re-executes an already-recorded close must not
* double-count the SAME enforcement action toward the ban threshold. Returns whether a NEW row was actually
* inserted (false for an already-recorded duplicate), so the caller can skip redundant escalation work
* (re-labeling, re-checking the ban threshold) when nothing new actually happened. Best-effort, not a hard
* guarantee under true concurrency (no unique constraint on audit_events for this) -- matches this
* codebase's other check-then-act coalescing helpers, and is more than sufficient for the sequential
* redelivery/retry pattern it defends against. */
export async function recordModerationViolation(env: Env, args: { eventType: string; actor: string; targetKey: string; repoFullName: string; ruleReason: string }): Promise<boolean> {
if (await hasModerationViolationForTarget(env, args.actor, args.eventType, args.targetKey)) return false;
await recordAuditEvent(env, {
eventType: args.eventType,
actor: args.actor,
targetKey: args.targetKey,
outcome: "completed",
detail: args.ruleReason,
metadata: { repoFullName: args.repoFullName },
});
return true;
}

// #gate-flagged: same non-clamping, non-rounding shape as normalizeOpenItemCap, PLUS an upper bound --
// unlike an ordinary open-item cap, this value feeds Date arithmetic on the LIVE close path
// (`Date.now() - violationDecayDays * 86400000`); an unbounded value (e.g. a typo adding extra zeros) can
// overflow into an Invalid Date, and calling .toISOString() on an Invalid Date THROWS, crashing the close.
// Clamped (Math.min), not dropped to null, mirroring normalizeReviewNagCooldownDays' own clamping shape for
// the same "still meaningful, just bounded" family of day-count settings.
function normalizeModerationDecayDays(value: number | null | undefined): number | null {
const parsed = normalizeOpenItemCap(value);
return parsed === null ? null : Math.min(parsed, MAX_MODERATION_VIOLATION_DECAY_DAYS);
}

/** Read the singleton global moderation-rules engine config (#selfhost-mod-engine). A missing table/row fails
* open to the FULL {@link DEFAULT_GLOBAL_MODERATION_CONFIG} (`enabled: false`) -- a DB hiccup on this path
* must never accidentally turn ON a layer capable of auto-banning a contributor across every gated repo.
* Malformed JSON in an otherwise-present row is narrower: only `rules_json` degrades (to an empty rules
* list, via `normalizeModerationRules`), while every other column is still read from the row as normal. */
export async function getGlobalModerationConfig(env: Env): Promise<GlobalModerationConfig> {
try {
const row = await env.DB.prepare(
"SELECT enabled, rules_json, warning_label, banned_label, ban_threshold, violation_decay_days, auto_blacklist_on_ban FROM global_moderation_config WHERE id = 'singleton'",
).first<{
enabled: number;
rules_json: string;
warning_label: string;
banned_label: string;
ban_threshold: number;
violation_decay_days: number | null;
auto_blacklist_on_ban: number;
}>();
if (!row) return DEFAULT_GLOBAL_MODERATION_CONFIG;
return {
enabled: row.enabled === 1,
rules: normalizeModerationRules(parseJson<unknown>(row.rules_json, null)).rules,
warningLabel: normalizeModerationLabel(row.warning_label) ?? DEFAULT_GLOBAL_MODERATION_CONFIG.warningLabel,
bannedLabel: normalizeModerationLabel(row.banned_label) ?? DEFAULT_GLOBAL_MODERATION_CONFIG.bannedLabel,
banThreshold: normalizePositiveIntWithDefault(row.ban_threshold, DEFAULT_GLOBAL_MODERATION_CONFIG.banThreshold),
violationDecayDays: normalizeModerationDecayDays(row.violation_decay_days),
autoBlacklistOnBan: row.auto_blacklist_on_ban === 1,
};
} catch {
return DEFAULT_GLOBAL_MODERATION_CONFIG;
}
}

/** Upsert the singleton global moderation-rules engine config. Input is normalized/validated once so malformed
* stored data never reaches enforcement. Returns the normalized persisted config for convenience/tests. */
export async function upsertGlobalModerationConfig(
env: Env,
input: Partial<GlobalModerationConfig> & { updatedBy?: string | null },
): Promise<GlobalModerationConfig> {
const current = await getGlobalModerationConfig(env);
const resolved: GlobalModerationConfig = {
enabled: input.enabled ?? current.enabled,
rules: input.rules ? normalizeModerationRules(input.rules as unknown).rules : current.rules,
warningLabel: normalizeModerationLabel(input.warningLabel) ?? current.warningLabel,
bannedLabel: normalizeModerationLabel(input.bannedLabel) ?? current.bannedLabel,
banThreshold: input.banThreshold !== undefined ? normalizePositiveIntWithDefault(input.banThreshold, current.banThreshold) : current.banThreshold,
violationDecayDays: input.violationDecayDays !== undefined ? normalizeModerationDecayDays(input.violationDecayDays) : current.violationDecayDays,
autoBlacklistOnBan: input.autoBlacklistOnBan ?? current.autoBlacklistOnBan,
};
await env.DB.prepare(
"INSERT INTO global_moderation_config (id, enabled, rules_json, warning_label, banned_label, ban_threshold, violation_decay_days, auto_blacklist_on_ban, updated_at, updated_by) VALUES ('singleton', ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled, rules_json = excluded.rules_json, warning_label = excluded.warning_label, banned_label = excluded.banned_label, ban_threshold = excluded.ban_threshold, violation_decay_days = excluded.violation_decay_days, auto_blacklist_on_ban = excluded.auto_blacklist_on_ban, updated_at = excluded.updated_at, updated_by = excluded.updated_by",
)
.bind(
resolved.enabled ? 1 : 0,
jsonString(resolved.rules),
resolved.warningLabel,
resolved.bannedLabel,
resolved.banThreshold,
resolved.violationDecayDays,
resolved.autoBlacklistOnBan ? 1 : 0,
input.updatedBy ?? null,
)
.run();
return resolved;
}

/** Whether `deliveryId` has ALREADY been recorded for this (actor, eventType, targetKey) within `sinceIso` --
* makes a counting/rate-limit check idempotent against a REDELIVERED or retried webhook event (GitHub can
* and does redeliver the same issue_comment event), which would otherwise increment the counter twice for
Expand Down Expand Up @@ -6005,6 +6159,19 @@ function normalizeCommandRateLimitPolicy(value: string | null | undefined): "off
return value === "hold" ? value : "off";
}

function normalizeModerationGateMode(value: string | null | undefined): "inherit" | "off" | "enabled" {
return value === "off" || value === "enabled" ? value : "inherit";
}

// NULL means "inherit the global rule set" (undefined), distinct from a normalized-but-empty list -- a repo
// that explicitly configured an empty moderationRules override (opting every rule out) must stay empty, not
// be coerced back to "inherit". Mirrors parseContributorBlacklist/parseAutoCloseExemptLogins's JSON-parse
// shape, except the column itself (not just malformed JSON) can be genuinely absent.
function parseModerationRulesColumn(value: string | null | undefined): RepositorySettings["moderationRules"] {
if (value === null || value === undefined) return undefined;
return normalizeModerationRules(parseJson<unknown>(value, null)).rules;
}

// A review-nag threshold/window is a discrete positive count, not a score — reuses the same non-clamping,
// non-rounding shape as contributorOpenPrCap's normalizeOpenItemCap (#2270): an invalid value (fractional,
// non-positive, non-finite) falls back to the given default rather than being silently coerced.
Expand Down
7 changes: 7 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ export const repositorySettings = sqliteTable("repository_settings", {
commandRateLimitMaxPerWindow: integer("command_rate_limit_max_per_window").notNull().default(20),
commandRateLimitAiMaxPerWindow: integer("command_rate_limit_ai_max_per_window").notNull().default(5),
commandRateLimitWindowHours: integer("command_rate_limit_window_hours").notNull().default(24),
// Moderation-rules engine (#selfhost-mod-engine): per-repo overrides layered over global_moderation_config.
// 'inherit' (default) defers to the global master switch; 'off'/'enabled' force this repo regardless of it.
moderationGateMode: text("moderation_gate_mode").notNull().default("inherit"),
// Nullable: null = inherit the global rule set / label text, never "unset to empty".
moderationRulesJson: text("moderation_rules_json"),
moderationWarningLabel: text("moderation_warning_label"),
moderationBannedLabel: text("moderation_banned_label"),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
});
Expand Down
4 changes: 4 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,10 @@ export const RepositorySettingsSchema = z
commandRateLimitMaxPerWindow: z.number().int().positive().optional(),
commandRateLimitAiMaxPerWindow: z.number().int().positive().optional(),
commandRateLimitWindowHours: z.number().int().positive().optional(),
moderationGateMode: z.enum(["inherit", "off", "enabled"]).optional(),
moderationRules: z.array(z.enum(["contributor_cap", "blacklist", "review_nag"])).optional(),
moderationWarningLabel: z.string().optional(),
moderationBannedLabel: z.string().optional(),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
})
Expand Down
Loading
Loading