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
26 changes: 26 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8152,6 +8152,32 @@
"advisory",
"block"
]
},
"contributorBlacklist": {
"type": "array",
"items": {
"type": "object",
"properties": {
"login": {
"type": "string"
},
"reason": {
"type": "string"
},
"evidence": {
"type": "array",
"items": {
"type": "string"
}
},
"addedAt": {
"type": "string"
}
},
"required": [
"login"
]
}
}
},
"required": [
Expand Down
5 changes: 5 additions & 0 deletions migrations/0072_contributor_blacklist.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Per-repo contributor blacklist (#1425, anti-abuse): a JSON array of banned-login entries
-- ({ login, reason?, evidence?, addedAt? }) the converged engine deterministically closes a PR/issue against,
-- ahead of any merit/CI/AI analysis. Layered like other settings (.gittensory.yml > DB) and unioned with the
-- shared/global list at the point of use. Defaults to an empty list, so existing rows are byte-identical.
ALTER TABLE repository_settings ADD COLUMN contributor_blacklist_json TEXT NOT NULL DEFAULT '[]';
8 changes: 8 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "../auth/security";
import { normalizeGittBountySnapshot } from "../bounties/ingest";
import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization";
import { normalizeContributorBlacklist } from "../settings/contributor-blacklist";
import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model";
import {
countOpenIssues,
Expand Down Expand Up @@ -630,6 +631,12 @@ const repositorySettingsSchema = z.object({
commands: z.record(z.string().trim().min(1).max(64), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4)).optional(),
})
.default(DEFAULT_COMMAND_AUTHORIZATION_POLICY),
// Per-repo contributor blacklist (#1425). Loose by design — the DB layer normalizes/validates each entry
// (login pattern, public-safe metadata, de-dup, caps), so invalid entries are dropped on persist.
contributorBlacklist: z
.array(z.object({ login: z.string(), reason: z.string().optional(), evidence: z.array(z.string()).optional(), addedAt: z.string().optional() }))
.max(1000)
.default([]),
});

// #130 maintainer self-serve settings editor. A PATCH-style subset: every field optional so the maintainer
Expand Down Expand Up @@ -3362,6 +3369,7 @@ export function createApp() {
privateTrustEnabled: parsed.data.privateTrustEnabled,
badgeEnabled: parsed.data.badgeEnabled,
commandAuthorization: normalizeCommandAuthorizationPolicy(parsed.data.commandAuthorization).policy,
contributorBlacklist: normalizeContributorBlacklist(parsed.data.contributorBlacklist).entries,
}),
);
});
Expand Down
10 changes: 10 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ import type {
import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } from "../gittensor/api";
import { classifyMcpClientVersion, LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SUPPORTED_MCP_VERSION } from "../services/mcp-compatibility";
import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization";
import { normalizeContributorBlacklist } from "../settings/contributor-blacklist";
import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy, DEFAULT_AUTO_MAINTAIN_POLICY } from "../settings/autonomy";
import { decryptSecret, encryptSecret, sha256Hex } from "../utils/crypto";
import { jsonString, nowIso, parseJson, repoParts } from "../utils/json";
Expand Down Expand Up @@ -442,6 +443,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
agentPaused: false,
agentDryRun: false,
commandAuthorization: normalizeCommandAuthorizationPolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY).policy,
contributorBlacklist: [],
autonomy: {},
autoMaintain: { ...DEFAULT_AUTO_MAINTAIN_POLICY },
};
Expand Down Expand Up @@ -482,6 +484,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
agentPaused: row.agentPaused,
agentDryRun: row.agentDryRun,
commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson),
contributorBlacklist: parseContributorBlacklist(row.contributorBlacklistJson),
autonomy: parseAutonomyPolicy(row.autonomyJson),
autoMaintain: parseAutoMaintainPolicy(row.autoMaintainJson),
createdAt: row.createdAt,
Expand Down Expand Up @@ -526,6 +529,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
agentPaused: settings.agentPaused ?? false,
agentDryRun: settings.agentDryRun ?? false,
commandAuthorization: normalizeCommandAuthorizationPolicy(settings.commandAuthorization).policy,
contributorBlacklist: normalizeContributorBlacklist(settings.contributorBlacklist).entries,
autonomy: normalizeAutonomyPolicy(settings.autonomy),
autoMaintain: normalizeAutoMaintainPolicy(settings.autoMaintain),
};
Expand Down Expand Up @@ -568,6 +572,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
agentPaused: resolved.agentPaused,
agentDryRun: resolved.agentDryRun,
commandAuthorizationJson: jsonString(resolved.commandAuthorization),
contributorBlacklistJson: jsonString(resolved.contributorBlacklist),
autonomyJson: jsonString(resolved.autonomy),
autoMaintainJson: jsonString(resolved.autoMaintain),
updatedAt: nowIso(),
Expand Down Expand Up @@ -611,6 +616,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
agentPaused: resolved.agentPaused,
agentDryRun: resolved.agentDryRun,
commandAuthorizationJson: jsonString(resolved.commandAuthorization),
contributorBlacklistJson: jsonString(resolved.contributorBlacklist),
autonomyJson: jsonString(resolved.autonomy),
autoMaintainJson: jsonString(resolved.autoMaintain),
updatedAt: nowIso(),
Expand Down Expand Up @@ -5383,6 +5389,10 @@ function parseCommandAuthorizationPolicy(value: string): RepositorySettings["com
return normalizeCommandAuthorizationPolicy(parseJson<unknown>(value, null)).policy;
}

function parseContributorBlacklist(value: string): RepositorySettings["contributorBlacklist"] {
return normalizeContributorBlacklist(parseJson<unknown>(value, null)).entries;
}

function parseAutonomyPolicy(value: string): AutonomyPolicy {
return normalizeAutonomyPolicy(parseJson<unknown>(value, null));
}
Expand Down
2 changes: 2 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export const repositorySettings = sqliteTable("repository_settings", {
privateTrustEnabled: integer("private_trust_enabled", { mode: "boolean" }).notNull().default(true),
badgeEnabled: integer("badge_enabled", { mode: "boolean" }).notNull().default(false),
commandAuthorizationJson: text("command_authorization_json").notNull().default("{}"),
// Per-repo contributor blacklist (#1425): a JSON array of { login, reason?, evidence?, addedAt? } entries.
contributorBlacklistJson: text("contributor_blacklist_json").notNull().default("[]"),
autonomyJson: text("autonomy_json").notNull().default("{}"),
autoMaintainJson: text("auto_maintain_json").notNull().default("{}"),
agentPaused: integer("agent_paused", { mode: "boolean" }).notNull().default(false),
Expand Down
10 changes: 10 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,16 @@ export const RepositorySettingsSchema = z
default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])),
commands: z.record(z.string(), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"]))),
}),
contributorBlacklist: z
.array(
z.object({
login: z.string(),
reason: z.string().optional(),
evidence: z.array(z.string()).optional(),
addedAt: z.string().optional(),
}),
)
.optional(),
autonomy: z
.record(z.enum(["review", "request_changes", "approve", "merge", "close", "label"]), z.enum(["observe", "suggest", "propose", "auto_with_approval", "auto"]))
.optional(),
Expand Down
90 changes: 90 additions & 0 deletions src/settings/contributor-blacklist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Contributor blacklist (#1425, anti-abuse). Pure resolution + matching for the banned-login list the converged
// engine acts on. Config-driven and layered the same as other settings (`.gittensory.yml` > DB) and unioned with
// the shared/global list at the point of use — NEVER hard-coded for any repo. Logins are public data; entries
// carry only public-safe metadata (no wallets/hotkeys/trust-scores/private values). Mirrors the shape of
// command-authorization.ts (normalize → typed policy + warnings).
import type { ContributorBlacklistEntry } from "../types";

// GitHub logins: 1–39 chars, alphanumeric or single hyphens (not leading/trailing). Anything else is dropped so a
// malformed entry can never widen the match or break the close path.
const GITHUB_LOGIN = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/;
const MAX_ENTRIES = 1000;
const MAX_REASON_CHARS = 200;
const MAX_EVIDENCE = 10;
const MAX_EVIDENCE_CHARS = 500;

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/** Normalize a raw blacklist value (DB JSON or `.gittensory.yml`) into validated, de-duplicated entries. Never
* throws: malformed entries are dropped with a warning. De-dup is by case-insensitive login (the FIRST wins, so
* its richer metadata is kept). */
export function normalizeContributorBlacklist(input: unknown): { entries: ContributorBlacklistEntry[]; warnings: string[] } {
const warnings: string[] = [];
if (input === undefined || input === null) return { entries: [], warnings };
if (!Array.isArray(input)) {
warnings.push("contributorBlacklist must be a list of entries; ignoring it.");
return { entries: [], warnings };
}
const entries: ContributorBlacklistEntry[] = [];
const seen = new Set<string>();
for (const [index, raw] of input.entries()) {
if (entries.length >= MAX_ENTRIES) {
warnings.push(`contributorBlacklist is capped at ${MAX_ENTRIES} entries; dropping the rest.`);
break;
}
// Accept either a bare login string or a `{ login, ... }` object.
const record = typeof raw === "string" ? { login: raw } : raw;
if (!isRecord(record) || typeof record.login !== "string") {
warnings.push(`contributorBlacklist[${index}] needs a string login; ignoring it.`);
continue;
}
const login = record.login.trim();
if (!GITHUB_LOGIN.test(login)) {
warnings.push(`contributorBlacklist[${index}].login is not a valid GitHub login; ignoring it.`);
continue;
}
const key = login.toLowerCase();
if (seen.has(key)) continue; // first occurrence wins
seen.add(key);
const entry: ContributorBlacklistEntry = { login };
if (typeof record.reason === "string" && record.reason.trim().length > 0) entry.reason = record.reason.trim().slice(0, MAX_REASON_CHARS);
if (Array.isArray(record.evidence)) {
const evidence = record.evidence.filter((ref): ref is string => typeof ref === "string" && ref.trim().length > 0).map((ref) => ref.trim().slice(0, MAX_EVIDENCE_CHARS)).slice(0, MAX_EVIDENCE);
if (evidence.length > 0) entry.evidence = evidence;
}
if (typeof record.addedAt === "string" && record.addedAt.trim().length > 0) entry.addedAt = record.addedAt.trim();
entries.push(entry);
}
return { entries, warnings };
}

/** The blacklist entry matching `login` (case-insensitive), or null. */
export function findBlacklistEntry(login: string | null | undefined, entries: ContributorBlacklistEntry[]): ContributorBlacklistEntry | null {
if (!login) return null;
const key = login.toLowerCase();
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 {
return findBlacklistEntry(login, entries) !== null;
}

/** Union multiple blacklist sources (e.g. the shared/global list + the per-repo list) by case-insensitive login.
* A login on ANY source is blocked; the FIRST source's entry wins on a duplicate so earlier (more authoritative)
* metadata is preserved. Already-normalized inputs in, de-duplicated entries out. */
export function mergeContributorBlacklists(...lists: ContributorBlacklistEntry[][]): ContributorBlacklistEntry[] {
const merged: ContributorBlacklistEntry[] = [];
const seen = new Set<string>();
for (const list of lists) {
for (const entry of list) {
const key = entry.login.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
merged.push(entry);
}
}
return merged;
}
10 changes: 10 additions & 0 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { parse as parseYaml } from "yaml";
import type { GatePolicyPack, GateRuleMode, JsonValue, RepositorySettings } from "../types";
import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy } from "../settings/autonomy";
import { normalizeContributorBlacklist } from "../settings/contributor-blacklist";

export type FocusManifestSource = "repo_file" | "api_record" | "none";
export type FocusManifestLinkedIssuePolicy = "required" | "preferred" | "optional";
Expand Down Expand Up @@ -73,6 +74,7 @@ export type FocusManifestSettings = Partial<
| "autoMaintain"
| "agentPaused"
| "agentDryRun"
| "contributorBlacklist"
>
>;

Expand Down Expand Up @@ -493,6 +495,14 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
if (typeof r.autoMaintain === "object" && r.autoMaintain !== null && !Array.isArray(r.autoMaintain)) {
out.autoMaintain = normalizeAutoMaintainPolicy(r.autoMaintain);
}
// Contributor blacklist (#1425): `settings.contributorBlacklist` is a list of banned-login entries. Only set it
// when at least one VALID entry survives normalization, so a malformed block never blanks the DB-configured
// list via the resolver's `{...dbSettings, ...manifest.settings}` overlay. Normalization warnings are folded in.
if (r.contributorBlacklist !== undefined) {
const { entries, warnings: blacklistWarnings } = normalizeContributorBlacklist(r.contributorBlacklist);
warnings.push(...blacklistWarnings);
if (entries.length > 0) out.contributorBlacklist = entries;
}
return out;
}

Expand Down
18 changes: 18 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,11 @@ export type RepositorySettings = {
* (default false); optional so existing settings fixtures/callers need not be touched. */
badgeEnabled?: boolean | undefined;
commandAuthorization?: RepositoryCommandAuthorizationPolicy | undefined;
/** Per-repo contributor blacklist (#1425, anti-abuse): banned GitHub logins whose PRs/issues the engine
* deterministically closes ahead of merit review. Layered the same as other settings (`.gittensory.yml` >
* DB) and unioned with the shared/global list at the point of use. Always populated by the DB layer
* (default `[]`); optional so existing settings fixtures/callers need not be touched. */
contributorBlacklist?: ContributorBlacklistEntry[] | undefined;
/** Agent-layer autonomy dial (#773): per-action-class level. Always populated by the DB layer (default
* `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers
* need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */
Expand All @@ -578,6 +583,19 @@ export type RepositoryCommandAuthorizationPolicy = {
commands: Record<string, CommandAuthorizationRole[]>;
};

/** A blocked contributor (#1425, anti-abuse): a GitHub `login` plus optional PUBLIC metadata. The converged
* engine short-circuits a blacklisted author's PR/issue to a deterministic close ahead of any merit/CI/AI
* analysis. `login` is public data — entries NEVER carry wallets/hotkeys/trust-scores/private values. */
export type ContributorBlacklistEntry = {
login: string;
/** Why the account is blocked, e.g. `plagiarism` / `farming`. Free-text, public-safe. */
reason?: string | undefined;
/** Public PR/issue URLs (or other public refs) evidencing the block. */
evidence?: string[] | undefined;
/** ISO-8601 date the entry was added. */
addedAt?: string | undefined;
};

/** Agent-layer graduated autonomy (#773), least → most autonomous. `observe` is the deny-by-default floor:
* gittensory watches but never acts. `suggest`/`propose` surface guidance/concrete proposals without
* executing; `auto_with_approval` executes behind a human approval gate (#779); `auto` executes directly. */
Expand Down
Loading
Loading