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
6 changes: 6 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,9 @@ settings:
# above will reuse this list too), on top of the standing owner/admin/automation-bot exemption.
# List of GitHub logins. Default: [] (no additional exemptions).
# autoCloseExemptLogins: [some-trusted-regular]

# Account-age throttle (#2561, anti-abuse): a PR from an account younger than this many days gets
# the newAccountLabel below -- friction/visibility only, NEVER an automatic close on account age alone.
# Never fires for the repo owner, admin logins, or automation bots. PR-path only. Off by default.
# accountAgeThresholdDays: null # Positive integer, or null/omitted to disable. Default: null (off).
# newAccountLabel: new-account # Label applied to a below-threshold-age account's PR. Default: new-account.
9 changes: 9 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8688,6 +8688,15 @@
"nullable": true,
"minimum": 0,
"exclusiveMinimum": true
},
"accountAgeThresholdDays": {
"type": "integer",
"nullable": true,
"minimum": 0,
"exclusiveMinimum": true
},
"newAccountLabel": {
"type": "string"
}
},
"required": [
Expand Down
6 changes: 6 additions & 0 deletions migrations/0096_account_age_throttle.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Account-age throttle (#2561, anti-abuse): a friction/visibility signal for the classic ban-evasion pattern
-- (a banned login gets a fresh account the same day). Defaults are byte-identical to today:
-- account_age_threshold_days defaults to NULL (off), so existing repos see no behavior change until they
-- configure a threshold. Never auto-closes on account age alone -- label and/or a tighter effective cap only.
ALTER TABLE repository_settings ADD COLUMN account_age_threshold_days INTEGER;
ALTER TABLE repository_settings ADD COLUMN new_account_label TEXT NOT NULL DEFAULT 'new-account';
10 changes: 10 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
reviewNagLabel: "review-nag-cooldown",
autoCloseExemptLogins: [],
requireFreshRebaseWindowMinutes: null,
accountAgeThresholdDays: null,
newAccountLabel: "new-account",
};
}
return {
Expand Down Expand Up @@ -564,6 +566,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
reviewNagLabel: row.reviewNagLabel,
autoCloseExemptLogins: parseAutoCloseExemptLogins(row.autoCloseExemptLoginsJson),
requireFreshRebaseWindowMinutes: normalizeOpenItemCap(row.requireFreshRebaseWindowMinutes),
accountAgeThresholdDays: normalizeOpenItemCap(row.accountAgeThresholdDays),
newAccountLabel: row.newAccountLabel,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
Expand Down Expand Up @@ -649,6 +653,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewNagLabel: settings.reviewNagLabel ?? "review-nag-cooldown",
autoCloseExemptLogins: normalizeAutoCloseExemptLogins(settings.autoCloseExemptLogins).logins,
requireFreshRebaseWindowMinutes: normalizeOpenItemCap(settings.requireFreshRebaseWindowMinutes),
accountAgeThresholdDays: normalizeOpenItemCap(settings.accountAgeThresholdDays),
newAccountLabel: settings.newAccountLabel ?? "new-account",
};
const db = getDb(env.DB);
await db
Expand Down Expand Up @@ -704,6 +710,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewNagLabel: resolved.reviewNagLabel,
autoCloseExemptLoginsJson: jsonString(resolved.autoCloseExemptLogins),
requireFreshRebaseWindowMinutes: resolved.requireFreshRebaseWindowMinutes,
accountAgeThresholdDays: resolved.accountAgeThresholdDays,
newAccountLabel: resolved.newAccountLabel,
updatedAt: nowIso(),
})
.onConflictDoUpdate({
Expand Down Expand Up @@ -760,6 +768,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewNagLabel: resolved.reviewNagLabel,
autoCloseExemptLoginsJson: jsonString(resolved.autoCloseExemptLogins),
requireFreshRebaseWindowMinutes: resolved.requireFreshRebaseWindowMinutes,
accountAgeThresholdDays: resolved.accountAgeThresholdDays,
newAccountLabel: resolved.newAccountLabel,
updatedAt: nowIso(),
},
});
Expand Down
4 changes: 4 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ export const repositorySettings = sqliteTable("repository_settings", {
// Force-rebase-before-merge window in minutes (#2552): null = never force (default). Enforcement lands in
// runAgentMaintenancePlanAndExecute, not here.
requireFreshRebaseWindowMinutes: integer("require_fresh_rebase_window_minutes"),
// Account-age throttle (#2561, anti-abuse): null = off (default). Enforcement lands in
// runAgentMaintenancePlanAndExecute, not here.
accountAgeThresholdDays: integer("account_age_threshold_days"),
newAccountLabel: text("new_account_label").notNull().default("new-account"),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
});
Expand Down
29 changes: 29 additions & 0 deletions src/github/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,35 @@ export async function getRepositoryCollaboratorPermission(
return payload.permission ?? null;
}

/** Account-age throttle (#2561, anti-abuse): `GET /users/{login}` for `created_at`, so a repo can flag a
* freshly-created account as friction/visibility for the classic ban-evasion pattern (a banned login gets a
* fresh account the same day). `/users/{login}` is already classified "metadata" by `resolveGitHubCacheClass`
* (src/github/client.ts), so this reuses the existing response cache with no new caching infra. Returns null
* (fail-open) on any non-2xx/network error -- a lookup failure must never invent a "new account" finding. */
export async function getGithubUserCreatedAt(
env: Env,
installationId: number,
login: string,
): Promise<string | null> {
if (!login) return null;
try {
const token = await createInstallationToken(env, installationId);
const response = await timeoutFetch(
`https://api.github.com/users/${encodeURIComponent(login)}`,
{
headers: githubHeaders(`Bearer ${token}`),
githubRateLimitAdmission: true,
githubRateLimitAdmissionKey: githubRateLimitAdmissionKeyForInstallation(installationId),
},
);
if (!response.ok) return null;
const payload = (await response.json()) as { created_at?: unknown };
return typeof payload.created_at === "string" ? payload.created_at : null;
} catch {
return null;
}
}

// The App JWT is valid ~9 min (iat backdated 60s, exp +540s). Re-signing (RS256) it on EVERY call is wasteful CPU
// AND defeats response caching of App-level reads (/app/installations/{id}): the rotating JWT changes the
// auth-scoped response-cache key on every call, so the metadata cache class never hits for its heaviest caller
Expand Down
2 changes: 2 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,8 @@ export const RepositorySettingsSchema = z
reviewNagCooldownDays: z.number().int().positive().optional(),
reviewNagLabel: z.string().optional(),
autoCloseExemptLogins: z.array(z.string()).optional(),
accountAgeThresholdDays: z.number().int().positive().nullable().optional(),
newAccountLabel: z.string().optional(),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
})
Expand Down
43 changes: 41 additions & 2 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import {
createOrUpdateOverriddenGateCheckRun,
createOrUpdatePendingGateCheckRun,
createOrUpdateSkippedGateCheckRun,
getGithubUserCreatedAt,
getInstallationId,
getRepositoryCollaboratorPermission,
GITTENSORY_GATE_CHECK_NAME,
Expand Down Expand Up @@ -1978,16 +1979,54 @@ async function runAgentMaintenancePlanAndExecute(
settings.contributorBlacklist,
);

// Account-age throttle (#2561, anti-abuse): a friction/visibility signal for the classic ban-evasion pattern
// (a banned login gets a fresh account the same day) — NEVER an automatic close on account age alone. Off
// (null accountAgeThresholdDays, the default) ⇒ this block is a no-op, no extra GitHub API call at all.
// Fires for a CONTRIBUTOR only — same standing owner/admin/automation-bot exemption as every other
// anti-abuse mechanism above. The label is applied directly (fire-and-forget, matching mode gating) rather
// than threaded through the planner: this is advisory/visibility only, independent of the merit/CI/AI
// disposition the planner computes below. Gate finding: a direct label mutation still needs the SAME
// label-autonomy opt-in every other label write goes through (resolveAutonomy(..., "label") === "auto") —
// a repo that has not opted into automatic label actions must not have this throttle silently write labels.
let isNewAccount = false;
const accountAgeThresholdDays = settings.accountAgeThresholdDays;
if (typeof accountAgeThresholdDays === "number" && pr.authorLogin && !authorIsOwner && !authorIsAdmin && !authorIsAutomationBot) {
const createdAt = await getGithubUserCreatedAt(env, installationId, pr.authorLogin);
if (createdAt) {
const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000);
isNewAccount = ageDays < accountAgeThresholdDays;
}
if (isNewAccount && resolveAutonomy(settings.autonomy, "label") === "auto") {
const newAccountMode = resolveAgentActionMode({
globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)),
agentPaused: settings.agentPaused,
agentDryRun: settings.agentDryRun,
});
await ensurePullRequestLabel(env, installationId, repoFullName, pr.number, settings.newAccountLabel ?? "new-account", {
createMissingLabel: settings.createMissingLabel,
mode: newAccountMode,
}).catch(
/* v8 ignore next -- fail-safe: a label-application failure must never block the rest of the handler */
() => undefined,
);
}
}

// Per-contributor open-PR cap (#2270, anti-abuse): count this author's OTHER currently-open PRs on this repo
// (otherOpenPullRequests already excludes the current PR — see reconcileLiveDuplicateSiblings) plus this one,
// ranked by PR NUMBER (GitHub's own creation order, not webhook-arrival order) so a burst of near-simultaneous
// opens still ranks deterministically. Only matches when THIS PR's number is among the ones over the cap — an
// older sibling that was already under the cap when it was opened stays open. The owner/admin/automation-bot
// exemption is applied by the planner itself (defense-in-depth, mirroring how blacklistEntry above is resolved
// unconditionally and exempted only inside planAgentMaintenanceActions). Disabled (null/undefined cap, the
// default) ⇒ this block is a no-op.
// default) ⇒ this block is a no-op. A below-account-age-threshold author (#2561) gets a TIGHTER effective
// cap (half, rounded up, minimum 1) — visibility/friction, still never a close on account age by itself
// (the close, if any, is still tagged/reasoned as the ordinary contributor-cap close).
let contributorCapMatch: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" } | undefined;
const contributorOpenPrCap = settings.contributorOpenPrCap;
const contributorOpenPrCap =
isNewAccount && typeof settings.contributorOpenPrCap === "number"
? Math.max(1, Math.ceil(settings.contributorOpenPrCap / 2))
: settings.contributorOpenPrCap;
if (typeof contributorOpenPrCap === "number" && pr.authorLogin) {
const authorLoginLower = pr.authorLogin.toLowerCase();
const authorOpenPrNumbers = otherOpenPullRequests
Expand Down
12 changes: 12 additions & 0 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ export type FocusManifestSettings = Partial<
| "reviewNagCooldownDays"
| "reviewNagLabel"
| "autoCloseExemptLogins"
| "accountAgeThresholdDays"
| "newAccountLabel"
>
>;

Expand Down Expand Up @@ -867,6 +869,16 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
warnings.push(...exemptWarnings);
if (logins.length > 0) out.autoCloseExemptLogins = logins;
}
// Account-age throttle (#2561): an explicit yml `null` is load-bearing (clears a DB-configured threshold
// back to "off"), matching contributorOpenPrCap's own null-vs-omitted distinction above.
if (r.accountAgeThresholdDays === null) {
out.accountAgeThresholdDays = null;
} else {
const accountAgeThresholdDays = normalizeOptionalPositiveInteger(r.accountAgeThresholdDays, "settings.accountAgeThresholdDays", warnings);
if (accountAgeThresholdDays !== null) out.accountAgeThresholdDays = accountAgeThresholdDays;
}
const newAccountLabel = normalizeOptionalString(r.newAccountLabel, "settings.newAccountLabel", warnings);
if (newAccountLabel !== null) out.newAccountLabel = newAccountLabel;
return out;
}

Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,16 @@ export type RepositorySettings = {
* force -- a `mergeable_state: clean` read is trusted exactly as it is today. Layered like every other
* settings field (`.gittensory.yml` `gate.requireFreshRebaseWindow` > DB > `null`). */
requireFreshRebaseWindowMinutes?: number | null | undefined;
/** Account-age throttle (#2561, anti-abuse): a PR from an account younger than this many days gets the
* {@link newAccountLabel} and a tighter effective contributor cap -- friction/visibility, NEVER an
* automatic close on account age alone. `null`/undefined (default) = off, zero behavior change. Never
* fires for the repo owner, admin logins, or automation bots. PR-path only for now -- the issue-path
* enforcement `maybeCloseIssueOverContributorCap` already goes through does not yet read this setting. */
accountAgeThresholdDays?: number | null | undefined;
/** The label applied to a below-threshold-age account's PR (#2561), mirroring {@link blacklistLabel}'s
* configurable-with-fallback shape. Always populated by the DB layer (default `"new-account"`); optional so
* existing settings fixtures/callers need not be touched. */
newAccountLabel?: string | 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 Down
18 changes: 18 additions & 0 deletions test/unit/focus-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1384,6 +1384,24 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () =
expect(invalid.warnings.some((w) => /settings\.reviewNagCooldownDays/.test(w))).toBe(true);
});

it("parses + resolves the account-age throttle settings from the settings: block, overlaying the DB (#2561)", () => {
const manifest = parseFocusManifest({ settings: { accountAgeThresholdDays: 14, newAccountLabel: "fresh-account" } });
expect(manifest.settings.accountAgeThresholdDays).toBe(14);
expect(manifest.settings.newAccountLabel).toBe("fresh-account");
const eff = resolveEffectiveSettings({ accountAgeThresholdDays: null, newAccountLabel: "new-account" } as unknown as RepositorySettings, manifest);
expect(eff.accountAgeThresholdDays).toBe(14);
// An explicit yml `null` clears a DB-configured threshold back to off (load-bearing null).
const cleared = resolveEffectiveSettings({ accountAgeThresholdDays: 30 } as unknown as RepositorySettings, parseFocusManifest({ settings: { accountAgeThresholdDays: null } }));
expect(cleared.accountAgeThresholdDays).toBeNull();
// Omitted in yml ⇒ the DB-configured threshold survives untouched.
const noOverride = resolveEffectiveSettings({ accountAgeThresholdDays: 7 } as unknown as RepositorySettings, parseFocusManifest({}));
expect(noOverride.accountAgeThresholdDays).toBe(7);
// A non-positive threshold is dropped with a warning rather than silently coerced.
const invalid = parseFocusManifest({ settings: { accountAgeThresholdDays: 0 } });
expect(invalid.settings.accountAgeThresholdDays).toBeUndefined();
expect(invalid.warnings.some((w) => /settings\.accountAgeThresholdDays/.test(w))).toBe(true);
});

it("parses + resolves autoCloseExemptLogins from the settings: block, overlaying the DB (#2463)", () => {
const manifest = parseFocusManifest({ settings: { autoCloseExemptLogins: ["Trusted-Regular", "another-one", "-bad", 42 as never] } });
expect(manifest.settings.autoCloseExemptLogins).toEqual(["Trusted-Regular", "another-one"]); // invalid entries dropped
Expand Down
34 changes: 34 additions & 0 deletions test/unit/github-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createOrUpdatePendingGateCheckRun,
createOrUpdateSkippedGateCheckRun,
getAppInstallation,
getGithubUserCreatedAt,
getInstallationId,
getRepositoryCollaboratorPermission,
isCacheableGithubUrl,
Expand Down Expand Up @@ -627,6 +628,39 @@ describe("GitHub check runs", () => {
).rejects.toThrow(/Failed to fetch GitHub collaborator permission/);
});

it("getGithubUserCreatedAt fetches the account creation date, and fails OPEN (null) on any error (#2561)", async () => {
const privateKey = await generatePrivateKeyPem();
await expect(getGithubUserCreatedAt(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "")).resolves.toBeNull();

vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
if (url.includes("/users/newbie")) return Response.json({ login: "newbie", created_at: "2026-06-01T00:00:00Z" });
if (url.includes("/users/missing-field")) return Response.json({ login: "missing-field" });
if (url.includes("/users/malformed-field")) return Response.json({ login: "malformed-field", created_at: 12345 });
if (url.includes("/users/not-found")) return new Response("not found", { status: 404 });
if (url.includes("/users/network-error")) throw new Error("network down");
return new Response("not found", { status: 404 });
});

await expect(
getGithubUserCreatedAt(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "newbie"),
).resolves.toBe("2026-06-01T00:00:00Z");
await expect(
getGithubUserCreatedAt(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "missing-field"),
).resolves.toBeNull();
// Gate finding (#2561): a malformed (non-string) created_at must fail open, not be coerced by Date.parse.
await expect(
getGithubUserCreatedAt(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "malformed-field"),
).resolves.toBeNull();
await expect(
getGithubUserCreatedAt(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "not-found"),
).resolves.toBeNull();
await expect(
getGithubUserCreatedAt(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "network-error"),
).resolves.toBeNull();
});

it("updates an existing Gittensory check run for the same head SHA", async () => {
const privateKey = await generatePrivateKeyPem();
const methods: string[] = [];
Expand Down
Loading
Loading