From cb508a6878f4aa07ce9bbc1fb2388ebd19fdafb4 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:09:08 +0200 Subject: [PATCH 1/5] chore: add first-contributor trust lane --- .github/scripts/pr-trust-lane.cjs | 100 ++++++++++ .github/scripts/pr-trust-lane.test.cjs | 79 ++++++++ .github/workflows/pr-trust-lane.yml | 172 ++++++++++++++++++ .../specs/2026-08-02-pr-trust-lane-design.md | 12 ++ 4 files changed, 363 insertions(+) create mode 100644 .github/scripts/pr-trust-lane.cjs create mode 100644 .github/scripts/pr-trust-lane.test.cjs create mode 100644 .github/workflows/pr-trust-lane.yml create mode 100644 docs/superpowers/specs/2026-08-02-pr-trust-lane-design.md diff --git a/.github/scripts/pr-trust-lane.cjs b/.github/scripts/pr-trust-lane.cjs new file mode 100644 index 0000000000..ad87fe0357 --- /dev/null +++ b/.github/scripts/pr-trust-lane.cjs @@ -0,0 +1,100 @@ +"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/auth/", + "src/oauth/", +]; +const RESTRICTED_FILES = new Set([ + "scripts/release.ts", + "package.json", + "bun.lock", +]); +const IMPLEMENTATION_PREFIXES = ["src/", "gui/", "scripts/", "tests/", "bin/", "packages/", ".github/workflows/"]; +const IMPLEMENTATION_FILES = new Set(["package.json", "bun.lock", "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.labels || []).some((label) => + (typeof label === "string" ? label : label?.name) === labelName, + ), + ); +} + +function assessTrustLane({ + authorAssociation, + authorHasPushPermission = false, + files = [], + linkedIssues = [], + otherOpenImplementationPrs = [], +}) { + if (authorHasPushPermission || !isFirstTimeContributor(authorAssociation)) return []; + if (!files.some((file) => isImplementationPath(file.filename))) return []; + + const failures = []; + if (otherOpenImplementationPrs.length > 0) { + failures.push({ + code: "active_pr_limit", + pullRequests: otherOpenImplementationPrs, + }); + } + + 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 = files + .map((file) => file.filename) + .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 0000000000..6c2661671e --- /dev/null +++ b/.github/scripts/pr-trust-lane.test.cjs @@ -0,0 +1,79 @@ +"use strict"; + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { + MAX_FIRST_TIME_CHANGED_LINES, + assessTrustLane, + isFirstTimeContributor, + 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); + }); +}); + +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: [812], + }); + assert.deepEqual(failures[0], { code: "active_pr_limit", pullRequests: [812] }); + }); + + 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" }] }], + }); + assert.deepEqual(failures, []); + }); + + 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"] }], + }); + assert.deepEqual(failures, []); + }); + + 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 0000000000..fe51ca07b4 --- /dev/null +++ b/.github/workflows/pr-trust-lane.yml @@ -0,0 +1,172 @@ +name: PR trust lane + +on: + pull_request_target: + types: [opened, reopened, edited, synchronize, ready_for_review] + +# Trusted default-branch script only; no PR-head checkout or execution. +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: pr-trust-lane-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + trust-lane: + runs-on: ubuntu-latest + 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 { + assessTrustLane, + isImplementationPath, + } = require( + path.join(process.cwd(), ".github", "scripts", "pr-trust-lane.cjs"), + ); + + const { owner, repo } = context.repo; + const pull_number = context.payload.pull_request.number; + const marker = ""; + const blockedLabel = "intake: trust-lane-blocked"; + + 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 authorHasPushPermission = ["admin", "maintain", "write"].includes(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 }); + } 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(candidate.number); + } + } + + const failures = assessTrustLane({ + authorAssociation: pr.author_association, + authorHasPushPermission, + files, + linkedIssues, + otherOpenImplementationPrs, + }); + + 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 0000000000..e59e16bc41 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-pr-trust-lane-design.md @@ -0,0 +1,12 @@ +# 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. From 1028cec929ffd29687c7ce2c43e46f8fcf14a422 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:54:31 +0200 Subject: [PATCH 2/5] fix(ci): classify renames and bunfig in trust lane, require open approval issues --- .github/scripts/pr-trust-lane.cjs | 17 ++++++++-------- .github/scripts/pr-trust-lane.test.cjs | 27 ++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.github/scripts/pr-trust-lane.cjs b/.github/scripts/pr-trust-lane.cjs index ad87fe0357..b4f30fcfc7 100644 --- a/.github/scripts/pr-trust-lane.cjs +++ b/.github/scripts/pr-trust-lane.cjs @@ -17,7 +17,7 @@ const RESTRICTED_FILES = new Set([ "bun.lock", ]); const IMPLEMENTATION_PREFIXES = ["src/", "gui/", "scripts/", "tests/", "bin/", "packages/", ".github/workflows/"]; -const IMPLEMENTATION_FILES = new Set(["package.json", "bun.lock", "tsconfig.json"]); +const IMPLEMENTATION_FILES = new Set(["package.json", "bun.lock", "bunfig.toml", "tsconfig.json"]); function isFirstTimeContributor(authorAssociation) { return FIRST_TIME_ASSOCIATIONS.has(String(authorAssociation || "").toUpperCase()); @@ -40,9 +40,10 @@ function changedLines(files) { function linkedIssueHasLabel(linkedIssues, labelName) { return (linkedIssues || []).some((issue) => - (issue.labels || []).some((label) => - (typeof label === "string" ? label : label?.name) === labelName, - ), + issue.state === "open" && + (issue.labels || []).some((label) => + (typeof label === "string" ? label : label?.name) === labelName, + ), ); } @@ -50,11 +51,13 @@ function assessTrustLane({ authorAssociation, authorHasPushPermission = false, files = [], + changedFiles, linkedIssues = [], otherOpenImplementationPrs = [], }) { if (authorHasPushPermission || !isFirstTimeContributor(authorAssociation)) return []; - if (!files.some((file) => isImplementationPath(file.filename))) return []; + const paths = changedFiles ?? (files || []).map((file) => file.filename); + if (!paths.some(isImplementationPath)) return []; const failures = []; if (otherOpenImplementationPrs.length > 0) { @@ -76,9 +79,7 @@ function assessTrustLane({ }); } - const restricted = files - .map((file) => file.filename) - .filter(isRestrictedPath); + const restricted = paths.filter(isRestrictedPath); if ( restricted.length > 0 && !linkedIssueHasLabel(linkedIssues, "maintainer-sponsored") diff --git a/.github/scripts/pr-trust-lane.test.cjs b/.github/scripts/pr-trust-lane.test.cjs index 6c2661671e..27ff57ad99 100644 --- a/.github/scripts/pr-trust-lane.test.cjs +++ b/.github/scripts/pr-trust-lane.test.cjs @@ -6,6 +6,7 @@ const { MAX_FIRST_TIME_CHANGED_LINES, assessTrustLane, isFirstTimeContributor, + isImplementationPath, isRestrictedPath, } = require("./pr-trust-lane.cjs"); @@ -23,6 +24,10 @@ describe("first-time classification", () => { assert.equal(isRestrictedPath("package.json"), true); assert.equal(isRestrictedPath("src/router.ts"), false); }); + + it("classifies bunfig.toml as an implementation file", () => { + assert.equal(isImplementationPath("bunfig.toml"), true); + }); }); describe("assessTrustLane", () => { @@ -49,11 +54,20 @@ describe("assessTrustLane", () => { const failures = assessTrustLane({ authorAssociation: "FIRST_TIMER", files: [{ filename: "src/router.ts", additions: 700, deletions: 0 }], - linkedIssues: [{ labels: [{ name: "large-change-approved" }] }], + 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", @@ -66,11 +80,20 @@ describe("assessTrustLane", () => { const failures = assessTrustLane({ authorAssociation: "NONE", files: [{ filename: "src/oauth/provider.ts", additions: 10, deletions: 2 }], - linkedIssues: [{ labels: ["maintainer-sponsored"] }], + 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/auth/oauth.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 }), []); From 842cd519167e8252a567c44c363c6ff8d278b2cd Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:54:33 +0200 Subject: [PATCH 3/5] fix(ci): repair trust-lane workflow syntax, scope permissions, add guarded dispatch, reuse permission helper --- .github/workflows/pr-trust-lane.yml | 72 ++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pr-trust-lane.yml b/.github/workflows/pr-trust-lane.yml index fe51ca07b4..3642769f27 100644 --- a/.github/workflows/pr-trust-lane.yml +++ b/.github/workflows/pr-trust-lane.yml @@ -3,20 +3,29 @@ 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. -permissions: - contents: read - issues: write - pull-requests: write +# 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 }} + 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 @@ -33,6 +42,12 @@ jobs: 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, @@ -41,10 +56,33 @@ jobs: ); const { owner, repo } = context.repo; - const pull_number = context.payload.pull_request.number; 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, @@ -59,13 +97,19 @@ jobs: } catch (error) { core.warning(`Permission lookup failed: ${error.message}`); } - const authorHasPushPermission = ["admin", "maintain", "write"].includes(permission); + 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 }); + 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}`); } @@ -78,17 +122,21 @@ jobs: 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, + owner, repo, pull_number: candidate.number, per_page: 100, }); if (candidateFiles.some((file) => isImplementationPath(file.filename))) { otherOpenImplementationPrs.push(candidate.number); } } + const changedFiles = files.flatMap((file) => + [file.filename, file.previous_filename].filter(Boolean), + ); const failures = assessTrustLane({ authorAssociation: pr.author_association, - authorHasPushPermission, + authorHasPushPermission: hasPushPermission, files, + changedFiles, linkedIssues, otherOpenImplementationPrs, }); @@ -121,7 +169,7 @@ jobs: }); } else if (!blocked && labels.has(blockedLabel)) { await github.rest.issues.removeLabel({ - owner, repo, issue_number pull_number, name: blockedLabel, + owner, repo, issue_number: pull_number, name: blockedLabel, }); } } @@ -164,7 +212,7 @@ jobs: } 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.`, + `Restricted paths: ${failure.paths.map((p) => `\`${p}\``).join(", ")}. The linked issue needs \`maintainer-sponsored\` before a first-time contributor changes these surfaces.`, "", ]; }); From 9361bd3553b646947cccd25bda958943b921ecfe Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:54:34 +0200 Subject: [PATCH 4/5] docs: sync trust-lane design record --- docs/superpowers/specs/2026-08-02-pr-trust-lane-design.md | 2 ++ 1 file changed, 2 insertions(+) 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 index e59e16bc41..18900c2d76 100644 --- a/docs/superpowers/specs/2026-08-02-pr-trust-lane-design.md +++ b/docs/superpowers/specs/2026-08-02-pr-trust-lane-design.md @@ -10,3 +10,5 @@ First-time contributors get a deliberately narrow lane until the repository has - 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. From 8a1ebcf2a446aa8a2f590dfc6bd5993bf6079724 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:10:12 +0200 Subject: [PATCH 5/5] fix(ci): restrict real auth and release paths, keep oldest active PR eligible --- .github/scripts/pr-trust-lane.cjs | 39 +++++++++++++++++-- .github/scripts/pr-trust-lane.test.cjs | 38 +++++++++++++++++- .github/workflows/pr-trust-lane.yml | 6 ++- .../specs/2026-08-02-pr-trust-lane-design.md | 2 + 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/.github/scripts/pr-trust-lane.cjs b/.github/scripts/pr-trust-lane.cjs index b4f30fcfc7..4f33011894 100644 --- a/.github/scripts/pr-trust-lane.cjs +++ b/.github/scripts/pr-trust-lane.cjs @@ -8,11 +8,31 @@ const FIRST_TIME_ASSOCIATIONS = new Set([ const MAX_FIRST_TIME_CHANGED_LINES = 500; const RESTRICTED_PREFIXES = [ ".github/workflows/", - "src/auth/", "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", ]); @@ -54,16 +74,29 @@ function assessTrustLane({ changedFiles, linkedIssues = [], otherOpenImplementationPrs = [], + currentPr = {}, }) { if (authorHasPushPermission || !isFirstTimeContributor(authorAssociation)) return []; const paths = changedFiles ?? (files || []).map((file) => file.filename); if (!paths.some(isImplementationPath)) return []; const failures = []; - if (otherOpenImplementationPrs.length > 0) { + // 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: otherOpenImplementationPrs, + pullRequests: [candidates[0].number], }); } diff --git a/.github/scripts/pr-trust-lane.test.cjs b/.github/scripts/pr-trust-lane.test.cjs index 27ff57ad99..971003bc76 100644 --- a/.github/scripts/pr-trust-lane.test.cjs +++ b/.github/scripts/pr-trust-lane.test.cjs @@ -25,6 +25,29 @@ describe("first-time classification", () => { 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); }); @@ -37,11 +60,22 @@ describe("assessTrustLane", () => { const failures = assessTrustLane({ authorAssociation: "FIRST_TIME_CONTRIBUTOR", files: smallRuntimeChange, - otherOpenImplementationPrs: [812], + 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", @@ -89,7 +123,7 @@ describe("assessTrustLane", () => { const failures = assessTrustLane({ authorAssociation: "NONE", files: [{ filename: "docs/moved.md", additions: 10, deletions: 2 }], - changedFiles: ["docs/moved.md", "src/auth/oauth.ts"], + changedFiles: ["docs/moved.md", "src/oauth/store.ts"], }); assert.equal(failures[0].code, "restricted_surface"); }); diff --git a/.github/workflows/pr-trust-lane.yml b/.github/workflows/pr-trust-lane.yml index 3642769f27..bcc4a94d83 100644 --- a/.github/workflows/pr-trust-lane.yml +++ b/.github/workflows/pr-trust-lane.yml @@ -125,7 +125,10 @@ jobs: owner, repo, pull_number: candidate.number, per_page: 100, }); if (candidateFiles.some((file) => isImplementationPath(file.filename))) { - otherOpenImplementationPrs.push(candidate.number); + otherOpenImplementationPrs.push({ + number: candidate.number, + created_at: candidate.created_at, + }); } } @@ -139,6 +142,7 @@ jobs: changedFiles, linkedIssues, otherOpenImplementationPrs, + currentPr: { number: pull_number, created_at: pr.created_at }, }); async function ensureLabel() { 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 index 18900c2d76..97cdd30ca9 100644 --- a/docs/superpowers/specs/2026-08-02-pr-trust-lane-design.md +++ b/docs/superpowers/specs/2026-08-02-pr-trust-lane-design.md @@ -12,3 +12,5 @@ First-time contributors get a deliberately narrow lane until the repository has 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.