diff --git a/.github/scripts/pr-trust-lane.cjs b/.github/scripts/pr-trust-lane.cjs new file mode 100644 index 000000000..4f3301189 --- /dev/null +++ b/.github/scripts/pr-trust-lane.cjs @@ -0,0 +1,134 @@ +"use strict"; + +const FIRST_TIME_ASSOCIATIONS = new Set([ + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "NONE", +]); +const MAX_FIRST_TIME_CHANGED_LINES = 500; +const RESTRICTED_PREFIXES = [ + ".github/workflows/", + "src/oauth/", +]; +const RESTRICTED_FILES = new Set([ + // Release and packaging automation executed by the release workflow. + "scripts/release.ts", + "scripts/release-notes.ts", + "scripts/prepare-package.ts", + // Authentication, credential, and secret handling. This mirrors the + // CODEOWNERS security boundary; `src/auth/` does not exist in this repository. + "src/codex/auth-api.ts", + "src/codex/auth-collision.ts", + "src/codex/auth-context.ts", + "src/cli/account-auth.ts", + "src/cli/status-oauth.ts", + "src/lib/admin-secrets.ts", + "src/lib/service-secrets.ts", + "src/lib/windows-secret-acl.ts", + "src/server/auth-cors.ts", + "src/server/management-api.ts", + "src/server/management-auth.ts", + "src/server/management/oauth-account-routes.ts", + "src/claude/auth-detect.ts", + "src/claude/auth-mode-migration.ts", + "src/claude/auth-mode.ts", + // Dependency surfaces. + "package.json", + "bun.lock", +]); +const IMPLEMENTATION_PREFIXES = ["src/", "gui/", "scripts/", "tests/", "bin/", "packages/", ".github/workflows/"]; +const IMPLEMENTATION_FILES = new Set(["package.json", "bun.lock", "bunfig.toml", "tsconfig.json"]); + +function isFirstTimeContributor(authorAssociation) { + return FIRST_TIME_ASSOCIATIONS.has(String(authorAssociation || "").toUpperCase()); +} + +function isImplementationPath(path) { + return IMPLEMENTATION_FILES.has(path) || IMPLEMENTATION_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +function isRestrictedPath(path) { + return RESTRICTED_FILES.has(path) || RESTRICTED_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +function changedLines(files) { + return (files || []).reduce( + (total, file) => total + Number(file.additions || 0) + Number(file.deletions || 0), + 0, + ); +} + +function linkedIssueHasLabel(linkedIssues, labelName) { + return (linkedIssues || []).some((issue) => + issue.state === "open" && + (issue.labels || []).some((label) => + (typeof label === "string" ? label : label?.name) === labelName, + ), + ); +} + +function assessTrustLane({ + authorAssociation, + authorHasPushPermission = false, + files = [], + changedFiles, + linkedIssues = [], + otherOpenImplementationPrs = [], + currentPr = {}, +}) { + if (authorHasPushPermission || !isFirstTimeContributor(authorAssociation)) return []; + const paths = changedFiles ?? (files || []).map((file) => file.filename); + if (!paths.some(isImplementationPath)) return []; + + const failures = []; + // Keep the oldest open implementation PR eligible and reject only newer + // ones, so a second PR can never block the author's first submission. + const candidates = [ + ...(otherOpenImplementationPrs || []).map((pr) => ({ + number: typeof pr === "number" ? pr : pr.number, + created_at: typeof pr === "number" ? "" : pr.created_at || "", + })), + { number: currentPr.number, created_at: currentPr.created_at || "" }, + ].filter((pr) => Number.isInteger(pr.number)); + candidates.sort((a, b) => + a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : a.number - b.number, + ); + if (candidates.length > 1 && candidates[0].number !== currentPr.number) { + failures.push({ + code: "active_pr_limit", + pullRequests: [candidates[0].number], + }); + } + + const size = changedLines(files); + if ( + size > MAX_FIRST_TIME_CHANGED_LINES && + !linkedIssueHasLabel(linkedIssues, "large-change-approved") + ) { + failures.push({ + code: "first_pr_too_large", + changedLines: size, + maximum: MAX_FIRST_TIME_CHANGED_LINES, + }); + } + + const restricted = paths.filter(isRestrictedPath); + if ( + restricted.length > 0 && + !linkedIssueHasLabel(linkedIssues, "maintainer-sponsored") + ) { + failures.push({ code: "restricted_surface", paths: restricted }); + } + + return failures; +} + +module.exports = { + MAX_FIRST_TIME_CHANGED_LINES, + assessTrustLane, + changedLines, + isFirstTimeContributor, + isImplementationPath, + isRestrictedPath, + linkedIssueHasLabel, +}; diff --git a/.github/scripts/pr-trust-lane.test.cjs b/.github/scripts/pr-trust-lane.test.cjs new file mode 100644 index 000000000..971003bc7 --- /dev/null +++ b/.github/scripts/pr-trust-lane.test.cjs @@ -0,0 +1,136 @@ +"use strict"; + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { + MAX_FIRST_TIME_CHANGED_LINES, + assessTrustLane, + isFirstTimeContributor, + isImplementationPath, + isRestrictedPath, +} = require("./pr-trust-lane.cjs"); + +describe("first-time classification", () => { + it("classifies GitHub first-time associations", () => { + assert.equal(isFirstTimeContributor("FIRST_TIMER"), true); + assert.equal(isFirstTimeContributor("FIRST_TIME_CONTRIBUTOR"), true); + assert.equal(isFirstTimeContributor("NONE"), true); + assert.equal(isFirstTimeContributor("CONTRIBUTOR"), false); + }); + + it("recognizes restricted security and dependency surfaces", () => { + assert.equal(isRestrictedPath(".github/workflows/ci.yml"), true); + assert.equal(isRestrictedPath("src/oauth/provider.ts"), true); + assert.equal(isRestrictedPath("package.json"), true); + assert.equal(isRestrictedPath("src/router.ts"), false); + }); + + it("restricts the repository's real auth and credential paths", () => { + for (const p of [ + "src/codex/auth-api.ts", + "src/codex/auth-context.ts", + "src/cli/account-auth.ts", + "src/lib/admin-secrets.ts", + "src/lib/service-secrets.ts", + "src/server/auth-cors.ts", + "src/server/management-auth.ts", + "src/server/management-api.ts", + "src/oauth/store.ts", + ]) { + assert.equal(isRestrictedPath(p), true, p); + } + assert.equal(isRestrictedPath("src/auth/oauth.ts"), false); + }); + + it("restricts release and packaging scripts", () => { + assert.equal(isRestrictedPath("scripts/release-notes.ts"), true); + assert.equal(isRestrictedPath("scripts/prepare-package.ts"), true); + assert.equal(isRestrictedPath("scripts/test.ts"), false); + }); + + it("classifies bunfig.toml as an implementation file", () => { + assert.equal(isImplementationPath("bunfig.toml"), true); + }); +}); + +describe("assessTrustLane", () => { + const smallRuntimeChange = [{ filename: "src/router.ts", additions: 40, deletions: 5 }]; + + it("limits first-time authors to one active implementation PR", () => { + const failures = assessTrustLane({ + authorAssociation: "FIRST_TIME_CONTRIBUTOR", + files: smallRuntimeChange, + otherOpenImplementationPrs: [{ number: 812, created_at: "2026-07-01T00:00:00Z" }], + currentPr: { number: 42, created_at: "2026-08-01T00:00:00Z" }, + }); + assert.deepEqual(failures[0], { code: "active_pr_limit", pullRequests: [812] }); + }); + + it("keeps the oldest implementation PR eligible", () => { + const failures = assessTrustLane({ + authorAssociation: "FIRST_TIME_CONTRIBUTOR", + files: smallRuntimeChange, + otherOpenImplementationPrs: [{ number: 812, created_at: "2026-08-01T00:00:00Z" }], + currentPr: { number: 42, created_at: "2026-07-01T00:00:00Z" }, + }); + assert.deepEqual(failures, []); + }); + + it("rejects oversized first implementation PRs without approval", () => { + const failures = assessTrustLane({ + authorAssociation: "FIRST_TIMER", + files: [{ filename: "src/router.ts", additions: MAX_FIRST_TIME_CHANGED_LINES + 1, deletions: 0 }], + }); + assert.equal(failures[0].code, "first_pr_too_large"); + }); + + it("allows oversized work when the linked issue approves it", () => { + const failures = assessTrustLane({ + authorAssociation: "FIRST_TIMER", + files: [{ filename: "src/router.ts", additions: 700, deletions: 0 }], + linkedIssues: [{ labels: [{ name: "large-change-approved" }], state: "open" }], + }); + assert.deepEqual(failures, []); + }); + + it("rejects approval labels on closed issues", () => { + const failures = assessTrustLane({ + authorAssociation: "FIRST_TIMER", + files: [{ filename: "src/router.ts", additions: 700, deletions: 0 }], + linkedIssues: [{ labels: [{ name: "large-change-approved" }], state: "closed" }], + }); + assert.equal(failures[0].code, "first_pr_too_large"); + }); + + it("requires sponsorship for restricted surfaces", () => { + const failures = assessTrustLane({ + authorAssociation: "NONE", + files: [{ filename: ".github/workflows/ci.yml", additions: 10, deletions: 2 }], + }); + assert.equal(failures[0].code, "restricted_surface"); + }); + + it("allows sponsored restricted work", () => { + const failures = assessTrustLane({ + authorAssociation: "NONE", + files: [{ filename: "src/oauth/provider.ts", additions: 10, deletions: 2 }], + linkedIssues: [{ labels: ["maintainer-sponsored"], state: "open" }], + }); + assert.deepEqual(failures, []); + }); + + it("classifies renamed sources as restricted implementation paths", () => { + const failures = assessTrustLane({ + authorAssociation: "NONE", + files: [{ filename: "docs/moved.md", additions: 10, deletions: 2 }], + changedFiles: ["docs/moved.md", "src/oauth/store.ts"], + }); + assert.equal(failures[0].code, "restricted_surface"); + }); + + it("does not restrict established contributors, maintainers, or docs-only PRs", () => { + assert.deepEqual(assessTrustLane({ authorAssociation: "CONTRIBUTOR", files: smallRuntimeChange }), []); + assert.deepEqual(assessTrustLane({ authorAssociation: "NONE", authorHasPushPermission: true, files: smallRuntimeChange }), []); + assert.deepEqual(assessTrustLane({ authorAssociation: "NONE", files: [{ filename: "README.md", additions: 900 }] }), []); + }); +}); diff --git a/.github/workflows/pr-trust-lane.yml b/.github/workflows/pr-trust-lane.yml new file mode 100644 index 000000000..bcc4a94d8 --- /dev/null +++ b/.github/workflows/pr-trust-lane.yml @@ -0,0 +1,224 @@ +name: PR trust lane + +on: + pull_request_target: + types: [opened, reopened, edited, synchronize, ready_for_review] + workflow_dispatch: + inputs: + pull_request_number: + description: "PR number to re-evaluate" + required: true + +# Trusted default-branch script only; no PR-head checkout or execution. +# Least privilege: no default permissions; the trust-lane job grants only what it needs. +permissions: {} + +concurrency: + group: pr-trust-lane-${{ github.event.pull_request.number || github.event.inputs.pull_request_number }} + cancel-in-progress: true + +jobs: + trust-lane: + runs-on: ubuntu-latest + # contents: read for the trusted script checkout; issues/pull-requests write + # maintain the blocked label and one bot comment. + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Checkout trusted trust-lane script + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout: .github/scripts + + - name: Enforce first-contribution limits + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const path = require("node:path"); + const { extractLinkedIssueNumbers } = require( + path.join(process.cwd(), ".github", "scripts", "pr-admission.cjs"), + ); + const { authorHasPushPermission } = require( + path.join(process.cwd(), ".github", "scripts", "pr-quality.cjs"), + ); + const { rejectsWorkflowDispatchNonDefaultBranch } = require( + path.join(process.cwd(), ".github", "scripts", "issue-quality.cjs"), + ); + const { + assessTrustLane, + isImplementationPath, + } = require( + path.join(process.cwd(), ".github", "scripts", "pr-trust-lane.cjs"), + ); + + const { owner, repo } = context.repo; + const marker = ""; + const blockedLabel = "intake: trust-lane-blocked"; + + const nonDefaultBranchFailure = rejectsWorkflowDispatchNonDefaultBranch( + context.eventName, + context.ref, + context.payload.repository?.default_branch, + ); + if (nonDefaultBranchFailure) { + core.setFailed(nonDefaultBranchFailure); + return; + } + + let pull_number; + if (context.eventName === "workflow_dispatch") { + const parsed = Number(context.payload.inputs?.pull_request_number); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + core.setFailed( + `Invalid workflow_dispatch pull_request_number: ${context.payload.inputs?.pull_request_number}`, + ); + return; + } + pull_number = parsed; + } else { + pull_number = context.payload.pull_request.number; + } + + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number }); + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number, per_page: 100, + }); + + let permission = "read"; + try { + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username: pr.user.login, + }); + permission = response.data.permission; + } catch (error) { + core.warning(`Permission lookup failed: ${error.message}`); + } + const hasPushPermission = authorHasPushPermission(permission); + + const linkedIssues = []; + for (const issue_number of extractLinkedIssueNumbers(pr.body)) { + try { + const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number }); + if (!issue.pull_request) { + linkedIssues.push({ + number: issue.number, + labels: issue.labels, + state: issue.state, + }); + } + } catch (error) { + core.warning(`Could not load issue #${issue_number}: ${error.message}`); + } + } + + const open = await github.paginate(github.rest.pulls.list, { + owner, repo, state: "open", per_page: 100, + }); + const otherOpenImplementationPrs = []; + for (const candidate of open) { + if (candidate.number === pull_number || candidate.user?.login !== pr.user.login) continue; + const candidateFiles = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: candidate.number, per_page: 100, + }); + if (candidateFiles.some((file) => isImplementationPath(file.filename))) { + otherOpenImplementationPrs.push({ + number: candidate.number, + created_at: candidate.created_at, + }); + } + } + + const changedFiles = files.flatMap((file) => + [file.filename, file.previous_filename].filter(Boolean), + ); + const failures = assessTrustLane({ + authorAssociation: pr.author_association, + authorHasPushPermission: hasPushPermission, + files, + changedFiles, + linkedIssues, + otherOpenImplementationPrs, + currentPr: { number: pull_number, created_at: pr.created_at }, + }); + + async function ensureLabel() { + try { + await github.rest.issues.getLabel({ owner, repo, name: blockedLabel }); + } catch (error) { + if (error.status !== 404) throw error; + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: blockedLabel, + color: "b60205", + description: "First-contribution limits require maintainer approval", + }); + } catch (createError) { + if (createError.status !== 422) throw createError; + } + } + } + + async function setBlocked(blocked) { + const labels = new Set(pr.labels.map((label) => label.name)); + if (blocked && !labels.has(blockedLabel)) { + await ensureLabel(); + await github.rest.issues.addLabels({ + owner, repo, issue_number: pull_number, labels: [blockedLabel], + }); + } else if (!blocked && labels.has(blockedLabel)) { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: pull_number, name: blockedLabel, + }); + } + } + + async function upsert(body) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: pull_number, per_page: 100, + }); + const existing = comments.find( + (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker), + ); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body }); + } + } + + if (failures.length === 0) { + await setBlocked(false); + await upsert(`${marker}\n\n✅ **Contributor trust-lane requirements passed.**`); + return; + } + + await setBlocked(true); + const lines = failures.flatMap((failure) => { + if (failure.code === "active_pr_limit") { + return [ + "### One active implementation PR", + `Close or finish ${failure.pullRequests.map((n) => `#${n}`).join(", ")} before opening another implementation PR.`, + "", + ]; + } + if (failure.code === "first_pr_too_large") { + return [ + "### First contribution is too large", + `This PR changes ${failure.changedLines} lines; the first-contribution ceiling is ${failure.maximum}. Split it, or obtain \`large-change-approved\` on the linked issue before implementation.`, + "", + ]; + } + return [ + "### Maintainer sponsorship required", + `Restricted paths: ${failure.paths.map((p) => `\`${p}\``).join(", ")}. The linked issue needs \`maintainer-sponsored\` before a first-time contributor changes these surfaces.`, + "", + ]; + }); + await upsert([marker, "", "⚠️ **First-contribution limits blocked this PR.**", "", ...lines].join("\n")); + core.setFailed(`Trust lane failed: ${failures.map((f) => f.code).join(", ")}`); diff --git a/docs/superpowers/specs/2026-08-02-pr-trust-lane-design.md b/docs/superpowers/specs/2026-08-02-pr-trust-lane-design.md new file mode 100644 index 000000000..97cdd30ca --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-pr-trust-lane-design.md @@ -0,0 +1,16 @@ +# New-contributor trust lane — Design + +**Stack:** 3/5, based on `agent/pr-readiness-gate` + +First-time contributors get a deliberately narrow lane until the repository has evidence that they can scope, validate, and maintain their submissions. + +- One active implementation PR per first-time author. +- Maximum 500 changed lines for a first implementation PR unless the linked issue has `large-change-approved`. +- Workflow, OAuth/authentication, release, and dependency surfaces require `maintainer-sponsored` on the linked issue. +- Documentation-only work, established contributors, and repository collaborators are exempt. + +The workflow uses PR metadata and GitHub APIs only. It does not inspect whether code was written by AI and does not execute untrusted code. + +Renamed files are classified on both sides (`filename` and `previous_filename`), approval labels (`large-change-approved`, `maintainer-sponsored`) count only on open issues, and a `workflow_dispatch` re-run restricted to the repository default branch lets a maintainer re-evaluate a PR after an issue gains an approval label. + +The restricted surface covers the repository's actual authentication, credential, OAuth, release, and packaging module paths (`src/auth/` does not exist in this tree), and the one-active-PR limit keeps the oldest open implementation PR eligible while rejecting newer ones.