Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .github/scripts/pr-trust-lane.cjs
Original file line number Diff line number Diff line change
@@ -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/",
];
Comment on lines +9 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cover the repository's actual authentication paths

The authentication restriction only matches src/auth/, but that directory does not exist in this tree; authentication and secret handling instead live in files such as src/codex/auth-api.ts, src/server/management-auth.ts, src/cli/account-auth.ts, and src/lib/admin-secrets.ts. A first-time contributor can therefore modify these security-boundary files without sponsorship. Replace the nonexistent-prefix assumption with coverage of the actual authentication and credential modules, backed by representative tests.

AGENTS.md reference: AGENTS.md:L187-L193

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[shipping-github] Fixed in 8a1ebcf2: the dead src/auth/ prefix is replaced with the repository's actual auth/credential/secret module paths (src/codex/auth-api.ts, src/codex/auth-context.ts, src/codex/auth-collision.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-auth.ts, src/server/management-api.ts, src/server/management/oauth-account-routes.ts, src/claude/auth-*.ts), keeping src/oauth/ and .github/workflows/ prefixes. All covered by tests.

const RESTRICTED_FILES = new Set([
"scripts/release.ts",
"package.json",
"bun.lock",
]);
Comment on lines +13 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Classify nested dependency manifests as restricted

The exact-name set covers only root package.json and bun.lock, so a first-time author can change gui/package.json or gui/bun.lock without maintainer-sponsored; the GUI prefix merely classifies the PR as implementation work. Those files control installed dependency code just as the root manifests do, so recognize dependency manifests and lockfiles at supported nested roots and add tests for them.

AGENTS.md reference: AGENTS.md:L187-L193

Useful? React with 👍 / 👎.

Comment on lines +13 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict all release and packaging automation

Only scripts/release.ts is treated as a restricted release file, while scripts/release-notes.ts is executed repeatedly by .github/workflows/release.yml and scripts/prepare-package.ts controls the published package payload. Both currently receive only the ordinary implementation limits, so first-time authors can change release behavior without maintainer-sponsored. Include every script executed by the release and package-publication paths in the restricted surface.

AGENTS.md reference: AGENTS.md:L187-L193

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[shipping-github] Fixed in 8a1ebcf2: scripts/release-notes.ts and scripts/prepare-package.ts are now restricted files alongside scripts/release.ts, so first-time contributors cannot change release behavior or the published package payload without maintainer-sponsored. Tested.

const IMPLEMENTATION_PREFIXES = ["src/", "gui/", "scripts/", "tests/", "bin/", "packages/", ".github/workflows/"];
const IMPLEMENTATION_FILES = new Set(["package.json", "bun.lock", "tsconfig.json"]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict executable GitHub automation scripts

A first-time contributor changing only .github/scripts/pr-admission.cjs or another workflow-loaded script bypasses this lane entirely: .github/scripts/ appears in neither the implementation nor restricted prefixes, so line 57 returns before sponsorship is checked. These scripts are executed by trusted pull_request_target workflows with write tokens, so include this directory in both classifications and cover it with a regression test.

AGENTS.md reference: .github/AGENTS.md:L7-L8

Useful? React with 👍 / 👎.


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 [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Evaluate both sides of renamed files

Path classification examines only file.filename, even though GitHub reports the old path of a rename in previous_filename. A PR that renames .github/workflows/ci.yml to a documentation path is consequently treated as docs-only and returns before the restricted check, despite removing a security-boundary workflow. Apply implementation and restricted-path checks to both the current and previous filenames and add a renamed-file scenario.

AGENTS.md reference: .github/AGENTS.md:L7-L8

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[shipping-github] Already fixed in 1028cec9 + 842cd519: changedFiles now includes previous_filename and feeds both the implementation and restricted-path checks, so a rename of .github/workflows/ci.yml (or any restricted file) cannot bypass the gate. Regression test covers a rename into docs/.


const failures = [];
if (otherOpenImplementationPrs.length > 0) {
failures.push({
code: "active_pr_limit",
pullRequests: otherOpenImplementationPrs,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve one eligible PR when enforcing the active limit

The predicate rejects a PR whenever any other implementation PR by the author is open, so after a second PR is blocked, the next synchronize or edit event on the original PR makes it see the second and become blocked too. Concurrently opened PRs can likewise both fail immediately, leaving zero active submissions instead of one. Select a deterministic keeper, such as the oldest open implementation PR, and reject only the others.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[shipping-github] Fixed in 8a1ebcf2: the one-active-PR limit now keeps the oldest open implementation PR eligible and rejects only newer ones (currentPr + created_at ordering), so a second PR can never block the author's first submission. Unit-tested for both directions.

}

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,
};
79 changes: 79 additions & 0 deletions .github/scripts/pr-trust-lane.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"use strict";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the trust-lane tests in CI

The new pr-trust-lane.test.cjs file is not referenced anywhere else in the target tree: .github/workflows/issue-quality-tests.yml neither includes it in its path filters nor executes it, and Cross-platform CI ignores .github/scripts/**. Consequently these eight tests run only when a developer invokes the command manually, so regressions in the enforcement helper can merge without any automated failure. Add the new script, test, and workflow to the validator workflow's triggers and run node --test .github/scripts/pr-trust-lane.test.cjs there.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[shipping-github] Declined here because it is already implemented later in this stack: #905 wires pr-trust-lane.test.cjs into the policy-test workflow (path filters + node --test line). Keeping the CI wiring in #905 avoids duplicating it in #902.


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 }] }), []);
});
});
172 changes: 172 additions & 0 deletions .github/workflows/pr-trust-lane.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
name: PR trust lane

on:
pull_request_target:
types: [opened, reopened, edited, synchronize, ready_for_review]
Comment on lines +3 to +5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile when approval or active-PR state changes

This workflow listens only for changes to the currently evaluated PR. When a maintainer adds maintainer-sponsored or large-change-approved to its linked issue, or when the contributor closes the other PR named by active_pr_limit, none of those state changes triggers reevaluation of the blocked PR. Its failure and intake: trust-lane-blocked label therefore persist until an unrelated edit, synchronization, reopening, or manual rerun; add an issue/PR-close reconciliation path, a scheduled sweep, or an explicit dispatch mechanism for these prescribed remediations.

AGENTS.md reference: .github/AGENTS.md:L23-L24

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[shipping-github] Already addressed in 842cd519: a guarded workflow_dispatch re-run with a pull_request_number input lets a maintainer re-evaluate a blocked PR after the issue gains large-change-approved / maintainer-sponsored or after the author closes the other PR. Dispatch is restricted to the default branch via rejectsWorkflowDispatchNonDefaultBranch, verified to reject branch-selected runs before any API call.


# Trusted default-branch script only; no PR-head checkout or execution.
permissions:
contents: read
issues: write
pull-requests: write

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reduce pull-request token access to read-only

This pull_request_target job only reads through the pulls API and performs its mutations through issue labels and comments, yet it grants pull-requests: write. That unnecessarily gives the script token authority to update or close PRs; keep issues: write for the intended mutations and downgrade pull-request access to read.

AGENTS.md reference: .github/AGENTS.md:L12-L17

Useful? React with 👍 / 👎.


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 = "<!-- pr-trust-lane -->";
const blockedLabel = "intake: trust-lane-blocked";
Comment on lines +59 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Feed the blocked state into the readiness gate

When this workflow finds a violation it only adds intake: trust-lane-blocked, but .github/workflows/pr-readiness.yml lines 125-139 bases readiness on intake: admitted plus checks fetched for the PR head and never inspects this new label. Since a pull_request_target run executes against the base/default-branch SHA, its failed check is not among those head-SHA checks, so an admitted PR can still be moved to awaiting-maintainer while carrying the blocked label. Make readiness explicitly treat this label as an author-action failure, or remove the admitted state while blocked.

Useful? React with 👍 / 👎.


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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate approval labels against the authorized issue

Because extractLinkedIssueNumbers accepts text such as Refs #123 from the untrusted PR body, an author can reference any unrelated or closed repository issue carrying maintainer-sponsored or large-change-approved, and linkedIssueHasLabel then treats that as authorization. Resolve a genuine open closing-linked issue and verify the relevant approval on that issue instead of trusting arbitrary issue references supplied in the body.

AGENTS.md reference: .github/AGENTS.md:L16-L17

Useful? React with 👍 / 👎.

} 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fix all syntax errors in the embedded workflow script

On every triggering event, actions/github-script must compile this entire script block, but pull_number candidate.number is invalid object-literal syntax; the same block also contains malformed expressions at lines 124 and 167. The step therefore exits before inspecting or labeling any PR. Fix all three expressions and add a test that compiles or executes the workflow body rather than testing only the imported helper.

AGENTS.md reference: .github/AGENTS.md:L23-L25

Useful? React with 👍 / 👎.

});
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(", ")}`);
12 changes: 12 additions & 0 deletions docs/superpowers/specs/2026-08-02-pr-trust-lane-design.md
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +7 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new contributor limits publicly

A repository-wide search of docs-site/ and the public contribution material finds none of the new one-PR limit, 500-line ceiling, sponsorship requirement, approval labels, or exemptions; they appear only in this internal design record and bot output. Contributors can therefore follow the published guide and still have their work blocked after submission. Add these rules and the process for obtaining approval to the public contributing documentation.

AGENTS.md reference: AGENTS.md:L200-L201

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[shipping-github] Declined here because it is already implemented later in this stack: #905's CONTRIBUTING.md "Pull request contract" section and docs-site page contributing/pr-quality.md document the one-PR limit, 500-line ceiling, sponsorship requirement, and approval labels. Keeping the docs change in #905 avoids duplicating it in #902.


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.
Loading