Skip to content
Closed
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
6 changes: 6 additions & 0 deletions docs/review-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ public data, so entries carry only public-safe metadata (a `reason`, `evidence`
date) — never wallets, hotkeys, trust scores, or private values. `blacklistLabel` (default `slop`) is
the label the engine applies to a blacklisted author's PR.

A PR from a **blacklisted login** is labeled (`blacklistLabel`) and **closed deterministically** —
ahead of any merit/CI/AI analysis, with a sanitized close comment and **no AI call**. The close
short-circuits and **wins over the normal gate disposition**; it honors the autonomy dial and
`agentPaused` / `agentDryRun` exactly like any other agent action, and the owner and automation bots
are never auto-closed.

### Example `.gittensory.yml`

```yaml
Expand Down
15 changes: 14 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ import {
import { executeAgentRun, explainBlockersWithAgent, planNextWork, preflightBranchWithAgent, preparePrPacketWithAgent } from "../services/agent-orchestrator";
import { isAuthorizedGitHubSessionLogin, parseGitHubLoginList } from "../auth/security";
import { commandAuthorizationAllowedRoles, commandAuthorizationNeedsMinerDetection, evaluateCommandAuthorization } from "../settings/command-authorization";
import { findBlacklistEntry, isAuthorBlacklisted } from "../settings/contributor-blacklist";
import { autonomyRequiresApproval, isAgentConfigured, resolveAutonomy } from "../settings/autonomy";
import { isGlobalAgentPause, resolveAgentActionMode } from "../settings/agent-execution";
import { SWEEP_FANOUT_DEDUP_MS, isRegateSweepDraining, selectRegateCandidates } from "../settings/agent-sweep";
Expand Down Expand Up @@ -824,6 +825,11 @@ async function maybeRunAgentMaintenance(
ciToken,
});

// Contributor blacklist (#1425): resolve whether the PR author is on the repo's blacklist (the shared/global
// list unions in once its table lands). A match short-circuits the planner to a deterministic label + close
// ahead of merit/CI/AI; the configured label (default "slop") and the entry's public-safe reason drive it.
const blacklistEntry = findBlacklistEntry(pr.authorLogin, settings.contributorBlacklist);

const planned = planAgentMaintenanceActions({
conclusion: gate.conclusion,
blockerTitles: gate.blockers.map((blocker) => blocker.title),
Expand All @@ -846,6 +852,9 @@ async function maybeRunAgentMaintenance(
ciState: ciAggregate.ciState,
failingCheckNames: ciAggregate.failingDetails.map((detail) => detail.name),
ciRequiredContextsVerified: hasVerifiedRequiredContexts(requiredContexts),
...(blacklistEntry !== null ? { blacklistMatch: { matched: true, reason: blacklistEntry.reason } } : {}),
// Always threaded (the DB layer populates it, default "slop"); the planner applies its own fallback.
blacklistLabel: settings.blacklistLabel,
...(linkedIssueHardRule !== undefined ? { linkedIssueHardRule } : {}),
// Flag-then-close double-check: thread the loaded verify config so the planner FLAGS first then closes on
// re-verification (default ON). Only passed when a rule is on (the planner reads it only for a violation).
Expand Down Expand Up @@ -2673,7 +2682,11 @@ async function maybePublishPrPublicSurface(
// files so the review (+ grounding + RAG) sees the REAL diff even on a pre-detail-sync first review (FIX B);
// resolve only when the review will actually run (aiReviewMode !== off + a head SHA + not explicitly skipped)
// to keep gate-only and advisory-sweep repos free of an extra file resolve.
const aiReviewWillRun = !webhook.skipAiReview && settings.aiReviewMode !== "off" && Boolean(advisory.headSha);
// Contributor blacklist (#1425): a blocked author's PR is closed by the deterministic disposition, so it must
// NEVER spend an AI call — skip the AI review entirely when the author is blacklisted (the gate + disposition
// still run; the close fires there). Per-repo list now; the shared/global list unions in once its table lands.
const authorBlacklisted = isAuthorBlacklisted(author, settings.contributorBlacklist);
const aiReviewWillRun = !webhook.skipAiReview && settings.aiReviewMode !== "off" && Boolean(advisory.headSha) && !authorBlacklisted;
// Post a transient "🟪 reviewing…" placeholder BEFORE the AI runs so contributors see the bot
// is actively working rather than silent. In-place upsert: once the final verdict is ready it
// overwrites this comment. Best-effort — a failed post never aborts the review. (#reviewing-placeholder)
Expand Down
41 changes: 40 additions & 1 deletion src/settings/agent-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AI_JUDGMENT_BLOCKER_CODES, type GateCheckConclusion } from "../rules/ad
import { DEFAULT_AUTO_MAINTAIN_POLICY, autonomyRequiresApproval, isActingAutonomyLevel, resolveAutonomy } from "./autonomy";
import { isGuardrailHit } from "../signals/change-guardrail";
import { AGENT_LABEL_PENDING_CLOSURE } from "../review/linked-issue-hard-rules";
import { sanitizePublicComment } from "../github/commands";

// High-slop threshold default when a repo hasn't set slopGateMinScore (mirrors the gate's `high` band).
const DEFAULT_SLOP_GATE_MIN_SCORE = 60;
Expand All @@ -18,6 +19,10 @@ const DEFAULT_SLOP_GATE_MIN_SCORE = 60;
// them and they never collide with project labels.
export const AGENT_LABEL_READY = "gittensory:ready-to-merge";
export const AGENT_LABEL_CHANGES = "gittensory:changes-requested";
// Default label applied to a blacklisted contributor's PR (#1425). NOT hardcoded into the action — it is
// configurable per-repo via `.gittensory.yml` (`settings.blacklistLabel`); the planner uses the resolved label
// and falls back to this default, so the disposition works regardless of the label a repo sets.
export const DEFAULT_BLACKLIST_LABEL = "slop";
// A PR that PASSES the gate but touches a hard-guardrail path is NOT ready to auto-merge — it is withheld
// for a human (the merge/approve/close dispositions are suppressed below). Labeling it `ready-to-merge`
// would be misleading (the label promises an auto-merge that never happens), so a guarded passing PR gets
Expand Down Expand Up @@ -56,7 +61,7 @@ export type PlannedAgentAction = {
// duplicate / slop / CI). The breaker downgrades ONLY "heuristic" closes; the deterministic close is EXEMPT
// (silently holding a close whose comment already promised closure would be incoherent). Absent on non-close
// actions; treated as a heuristic close only when explicitly tagged "heuristic".
closeKind?: "linked-issue-hard-rule" | "heuristic";
closeKind?: "linked-issue-hard-rule" | "blacklist" | "heuristic";
expectedHeadSha?: string;
};

Expand Down Expand Up @@ -109,6 +114,16 @@ export type AgentActionPlanInput = {
// AI verdicts). It still NEVER fires for the owner or automation bots (the `isContributor` guard). Absent /
// not-violated ⇒ no effect.
linkedIssueHardRule?: { violated: boolean; reason: string | null } | undefined;
// Contributor blacklist (#1425, anti-abuse): when the PR author is on the resolved blacklist (per-repo ∪
// global), the disposition SHORT-CIRCUITS to a deterministic close ahead of ALL merit/CI/AI analysis — the
// banned account never gets merit-reviewed or auto-merged. Zero-hallucination (not an AI judgment), so its
// close is EXEMPT from the AI-refutation breaker (closeKind "blacklist"). Fires for a CONTRIBUTOR only
// (owner/automation bots are never auto-closed). `reason` is the entry's public-safe reason (or null). Absent /
// not-matched ⇒ no effect. The close comment is sanitized through the public-safe sanitizer before posting.
blacklistMatch?: { matched: boolean; reason: string | null | undefined } | undefined;
// The repo-configured label applied to a blacklisted author's PR (#1425), resolved from `.gittensory.yml`.
// Absent ⇒ the default (`DEFAULT_BLACKLIST_LABEL` = "slop"), so the disposition works regardless of the label set.
blacklistLabel?: string | undefined;
// Flag-then-close double-check for the linked-issue hard rule (#linked-issue-verify-before-close). When
// `verifyBeforeClose` is true (the default), a violation FLAGS the PR (pending-closure label + warning comment)
// on first detection and only CLOSES on a LATER evaluation when the violation STILL holds AND the PR already
Expand Down Expand Up @@ -215,6 +230,12 @@ function closeMessage(reasons: string[]): string {
return `Gittensory is closing this pull request on the maintainer's behalf (${reasons.join("; ")}). This is an automated maintenance action — to pursue this change, please open a new pull request with the issues resolved. Closed PRs are re-reviewed automatically, so an inaccurate close may be reopened, but that does not guarantee it can merge (e.g. if conflicts or failing CI remain).`;
}

// The close comment for a blacklisted author (#1425). The maintainer-supplied `reason` is sanitized by the caller
// through the public-safe sanitizer, so this never leaks a private term into the public PR thread.
function blacklistCloseMessage(reason: string): string {
return `Gittensory is closing this pull request on the maintainer's behalf: ${reason}. This account is blocked from contributing to this repository, so the change was not reviewed on its merits. This is an automated maintenance action.`;
}

/**
* Plan the maintainer auto-maintain actions for one PR. Returns a COHERENT set (never both approve and
* request-changes; never both merge and close), each entry already filtered to an acting autonomy class.
Expand All @@ -232,6 +253,24 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
const acting = (actionClass: AgentActionClass) => isActingAutonomyLevel(level(actionClass));
const approval = (actionClass: AgentActionClass) => autonomyRequiresApproval(level(actionClass));

// Contributor blacklist (#1425): a banned author's PR is a DETERMINISTIC short-circuit — it SHORT-CIRCUITS to a
// label + close AHEAD of all merit/CI/gate/AI analysis (this returns before any of it), so a blocked account is
// never merit-reviewed or auto-merged. Fires for a CONTRIBUTOR only (owner/automation bots are NEVER auto-closed,
// the standing rule). Zero-hallucination, so its close is `closeKind: "blacklist"` — exempt from the AI-refutation
// breaker like the linked-issue hard rule. The `acting`/`approval` gates here + the executor's pause/dry-run/
// kill-switch gate make it dry-run-able and approval-gated exactly like every other action. The close comment is
// run through the public-safe sanitizer so a maintainer's reason can never leak a private term.
const blacklistContributor = !input.authorIsOwner && !input.authorIsAutomationBot;
if (input.blacklistMatch?.matched === true && blacklistContributor) {
const label = input.blacklistLabel ?? DEFAULT_BLACKLIST_LABEL;
if (acting("label")) actions.push({ actionClass: "label", requiresApproval: approval("label"), reason: "blacklisted contributor", label, labelOp: "add" });
if (acting("close")) {
const reason = input.blacklistMatch.reason ?? "this account is blocked from contributing to this repository";
actions.push({ actionClass: "close", requiresApproval: approval("close"), reason: "blacklisted contributor", closeComment: sanitizePublicComment(blacklistCloseMessage(reason)), closeKind: "blacklist" });
}
return actions;
}

// Only a SKIPPED gate (genuinely not evaluated) drives no action. A NEUTRAL gate (first-time-contributor
// grace, or eval-not-ready while state is still syncing) is gate-NON-BLOCKING: it flows to the disposition so
// the PR is merged (clean+green) or HELD with a label — never left silently undecided. (#harm-stop neutral-silent-stuck)
Expand Down
9 changes: 5 additions & 4 deletions src/settings/contributor-blacklist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,16 @@ export function normalizeContributorBlacklist(input: unknown): { entries: Contri
return { entries, warnings };
}

/** The blacklist entry matching `login` (case-insensitive), or null. */
export function findBlacklistEntry(login: string | null | undefined, entries: ContributorBlacklistEntry[]): ContributorBlacklistEntry | null {
/** The blacklist entry matching `login` (case-insensitive), or null. Tolerates an absent list (treated as empty)
* so callers can pass the optional `settings.contributorBlacklist` directly. */
export function findBlacklistEntry(login: string | null | undefined, entries: ContributorBlacklistEntry[] | undefined): ContributorBlacklistEntry | null {
if (!login) return null;
const key = login.toLowerCase();
return entries.find((entry) => entry.login.toLowerCase() === key) ?? null;
return (entries ?? []).find((entry) => entry.login.toLowerCase() === key) ?? null;
}

/** True iff `login` is on the resolved blacklist. */
export function isAuthorBlacklisted(login: string | null | undefined, entries: ContributorBlacklistEntry[]): boolean {
export function isAuthorBlacklisted(login: string | null | undefined, entries: ContributorBlacklistEntry[] | undefined): boolean {
return findBlacklistEntry(login, entries) !== null;
}

Expand Down
50 changes: 49 additions & 1 deletion test/unit/agent-actions.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { AGENT_LABEL_CHANGES, AGENT_LABEL_NEEDS_REVIEW, AGENT_LABEL_READY, downgradeCloseToHold, downgradeMergeToHold, isProtectedAutomationAuthor, planAgentMaintenanceActions, type AgentActionPlanInput, type PlannedAgentAction } from "../../src/settings/agent-actions";
import { AGENT_LABEL_CHANGES, AGENT_LABEL_NEEDS_REVIEW, AGENT_LABEL_READY, DEFAULT_BLACKLIST_LABEL, downgradeCloseToHold, downgradeMergeToHold, isProtectedAutomationAuthor, planAgentMaintenanceActions, type AgentActionPlanInput, type PlannedAgentAction } from "../../src/settings/agent-actions";
import { AGENT_LABEL_PENDING_CLOSURE } from "../../src/review/linked-issue-hard-rules";
import type { GateCheckConclusion } from "../../src/rules/advisory";

Expand Down Expand Up @@ -685,3 +685,51 @@ describe("downgradeCloseToHold — close-precision circuit-breaker (#close-preci
expect(heldNullish.find((a) => a.actionClass === "label" && a.label === AGENT_LABEL_NEEDS_REVIEW)?.requiresApproval).toBe(false);
});
});

describe("contributor blacklist short-circuit (#1425)", () => {
const blacklisted = (extra: Partial<AgentActionPlanInput> = {}) =>
input({ conclusion: "success", autonomy: { label: "auto", close: "auto", approve: "auto", merge: "auto" }, blacklistMatch: { matched: true, reason: "plagiarism" }, ...extra });

it("labels + closes a blacklisted contributor's PR, winning over a passing gate (no merit review / merge)", () => {
const plan = planAgentMaintenanceActions(blacklisted());
expect(classes(plan)).toEqual(["label", "close"]); // short-circuit: no approve/merge despite a SUCCESS gate
expect(plan[0]).toMatchObject({ actionClass: "label", label: DEFAULT_BLACKLIST_LABEL, labelOp: "add" });
expect(plan[1]).toMatchObject({ actionClass: "close", closeKind: "blacklist" });
expect(plan[1]?.closeComment).toContain("plagiarism");
expect(plan[1]?.closeComment).toContain("blocked from contributing");
});

it("uses the repo-configured blacklistLabel, defaulting to 'slop' when unset", () => {
expect(planAgentMaintenanceActions(blacklisted({ blacklistLabel: "abuse" }))[0]).toMatchObject({ label: "abuse" });
expect(DEFAULT_BLACKLIST_LABEL).toBe("slop");
expect(planAgentMaintenanceActions(blacklisted())[0]).toMatchObject({ label: "slop" });
});

it("uses a default reason when the entry has none", () => {
const plan = planAgentMaintenanceActions(blacklisted({ blacklistMatch: { matched: true, reason: null } }));
expect(plan[1]?.closeComment).toContain("blocked from contributing");
});

it("fires AHEAD of CI — closes even while CI is still pending (not the pending early-return)", () => {
expect(classes(planAgentMaintenanceActions(blacklisted({ ciState: "pending" })))).toEqual(["label", "close"]);
});

it("NEVER fires for the owner or an automation bot (standing rule) — the PR falls through to normal disposition", () => {
expect(classes(planAgentMaintenanceActions(blacklisted({ authorIsOwner: true })))).not.toContain("close");
expect(classes(planAgentMaintenanceActions(blacklisted({ authorIsAutomationBot: true })))).not.toContain("close");
});

it("no-ops when the author is not matched (normal disposition runs)", () => {
expect(classes(planAgentMaintenanceActions(blacklisted({ blacklistMatch: { matched: false, reason: null } })))).not.toContain("close");
});

it("respects autonomy: observe plans nothing (still short-circuits); label-only labels but does not close", () => {
expect(planAgentMaintenanceActions(blacklisted({ autonomy: {} }))).toEqual([]);
expect(classes(planAgentMaintenanceActions(blacklisted({ autonomy: { label: "auto" } })))).toEqual(["label"]);
});

it("sanitizes the close comment — a forbidden term in the reason never reaches the public thread", () => {
const plan = planAgentMaintenanceActions(blacklisted({ blacklistMatch: { matched: true, reason: "leaked a wallet address" } }));
expect(plan[1]?.closeComment).not.toMatch(/wallet/i);
});
});
5 changes: 5 additions & 0 deletions test/unit/contributor-blacklist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ describe("findBlacklistEntry / isAuthorBlacklisted", () => {
expect(isAuthorBlacklisted("stranger", list)).toBe(false);
expect(isAuthorBlacklisted(null, list)).toBe(false);
});

it("tolerates an absent list (treated as empty) so callers can pass the optional setting directly", () => {
expect(findBlacklistEntry("anyone", undefined)).toBeNull();
expect(isAuthorBlacklisted("anyone", undefined)).toBe(false);
});
});

describe("mergeContributorBlacklists (global ∪ per-repo)", () => {
Expand Down
Loading
Loading