mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Only trusted environments may change `pnpm-lock.yaml`: the lockfile refresh workflow and Dependabot. Contributors never commit it. > - Paid evaluation jobs also install a lock only when the reporting-lock proof (`tests/runner-e2e/report-lock.json` and `.patch`) admits it. > - #19283 added a CI test that checks the committed lock against that proof. Only the refresh workflow writes the proof, so every Dependabot lockfile pull request now fails CI. Examples are #17364, #17172, and #17911. > - A merged Dependabot lockfile also breaks the refresh workflow. `prepare-report-lock.mjs --refresh` refuses to renew the proof for a lock that the proof does not already admit. > - This pull request lets a verified Dependabot lockfile pass, and it keeps both lockfile rules strict. It also closes a gap in the contributor rule. > - The benefit is that Dependabot security updates can merge again, and only trusted environments write lock content. ## Linked Issues or Issue Description Refs #19283, #17364, #17172, #17911, #19303. **What happened?** `server/scripts/reporting-lock-policy.test.mjs` fails on each Dependabot pull request that changes `pnpm-lock.yaml`. The error is "Reporting lock changed; review the dependency correction before install". A Dependabot pull request cannot change the proof files, so it can never pass. After a merge, the lockfile refresh also stops at "Committed report lock is not reviewed; refusing to refresh its proof". **Expected behavior** - Contributors cannot change `pnpm-lock.yaml` or the reporting-lock proof. - Dependabot can change `pnpm-lock.yaml`. - The lockfile refresh workflow can change `pnpm-lock.yaml` and the proof. - After a Dependabot lockfile merges, the refresh workflow admits it and renews the proof. **Steps to reproduce** 1. Open a Dependabot pull request that changes `pnpm-lock.yaml`, such as #17364. 2. Let CI run. The `server (9/12)` shard fails on the reporting-lock policy test. **Security gap found during this work** The old rule skipped the lockfile check when `github.head_ref` was `chore/refresh-lockfile`. A contributor could open a pull request from a fork branch with that name and commit a lockfile change. The old rule also exempted any pull request that Dependabot opened, including commits that other people pushed to its branch. ## What Changed - Add `.github/scripts/lockfile-trust.mjs`. It decides which writers may change lock content: - The refresh workflow: the head is `chore/refresh-lockfile` in this repository, and `github-actions[bot]` opened the pull request. - Dependabot: Dependabot opened the pull request from this repository, and the head `pnpm-lock.yaml` matches a Dependabot commit. That commit must have `dependabot[bot]` as its author, GitHub (`web-flow`) as its committer, and a verified signature. The script reads these fields from the GitHub API. - The reporting-lock proof files count as lock content. Only the refresh workflow may change them. - `pr-trusted.yml` policy job: replace the inline `head_ref` and author exemption with `lockfile-trust.mjs pr`. The job runs the script copy from the base commit, so a pull request cannot change the check that judges it. If the base predates the script, the job uses the earlier rule. - `refresh-lockfile.yml`: add `lockfile-trust.mjs merged`. It checks that the last lockfile change on `master` is a GitHub-signed merge of a Dependabot pull request, and that the lockfile at the pull request head matches a verified Dependabot commit. If both checks pass, it gives the lockfile SHA-256 to `prepare-report-lock.mjs --refresh --trusted-lock-sha256`. - `refresh-lockfile.yml` PR step: check `pnpm-lock.yaml` and both proof files before it exits as unchanged. A refresh that renews only the proof now opens a pull request. - `prepare-report-lock.mjs` and its `.d.mts` declaration: `refreshReportLock` accepts a trusted lock hash. If the trusted lock already resolves, the proof admits it exactly with an empty patch. If not, the proof records the resolution as the reviewed correction, as before. - `reporting-lock-policy.test.mjs`: skip on Dependabot pull requests. The policy job verifies those lockfiles. The general test job gets `PAPERCLIP_CI_PR_AUTHOR` for this purpose. This keeps the test, so it still merges cleanly with #19303. - `check-pr-lockfile.mjs` (advisory gate): allow Dependabot lockfile changes, and flag changes to the proof files. - `CODEOWNERS`: add the two proof files. - `doc/RELEASE-AUTOMATION-SETUP.md`: describe the writers, the `@dependabot recreate` path, and the proof renewal after merge. ## Verification - `node --test '.github/scripts/tests/*.test.mjs'`: 707 passed. This includes 17 new tests in `lockfile-trust.test.mjs` and 3 new tests in `check-pr-lockfile.test.mjs`. The new tests run the `pr` command against real git histories with a stub `gh`. - `tests/runner-e2e/prepare-report-lock.test.ts`: 10 passed, including 3 new trusted-lock refresh tests. I ran it with a minimal Vitest config, because the runner-e2e config builds a provider bundle first. - `server/scripts/reporting-lock-policy.test.mjs`: passes normally, and skips with `PAPERCLIP_CI_PR_AUTHOR=dependabot[bot]`. - Live check of the `pr` mode against #17364 with the real GitHub API: - With the real author, it reports "pnpm-lock.yaml matches verified Dependabot commit 83c328c15c…" and `writer=dependabot`. - With a contributor as the author and `chore/refresh-lockfile` as the head ref, it fails. - Live check of the `merged` mode at the real merge commits of #16018, #16020, and #15361. All three report a trusted lock hash. For #16018 and #15361, `master` changed after Dependabot's commit, so GitHub's merge produced different lockfile bytes. An exact-bytes rule would refuse them. That is why the rule trusts GitHub-signed merges of a verified Dependabot head. - Greptile reviewed `513ca2def8` at 4/5 with one P1: a proof-only refresh was never committed, because the PR step checked only `pnpm-lock.yaml`. `edbeb1ea0d` fixes it. A new test in `lockfile-refresh-merge.test.mjs` runs the step's change check on a proof-only change. The test fails without the fix. - CI `Typecheck + Release Registry` failed at `edbeb1ea0d`: `prepare-report-lock.d.mts` did not declare the new options argument (TS2554 twice in `prepare-report-lock.test.ts`). `9139aaad85` updates the declaration. A local `tsc -p tests/runner-e2e/tsconfig.json` shows the 2 errors with the old declaration and none with the new one. - CI at `9139aaad85` failed in six general test shards. None of them run code that this pull request changes, and PR CI loads `pr-trusted.yml` from `master`. The failures: - `heartbeat-plugin-environment.test.ts`: "expected 'running' to be 'succeeded'" - `status-card-evaluation-work.test.ts`: a deadline comparison - `isolated-repository-rehome.test.ts`: timed out after 15 seconds - `chat-channels.integration.test.ts`: GitHub webhook recovery scheduling - `company-import-export-e2e.test.ts`: child server start timeout, which also fails on unrelated branches - `workspaces-b`: every test passed, but an `EPIPE` error came after `acpx-engine/execute.test.ts` I started a rerun of the failed jobs. - Superagent found a P1 at `9139aaad85`: a verified signature proves who committed, not who authored. A collaborator could sign a commit with their own key and set Dependabot as the author. `4fe180d6c1` also requires GitHub (`web-flow`) as the committer. New tests cover the spoofed-author case. #17364, #16018, #16020 and #15361 still verify against the GitHub API. - `git merge-file` merges this test change with #19303 without conflicts. - Not run: the full repository test suite and typecheck. These changes touch only CI scripts, workflows, one helper, and tests. GitHub CI covers full regression. ## Risks - `pr-trusted.yml` loads from `master`, so the new policy takes effect only after merge. This pull request's own CI uses the earlier rule. - A Dependabot pull request that is behind `master` now fails if someone merges `master` into it and the lockfile changes. Use `@dependabot recreate`. The policy job error says this. - Paid evaluation jobs refuse a merged Dependabot lockfile until the refresh pull request merges. The refresh merges without review when it adds no new package versions. If the release-age gate holds it, the wait lasts until a maintainer merges it or the daily refresh runs. During that time, the reporting-lock policy test also fails on other pull requests, which is the current behavior for any unrenewed lock. - The rule trusts GitHub as the signer of Dependabot commits and of merge commits (`web-flow`). Superagent uses the same trust root. - People with write access can still push to `chore/refresh-lockfile`. This was already true. The bot merges only its own head commit (`--match-head-commit`), and code-owner review covers anything else. - A future manual proof renewal, such as #19283, now needs the refresh workflow or an admin merge. A contributor pull request that changes the proof files fails. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution, in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused tests; see Verification) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
10 KiB
JavaScript
220 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* lockfile-trust.mjs
|
|
*
|
|
* Decides who may change the dependency lock. Only trusted environments write it:
|
|
* - the lockfile refresh workflow (`chore/refresh-lockfile`, opened by github-actions[bot]),
|
|
* - Dependabot, when the exact lockfile bytes come from a verified Dependabot commit.
|
|
*
|
|
* The reporting-lock proof (`tests/runner-e2e/report-lock.json` and `.patch`) can rewrite the
|
|
* lock that paid evaluation jobs install, so it counts as lock content too. Only the refresh
|
|
* workflow writes it. After a verified Dependabot lockfile merges, the refresh workflow renews
|
|
* the proof for it.
|
|
*
|
|
* Keep this file free of imports outside node:*. The PR policy job runs the copy from the base
|
|
* commit, so a pull request cannot rewrite the check that judges it.
|
|
*
|
|
* Usage:
|
|
* node .github/scripts/lockfile-trust.mjs pr # PR policy job
|
|
* node .github/scripts/lockfile-trust.mjs merged # refresh workflow on master
|
|
*/
|
|
import { createHash } from 'node:crypto';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { appendFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
export const LOCKFILE = 'pnpm-lock.yaml';
|
|
export const REPORT_LOCK_FILES = ['tests/runner-e2e/report-lock.json', 'tests/runner-e2e/report-lock.patch'];
|
|
export const DEPENDABOT_LOGIN = 'dependabot[bot]';
|
|
export const REFRESH_BRANCH = 'chore/refresh-lockfile';
|
|
export const REFRESH_PR_AUTHOR = 'github-actions[bot]';
|
|
// GitHub commits and signs Dependabot commits as web-flow. A verified signature proves only who
|
|
// committed, and anyone can set the author field, so require GitHub as the committer as well.
|
|
export const GITHUB_COMMITTER_LOGIN = 'web-flow';
|
|
|
|
const RECREATE_HINT = 'Comment `@dependabot recreate` so Dependabot rebuilds the lockfile, instead of merging or editing it by hand.';
|
|
|
|
/**
|
|
* True for a commit that Dependabot authored and GitHub committed and signed.
|
|
* @param {{ login: string | null, committer: string | null, verified: boolean }} commit
|
|
*/
|
|
export function isDependabotCommit(commit) {
|
|
return commit.login === DEPENDABOT_LOGIN && commit.committer === GITHUB_COMMITTER_LOGIN && commit.verified === true;
|
|
}
|
|
|
|
/**
|
|
* Find a Dependabot commit (see isDependabotCommit) in the pull request that holds exactly the
|
|
* lockfile under review.
|
|
* @param {{ lockBlob: string | null,
|
|
* commits: Array<{ sha: string, login: string | null, committer: string | null, verified: boolean }>,
|
|
* lockBlobAt: (sha: string) => string | null }} input
|
|
*/
|
|
export function findDependabotLockCommit({ lockBlob, commits, lockBlobAt }) {
|
|
if (!lockBlob) return null;
|
|
for (const commit of [...commits].reverse()) {
|
|
if (!isDependabotCommit(commit)) continue;
|
|
if (lockBlobAt(commit.sha) === lockBlob) return commit.sha;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Classify the lock-content change in a pull request.
|
|
* @returns {{ allowed: boolean, writer: 'none' | 'dependabot' | 'refresh-bot', reason: string }}
|
|
*/
|
|
export function classifyPullRequest({ changedPaths, author, headRepo, baseRepo, headRef, dependabotLockCommit }) {
|
|
const lockfileChanged = changedPaths.includes(LOCKFILE);
|
|
const proofChanged = changedPaths.some((file) => REPORT_LOCK_FILES.includes(file));
|
|
if (!lockfileChanged && !proofChanged) {
|
|
return { allowed: true, writer: 'none', reason: 'The pull request does not change lock content.' };
|
|
}
|
|
|
|
const sameRepo = Boolean(headRepo) && headRepo === baseRepo;
|
|
if (sameRepo && author === REFRESH_PR_AUTHOR && headRef === REFRESH_BRANCH) {
|
|
return { allowed: true, writer: 'refresh-bot', reason: 'The lockfile refresh workflow opened this pull request.' };
|
|
}
|
|
|
|
if (sameRepo && author === DEPENDABOT_LOGIN) {
|
|
if (proofChanged) {
|
|
return { allowed: false, writer: 'dependabot',
|
|
reason: `Dependabot pull requests must not change ${REPORT_LOCK_FILES.join(' or ')}. The lockfile refresh renews the reporting-lock proof after merge.` };
|
|
}
|
|
if (!dependabotLockCommit) {
|
|
return { allowed: false, writer: 'dependabot',
|
|
reason: `${LOCKFILE} does not match any verified Dependabot commit in this pull request. ${RECREATE_HINT}` };
|
|
}
|
|
return { allowed: true, writer: 'dependabot', reason: `${LOCKFILE} matches verified Dependabot commit ${dependabotLockCommit}.` };
|
|
}
|
|
|
|
const files = [lockfileChanged && LOCKFILE, ...REPORT_LOCK_FILES.filter((file) => changedPaths.includes(file))].filter(Boolean);
|
|
return { allowed: false, writer: 'none',
|
|
reason: `Do not commit ${files.join(', ')} in pull requests. CI owns lockfile updates.` };
|
|
}
|
|
|
|
/**
|
|
* True when GitHub created and signed the commit, as it does when it merges a pull request.
|
|
* @param {any} commit Response of GET /repos/{repo}/commits/{sha}.
|
|
*/
|
|
export function isGitHubSignedCommit(commit) {
|
|
return commit?.committer?.login === GITHUB_COMMITTER_LOGIN && commit?.commit?.verification?.verified === true;
|
|
}
|
|
|
|
/**
|
|
* Pick the merged Dependabot pull request whose squash or merge commit is `commitSha`.
|
|
* @param {Array<any>} pulls Response of GET /repos/{repo}/commits/{sha}/pulls.
|
|
*/
|
|
export function findMergedDependabotPull(pulls, commitSha, defaultBranch) {
|
|
return pulls.find((pull) => pull?.user?.login === DEPENDABOT_LOGIN && Boolean(pull.merged_at) &&
|
|
pull.merge_commit_sha === commitSha && pull?.base?.ref === defaultBranch) ?? null;
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }).trim();
|
|
}
|
|
|
|
function tryRun(command, args) {
|
|
try { return run(command, args); } catch { return null; }
|
|
}
|
|
|
|
function ghJsonLines(endpoint, jq) {
|
|
const output = run('gh', ['api', '--paginate', endpoint, '--jq', jq]);
|
|
return output ? output.split('\n').map((line) => JSON.parse(line)) : [];
|
|
}
|
|
|
|
function pullCommits(repository, number) {
|
|
return ghJsonLines(`repos/${repository}/pulls/${number}/commits?per_page=100`,
|
|
'.[] | {sha, login: .author.login, committer: .committer.login, verified: .commit.verification.verified}');
|
|
}
|
|
|
|
function writeOutputs(values) {
|
|
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}\n`).join('');
|
|
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, lines);
|
|
}
|
|
|
|
function required(name) {
|
|
const value = process.env[name];
|
|
if (!value) throw new Error(`${name} is required`);
|
|
return value;
|
|
}
|
|
|
|
function checkPullRequest() {
|
|
const repository = required('GITHUB_REPOSITORY');
|
|
const baseSha = required('BASE_SHA');
|
|
const headSha = required('HEAD_SHA');
|
|
const author = required('PR_AUTHOR');
|
|
// Diff against the merge base so recent base-branch commits are not counted as PR changes.
|
|
const changedPaths = run('git', ['diff', '--name-only', `${baseSha}...${headSha}`]).split('\n').filter(Boolean);
|
|
|
|
let dependabotLockCommit = null;
|
|
if (author === DEPENDABOT_LOGIN && changedPaths.includes(LOCKFILE)) {
|
|
dependabotLockCommit = findDependabotLockCommit({
|
|
lockBlob: tryRun('git', ['rev-parse', `${headSha}:${LOCKFILE}`]),
|
|
commits: pullCommits(repository, required('PR_NUMBER')),
|
|
lockBlobAt: (sha) => tryRun('git', ['rev-parse', `${sha}:${LOCKFILE}`]),
|
|
});
|
|
}
|
|
|
|
const result = classifyPullRequest({
|
|
changedPaths, author, headRepo: process.env.HEAD_REPO ?? '', baseRepo: repository,
|
|
headRef: process.env.HEAD_REF ?? '', dependabotLockCommit,
|
|
});
|
|
console.log(result.reason);
|
|
writeOutputs({ writer: result.writer });
|
|
if (!result.allowed) {
|
|
console.log(`::error title=Untrusted lockfile change::${result.reason}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* On master: report the committed lockfile's SHA-256 when its last change is a GitHub-signed merge
|
|
* of a Dependabot pull request whose head lockfile bytes come from a verified Dependabot commit.
|
|
* When master's lockfile moved after Dependabot's commit, GitHub's merge combines the two trusted
|
|
* lockfiles, so the merged bytes can differ from Dependabot's. The refresh then records the
|
|
* resolution of that merged lock as the reviewed correction. Otherwise report nothing.
|
|
*/
|
|
function checkMerged() {
|
|
const repository = required('GITHUB_REPOSITORY');
|
|
const head = required('GITHUB_SHA');
|
|
const defaultBranch = process.env.DEFAULT_BRANCH || 'master';
|
|
const lockCommit = run('gh', ['api', `repos/${repository}/commits?sha=${head}&path=${LOCKFILE}&per_page=1`, '--jq', '.[0].sha // empty']);
|
|
const pull = lockCommit
|
|
? findMergedDependabotPull(JSON.parse(run('gh', ['api', `repos/${repository}/commits/${lockCommit}/pulls`])), lockCommit, defaultBranch)
|
|
: null;
|
|
if (pull && !isGitHubSignedCommit(JSON.parse(run('gh', ['api', `repos/${repository}/commits/${lockCommit}`])))) {
|
|
console.log(`::warning title=Unverified Dependabot merge::${lockCommit} is not a GitHub-signed merge of #${pull.number}. The reporting-lock proof is not renewed.`);
|
|
writeOutputs({ trusted_lock_sha256: '', dependabot_pr: String(pull.number) });
|
|
return;
|
|
}
|
|
if (!pull) {
|
|
console.log(`The last ${LOCKFILE} change on ${defaultBranch} is not a merged Dependabot pull request.`);
|
|
writeOutputs({ trusted_lock_sha256: '', dependabot_pr: '' });
|
|
return;
|
|
}
|
|
|
|
// The checkout is shallow, so read each candidate's lockfile blob hash from the API.
|
|
const lockBlobAt = (sha) => tryRun('gh', ['api', `repos/${repository}/git/trees/${sha}`,
|
|
'--jq', `.tree[] | select(.path == "${LOCKFILE}") | .sha`]) || null;
|
|
const verified = findDependabotLockCommit({
|
|
lockBlob: lockBlobAt(pull.head.sha),
|
|
commits: pullCommits(repository, pull.number),
|
|
lockBlobAt,
|
|
});
|
|
if (!verified) {
|
|
console.log(`::warning title=Unverified Dependabot lockfile::The ${LOCKFILE} at the head of #${pull.number} matches no verified Dependabot commit. The reporting-lock proof is not renewed.`);
|
|
writeOutputs({ trusted_lock_sha256: '', dependabot_pr: String(pull.number) });
|
|
return;
|
|
}
|
|
const lockSha256 = createHash('sha256').update(execFileSync('git', ['show', `HEAD:${LOCKFILE}`])).digest('hex');
|
|
console.log(`${LOCKFILE} on ${defaultBranch} is GitHub's merge of verified Dependabot commit ${verified} from #${pull.number}.`);
|
|
writeOutputs({ trusted_lock_sha256: lockSha256, dependabot_pr: String(pull.number) });
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const mode = process.argv[2];
|
|
if (mode === 'pr') checkPullRequest();
|
|
else if (mode === 'merged') checkMerged();
|
|
else throw new Error('Usage: lockfile-trust.mjs pr|merged');
|
|
}
|