From e5753c5eef3c06bbf0e93ca5111c6fa4add4e3ea Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:06:39 -0700 Subject: [PATCH 1/3] feat(agent-actions): add an account-age throttle for ban-evasion The contributor blacklist matches by exact login string; the contributor cap counts open items per repo. Neither has any signal for how old a GitHub account is -- the classic ban-evasion gap: a banned login gets a fresh account the same day and gets full trust until it independently trips the cap on its own. Adds accountAgeThresholdDays: a PR/issue from an account younger than the threshold gets the new-account label AND a tightened effective contributor cap (half, rounded up) -- friction/visibility, never an automatic close on account age alone. Fetches created_at via GET /users/{login}, already classified as a cached "metadata" GitHub response (client.ts), so this reuses the existing response cache with no new caching infra. Off by default. Never fires for the repo owner, admin logins, or automation bots. Closes #2561. --- .gittensory.yml.example | 6 + apps/gittensory-ui/public/openapi.json | 9 ++ migrations/0095_account_age_throttle.sql | 6 + src/db/repositories.ts | 10 ++ src/db/schema.ts | 4 + src/github/app.ts | 29 +++++ src/openapi/schemas.ts | 2 + src/queue/processors.ts | 41 ++++++- src/signals/focus-manifest.ts | 12 ++ src/types.ts | 9 ++ test/unit/focus-manifest.test.ts | 18 +++ test/unit/github-app.test.ts | 29 +++++ test/unit/queue.test.ts | 144 +++++++++++++++++++++++ 13 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 migrations/0095_account_age_throttle.sql diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 9906ef4255..086517bfdc 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -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/issue 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. 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/issue. Default: new-account. diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 197086e44f..5cdb50d945 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8688,6 +8688,15 @@ "nullable": true, "minimum": 0, "exclusiveMinimum": true + }, + "accountAgeThresholdDays": { + "type": "integer", + "nullable": true, + "minimum": 0, + "exclusiveMinimum": true + }, + "newAccountLabel": { + "type": "string" } }, "required": [ diff --git a/migrations/0095_account_age_throttle.sql b/migrations/0095_account_age_throttle.sql new file mode 100644 index 0000000000..ea068487e9 --- /dev/null +++ b/migrations/0095_account_age_throttle.sql @@ -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'; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index e928883cb2..3589785a29 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -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 { @@ -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, }; @@ -649,6 +653,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), }); diff --git a/src/github/app.ts b/src/github/app.ts index f13acbca91..9571e1b2d1 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -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 { + 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?: string }; + return 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 diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index d50d6f3ca7..81f3ea0bd7 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -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(), }) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index a3270c15f7..2afc33ac85 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -113,6 +113,7 @@ import { createOrUpdateOverriddenGateCheckRun, createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, + getGithubUserCreatedAt, getInstallationId, getRepositoryCollaboratorPermission, GITTENSORY_GATE_CHECK_NAME, @@ -1978,6 +1979,37 @@ 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. + 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) { + 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 @@ -1985,9 +2017,14 @@ async function runAgentMaintenancePlanAndExecute( // 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 diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 4faf7199fc..85d49f17b8 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -150,6 +150,8 @@ export type FocusManifestSettings = Partial< | "reviewNagCooldownDays" | "reviewNagLabel" | "autoCloseExemptLogins" + | "accountAgeThresholdDays" + | "newAccountLabel" > >; @@ -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; } diff --git a/src/types.ts b/src/types.ts index 3c830da2e8..f741a4ce97 100644 --- a/src/types.ts +++ b/src/types.ts @@ -666,6 +666,15 @@ 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/issue from an account younger than this many days gets + * the {@link newAccountLabel} and/or 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. */ + accountAgeThresholdDays?: number | null | undefined; + /** The label applied to a below-threshold-age account's PR/issue (#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`. */ diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index c05465733d..6846e63ada 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -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 diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 41ff6fdff4..142ad14db2 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -9,6 +9,7 @@ import { createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, getAppInstallation, + getGithubUserCreatedAt, getInstallationId, getRepositoryCollaboratorPermission, isCacheableGithubUrl, @@ -627,6 +628,34 @@ 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/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(); + 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[] = []; diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 8cdc1a077a..ab0e0b88d8 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -6611,6 +6611,150 @@ describe("queue processors", () => { expect(seen.closed).toBe(true); }); + function stubAccountAgeFetch(prNumber: number, createdAt: string, seen: { labels: string[]; closed: boolean }) { + return async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/users/")) return Response.json({ login: "newbie", created_at: createdAt }); + if (url.includes(`/pulls/${prNumber}/files`)) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes(`/pulls/${prNumber}/reviews`)) return Response.json([]); + if (url.includes(`/pulls/${prNumber}/commits`)) return Response.json([]); + if (url.endsWith(`/pulls/${prNumber}`) && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ number: prNumber, state: "closed" }); } + if (url.endsWith(`/pulls/${prNumber}`)) return Response.json({ number: prNumber, state: "open", user: { login: "newbie" }, head: { sha: `s${prNumber}` }, mergeable_state: "clean" }); + if (url.includes(`/commits/s${prNumber}/check-runs`)) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes(`/commits/s${prNumber}/status`)) return Response.json({ state: "success", statuses: [] }); + if (url.includes(`/issues/${prNumber}/labels`) && method === "GET") return Response.json([]); + if (url.includes(`/issues/${prNumber}/labels`) && method === "POST") { seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); return Response.json([]); } + if (url.endsWith("/labels") && method === "POST") return Response.json({ name: "x" }, { status: 201 }); + if (url.includes(`/issues/${prNumber}/comments`)) return Response.json([]); + return Response.json({}); + }; + } + + it("account-age throttle (#2561): a below-threshold-age account gets the new-account label AND a tighter effective cap", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + // Two pre-existing open PRs from the same new author — a cap of 4 (tightened to 2 for a new account) + // means the 3rd PR is already over the tightened cap, even though it's well under the CONFIGURED cap of 4. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Newbie PR one", state: "open", user: { login: "newbie" }, head: { sha: "s60" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Newbie PR two", state: "open", user: { login: "newbie" }, head: { sha: "s61" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + gateCheckMode: "enabled", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 4, + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + // Account created 2 days ago — well under the 30-day threshold. + vi.stubGlobal("fetch", stubAccountAgeFetch(62, new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-tighter-cap", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 62, title: "Newbie's 3rd PR", state: "open", user: { login: "newbie" }, head: { sha: "s62" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + expect(seen.labels).toContain("new-account"); + // The tightened cap (ceil(4/2)=2) is already exceeded by the 3rd PR — closed despite being under the raw cap of 4. + expect(seen.closed).toBe(true); + }); + + it("account-age throttle (#2561): an account OLDER than the threshold is unaffected — no label, no cap tightening", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 63, title: "Vet PR one", state: "open", user: { login: "newbie" }, head: { sha: "s63" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 64, title: "Vet PR two", state: "open", user: { login: "newbie" }, head: { sha: "s64" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + gateCheckMode: "enabled", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 4, + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + // Account created 2 years ago — well over the 30-day threshold. + vi.stubGlobal("fetch", stubAccountAgeFetch(65, new Date(Date.now() - 730 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-unaffected", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 65, title: "Vet's 3rd PR", state: "open", user: { login: "newbie" }, head: { sha: "s65" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + expect(seen.labels).not.toContain("new-account"); + // The RAW cap (4) is not yet exceeded by a 3rd PR — untouched. + expect(seen.closed).toBe(false); + }); + + it("account-age throttle (#2561): the repo OWNER's own PR is never labeled even on a brand-new account", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + gateCheckMode: "enabled", + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/users/")) return Response.json({ login: "JSONbored", created_at: new Date().toISOString() }); + if (url.includes("/pulls/66/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes("/pulls/66/reviews")) return Response.json([]); + if (url.includes("/pulls/66/commits")) return Response.json([]); + if (url.endsWith("/pulls/66")) return Response.json({ number: 66, state: "open", user: { login: "JSONbored" }, head: { sha: "s66" }, mergeable_state: "clean" }); + if (url.includes("/commits/s66/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/s66/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/66/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/66/labels") && method === "POST") { seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); return Response.json([]); } + if (url.includes("/issues/66/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-owner-exempt", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 66, title: "Owner's own PR", state: "open", user: { login: "JSONbored" }, head: { sha: "s66" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + expect(seen.labels).not.toContain("new-account"); + }); + it("contributor open-PR cap (#2270): out-of-order webhook delivery wakes and self-corrects the missed sibling (regression, gate finding on #2479)", async () => { // PR56 (the NEWER PR) is delivered BEFORE PR55 exists in the DB — a real possibility under concurrent/ // retried webhook delivery. At that moment PR56 only sees {54, 56} (2 total, AT the cap of 2, not over), From f3721e0d3896d039a2f0a896ed2b02aaef2de3a9 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:34:35 -0700 Subject: [PATCH 2/3] fix(agent-actions): gate the account-age label behind label autonomy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account-age throttle applied its label directly, bypassing the label-autonomy opt-in every other label mutation goes through — a repo with label autonomy still at the default "observe" would get labels written anyway. Also narrows the docs to PR-only (issue-path enforcement isn't wired yet) and hardens getGithubUserCreatedAt against a non-string created_at field. Addresses gate review findings on #2561. --- .gittensory.yml.example | 6 +- src/github/app.ts | 4 +- src/queue/processors.ts | 6 +- src/types.ts | 9 +-- test/unit/github-app.test.ts | 5 ++ test/unit/queue.test.ts | 119 +++++++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 11 deletions(-) diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 086517bfdc..85b82fdbf8 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -334,8 +334,8 @@ settings: # List of GitHub logins. Default: [] (no additional exemptions). # autoCloseExemptLogins: [some-trusted-regular] - # Account-age throttle (#2561, anti-abuse): a PR/issue from an account younger than this many days gets + # 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. Off by default. + # 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/issue. Default: new-account. + # newAccountLabel: new-account # Label applied to a below-threshold-age account's PR. Default: new-account. diff --git a/src/github/app.ts b/src/github/app.ts index 9571e1b2d1..ca8e6e9dad 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -426,8 +426,8 @@ export async function getGithubUserCreatedAt( }, ); if (!response.ok) return null; - const payload = (await response.json()) as { created_at?: string }; - return payload.created_at ?? null; + const payload = (await response.json()) as { created_at?: unknown }; + return typeof payload.created_at === "string" ? payload.created_at : null; } catch { return null; } diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 2afc33ac85..e3005cae00 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -1985,7 +1985,9 @@ async function runAgentMaintenancePlanAndExecute( // 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. + // 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) { @@ -1994,7 +1996,7 @@ async function runAgentMaintenancePlanAndExecute( const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000); isNewAccount = ageDays < accountAgeThresholdDays; } - if (isNewAccount) { + if (isNewAccount && resolveAutonomy(settings.autonomy, "label") === "auto") { const newAccountMode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, diff --git a/src/types.ts b/src/types.ts index f741a4ce97..a4b5c887b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -666,12 +666,13 @@ 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/issue from an account younger than this many days gets - * the {@link newAccountLabel} and/or a tighter effective contributor cap -- friction/visibility, NEVER an + /** 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. */ + * 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/issue (#2561), mirroring {@link blacklistLabel}'s + /** 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; diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 142ad14db2..11b4edf342 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -637,6 +637,7 @@ describe("GitHub check runs", () => { 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 }); @@ -648,6 +649,10 @@ describe("GitHub check runs", () => { 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(); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index ab0e0b88d8..aaf2139318 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -6755,6 +6755,125 @@ describe("queue processors", () => { expect(seen.labels).not.toContain("new-account"); }); + it("account-age throttle (#2561): disabled (no threshold configured, the default) never fetches the GitHub user or labels", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + gateCheckMode: "enabled", + autonomy: { close: "auto", label: "auto" }, + // accountAgeThresholdDays intentionally omitted — off by default. + }); + const seen = { labels: [] as string[], accountAgeUsersFetched: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + // Distinct from the UNRELATED, always-on public-contributor-profile lookup (src/github/public.ts), which + // hits this same bare /users/{login} URL but with NO authorization header (GITHUB_PUBLIC_TOKEN unset in + // this test) — only getGithubUserCreatedAt's account-age-specific call sends a Bearer installation token. + if (url.includes("/users/") && (init?.headers as Record | undefined)?.authorization) { + seen.accountAgeUsersFetched = true; + return Response.json({ login: "newbie", created_at: new Date().toISOString() }); + } + if (url.includes("/users/")) return Response.json({ login: "newbie" }); + if (url.includes("/pulls/67/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes("/pulls/67/reviews")) return Response.json([]); + if (url.includes("/pulls/67/commits")) return Response.json([]); + if (url.endsWith("/pulls/67")) return Response.json({ number: 67, state: "open", user: { login: "newbie" }, head: { sha: "s67" }, mergeable_state: "clean" }); + if (url.includes("/commits/s67/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/s67/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/67/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/67/labels") && method === "POST") { seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); return Response.json([]); } + if (url.includes("/issues/67/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-disabled", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 67, title: "Newbie's PR", state: "open", user: { login: "newbie" }, head: { sha: "s67" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + expect(seen.accountAgeUsersFetched).toBe(false); + expect(seen.labels).not.toContain("new-account"); + }); + + it("account-age throttle (#2561): a configured newAccountLabel is used instead of the default", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + gateCheckMode: "enabled", + autonomy: { close: "auto", label: "auto" }, + accountAgeThresholdDays: 30, + newAccountLabel: "custom-new-account-label", + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", stubAccountAgeFetch(68, new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-custom-label", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 68, title: "Newbie's PR", state: "open", user: { login: "newbie" }, head: { sha: "s68" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + expect(seen.labels).toContain("custom-new-account-label"); + expect(seen.labels).not.toContain("new-account"); + }); + + it("account-age throttle (#2561): a below-threshold account is NOT labeled when the repo has not opted into label autonomy (regression, gate finding)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + gateCheckMode: "enabled", + // autonomy intentionally omitted — deny-by-default ("observe" for every action class, including "label"). + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", stubAccountAgeFetch(69, new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-label-not-autonomous", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 69, title: "Newbie's PR", state: "open", user: { login: "newbie" }, head: { sha: "s69" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + expect(seen.labels).not.toContain("new-account"); + }); + it("contributor open-PR cap (#2270): out-of-order webhook delivery wakes and self-corrects the missed sibling (regression, gate finding on #2479)", async () => { // PR56 (the NEWER PR) is delivered BEFORE PR55 exists in the DB — a real possibility under concurrent/ // retried webhook delivery. At that moment PR56 only sees {54, 56} (2 total, AT the cap of 2, not over), From 315f547101542e703ebf4e905c194fef8b48defb Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:42:16 -0700 Subject: [PATCH 3/3] fix(agent-actions): renumber account-age migration to 0096 main independently merged 0095_pull_request_detail_sync_pr_state.sql, colliding with this branch's own 0095_account_age_throttle.sql after rebase. --- ...095_account_age_throttle.sql => 0096_account_age_throttle.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrations/{0095_account_age_throttle.sql => 0096_account_age_throttle.sql} (100%) diff --git a/migrations/0095_account_age_throttle.sql b/migrations/0096_account_age_throttle.sql similarity index 100% rename from migrations/0095_account_age_throttle.sql rename to migrations/0096_account_age_throttle.sql