Files
PaperClipAI/.github/scripts/tests/lockfile-trust.test.mjs
T
Nicky LeachandClaude Opus 5.5 18ff71bca3 fix(ci): load the lockfile policy from the current base-branch tip (#19390)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Only trusted environments may change `pnpm-lock.yaml`. #19356 moved
that rule into `.github/scripts/lockfile-trust.mjs`.
> - The `pr-trusted.yml` policy job loads that script from trusted code,
so a pull request cannot change the check that judges it.
> - The job reads the trusted commit from `pull_request.base.sha`.
GitHub does not move that value forward on every push.
> - #17364 showed the result. Its recorded base was still the `master`
commit from before #19356, so the job used the earlier rule.
> - The earlier rule exempts any branch named `chore/refresh-lockfile`,
including one from a fork.
> - This pull request loads the policy from the first parent of GitHub's
merge commit, which is the current base-branch tip.
> - The benefit is that every pull request gets the current lockfile
policy, including pull requests opened before #19356.

## Linked Issues or Issue Description

Refs #19356, #17364.

**What happened?**
On #17364 at `482d76686e`, the "Block untrusted lockfile edits" step
printed nothing and passed. `pull_request.base.sha` was `da3084a05d`,
the `master` commit from before #19356. That commit has no
`lockfile-trust.mjs`, so the step used the earlier rule. The merge
commit that CI tested already contained #19356.

**Expected behavior**
The policy job runs the current `lockfile-trust.mjs` from `master` for
every pull request.

**Steps to reproduce**
1. Open a pull request before #19356 merged, or keep a pull request
whose recorded base predates it.
2. Push a commit. GitHub keeps the old `pull_request.base.sha`.
3. The policy job uses the earlier rule. A fork branch named
`chore/refresh-lockfile` can still change `pnpm-lock.yaml`.

**Also found**
In a local test, the script exited 0 without any check when it ran from
a symlinked temporary directory. Its entry-point check compared a
symlinked path with a real path. GitHub-hosted Linux runners do not use
a symlinked `RUNNER_TEMP`, but the check must fail closed.

## What Changed

- `pr-trusted.yml` "Block untrusted lockfile edits":
- Load `lockfile-trust.mjs` from `$GITHUB_SHA^1`, the first parent of
GitHub's merge commit. Do this only when `$GITHUB_SHA^2` is the PR head.
Otherwise, keep `pull_request.base.sha`.
  - Give the script the same trusted base for its diff.
  - Fail the step if the script writes no `writer=` output.
- `lockfile-trust.mjs`: compare real paths in the entry-point check.
- `lockfile-trust.test.mjs`: run the real step script against a git
history with a stale recorded base and a merge commit. Check that it
blocks a contributor lockfile change on a `chore/refresh-lockfile`
branch, allows a docs-only change, and falls back to the recorded base
when no merge commit is present.
- `doc/RELEASE-AUTOMATION-SETUP.md`: state where the job loads the
policy from.

## Verification

- `node --test '.github/scripts/tests/*.test.mjs'`: 709 passed.
- The new stale-base test fails when the workflow change is removed.
- The policy-step tests run with `RUNNER_TEMP` behind a symlink. They
fail when the realpath fix is removed.
- A test loads a policy that exits 0 without writing `writer=`. It fails
when the fail-closed check is removed.
- This pull request's own CI uses `pr-trusted.yml` from `master`. The
new step takes effect after merge.

## Risks

- If the checkout is not GitHub's merge commit, the step falls back to
`pull_request.base.sha`, which is the current behavior. The
`pull_request` event always checks out the merge commit by default.
- The fail-closed check can fail a pull request if the script stops
writing `writer=`. The script writes it on every path that returns.
- Low risk otherwise. The change touches one workflow step, one script
line, tests, and docs.

## 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
- [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>
2026-11-06 07:25:41 -08:00

310 lines
17 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
classifyPullRequest,
findDependabotLockCommit,
findMergedDependabotPull,
isDependabotCommit,
isGitHubSignedCommit,
} from '../lockfile-trust.mjs';
const repo = 'paperclipai/paperclip';
const base = { author: 'someuser', headRepo: repo, baseRepo: repo, headRef: 'fix/bug', dependabotLockCommit: null };
test('allows a pull request that does not change lock content', () => {
assert.deepEqual(
{ ...classifyPullRequest({ ...base, changedPaths: ['server/src/index.ts'] }), reason: undefined },
{ allowed: true, writer: 'none', reason: undefined },
);
});
test('blocks a contributor lockfile change', () => {
const result = classifyPullRequest({ ...base, changedPaths: ['pnpm-lock.yaml'] });
assert.equal(result.allowed, false);
assert.match(result.reason, /pnpm-lock\.yaml/);
});
test('blocks a contributor change to the reporting-lock proof', () => {
const result = classifyPullRequest({ ...base, changedPaths: ['tests/runner-e2e/report-lock.patch'] });
assert.equal(result.allowed, false);
assert.match(result.reason, /report-lock\.patch/);
});
test('blocks a contributor branch named like the refresh branch', () => {
for (const headRepo of [repo, 'someone/paperclip']) {
const result = classifyPullRequest({ ...base, headRepo, headRef: 'chore/refresh-lockfile', changedPaths: ['pnpm-lock.yaml'] });
assert.equal(result.allowed, false, headRepo);
}
});
test('blocks the refresh author and branch from a fork', () => {
const result = classifyPullRequest({ ...base, author: 'github-actions[bot]', headRepo: 'someone/paperclip',
headRef: 'chore/refresh-lockfile', changedPaths: ['pnpm-lock.yaml'] });
assert.equal(result.allowed, false);
});
test('allows the refresh workflow to change the lockfile and its proof', () => {
const result = classifyPullRequest({ ...base, author: 'github-actions[bot]', headRef: 'chore/refresh-lockfile',
changedPaths: ['pnpm-lock.yaml', 'tests/runner-e2e/report-lock.json', 'tests/runner-e2e/report-lock.patch'] });
assert.equal(result.allowed, true);
assert.equal(result.writer, 'refresh-bot');
});
test('allows a Dependabot lockfile that matches a verified Dependabot commit', () => {
const result = classifyPullRequest({ ...base, author: 'dependabot[bot]', headRef: 'dependabot/npm_and_yarn/zod-4.6.6',
changedPaths: ['package.json', 'pnpm-lock.yaml'], dependabotLockCommit: 'abc123' });
assert.equal(result.allowed, true);
assert.equal(result.writer, 'dependabot');
});
test('blocks a Dependabot lockfile that no verified Dependabot commit produced', () => {
const result = classifyPullRequest({ ...base, author: 'dependabot[bot]', changedPaths: ['pnpm-lock.yaml'] });
assert.equal(result.allowed, false);
assert.match(result.reason, /@dependabot recreate/);
});
test('blocks a Dependabot pull request that changes the reporting-lock proof', () => {
const result = classifyPullRequest({ ...base, author: 'dependabot[bot]', dependabotLockCommit: 'abc123',
changedPaths: ['pnpm-lock.yaml', 'tests/runner-e2e/report-lock.json'] });
assert.equal(result.allowed, false);
});
test('blocks a Dependabot author from a fork', () => {
const result = classifyPullRequest({ ...base, author: 'dependabot[bot]', headRepo: 'someone/paperclip',
dependabotLockCommit: 'abc123', changedPaths: ['pnpm-lock.yaml'] });
assert.equal(result.allowed, false);
});
test('finds only verified Dependabot commits that hold the exact lockfile', () => {
const blobs = { a: 'lock-1', b: 'lock-2', c: 'lock-2', d: 'lock-2' };
const lockBlobAt = (sha) => blobs[sha] ?? null;
const commits = [
{ sha: 'a', login: 'dependabot[bot]', committer: 'web-flow', verified: true },
{ sha: 'b', login: 'dependabot[bot]', committer: 'web-flow', verified: false },
{ sha: 'c', login: 'someuser', committer: 'web-flow', verified: true },
];
assert.equal(findDependabotLockCommit({ lockBlob: 'lock-2', commits, lockBlobAt }), null);
assert.equal(findDependabotLockCommit({ lockBlob: 'lock-1', commits, lockBlobAt }), 'a');
assert.equal(findDependabotLockCommit({ lockBlob: 'lock-2', lockBlobAt,
commits: [...commits, { sha: 'd', login: 'dependabot[bot]', committer: 'web-flow', verified: true }] }), 'd');
assert.equal(findDependabotLockCommit({ lockBlob: null, commits, lockBlobAt }), null);
});
test('requires a Dependabot author with GitHub as the verified committer', () => {
assert.equal(isDependabotCommit({ login: 'dependabot[bot]', committer: 'web-flow', verified: true }), true);
// A collaborator can sign with their own key and set Dependabot as the author.
assert.equal(isDependabotCommit({ login: 'dependabot[bot]', committer: 'someuser', verified: true }), false);
assert.equal(isDependabotCommit({ login: 'dependabot[bot]', committer: null, verified: true }), false);
assert.equal(isDependabotCommit({ login: 'dependabot[bot]', committer: 'web-flow', verified: false }), false);
assert.equal(isDependabotCommit({ login: 'someuser', committer: 'web-flow', verified: true }), false);
});
test('ignores a lockfile commit with a spoofed Dependabot author', () => {
const lockBlobAt = () => 'lock-1';
const spoofed = [{ sha: 'x', login: 'dependabot[bot]', committer: 'someuser', verified: true }];
assert.equal(findDependabotLockCommit({ lockBlob: 'lock-1', commits: spoofed, lockBlobAt }), null);
});
test('finds the merged Dependabot pull request for a master commit', () => {
const pull = (overrides) => ({ number: 1, user: { login: 'dependabot[bot]' }, merged_at: '2026-11-05T00:00:00Z',
merge_commit_sha: 'm', base: { ref: 'master' }, ...overrides });
assert.equal(findMergedDependabotPull([pull({})], 'm', 'master')?.number, 1);
assert.equal(findMergedDependabotPull([pull({ merged_at: null })], 'm', 'master'), null);
assert.equal(findMergedDependabotPull([pull({ user: { login: 'someuser' } })], 'm', 'master'), null);
assert.equal(findMergedDependabotPull([pull({ merge_commit_sha: 'other' })], 'm', 'master'), null);
assert.equal(findMergedDependabotPull([pull({ base: { ref: 'release' } })], 'm', 'master'), null);
});
test('trusts only merge commits that GitHub created and signed', () => {
assert.equal(isGitHubSignedCommit({ committer: { login: 'web-flow' }, commit: { verification: { committer: 'web-flow', verified: true } } }), true);
assert.equal(isGitHubSignedCommit({ committer: { login: 'web-flow' }, commit: { verification: { committer: 'web-flow', verified: false } } }), false);
assert.equal(isGitHubSignedCommit({ committer: { login: 'someuser' }, commit: { verification: { committer: 'web-flow', verified: true } } }), false);
assert.equal(isGitHubSignedCommit(null), false);
});
// Run the `pr` command against a real git history, with a stub `gh` that lists the PR commits.
function runPrCheck(build) {
const root = mkdtempSync(path.join(tmpdir(), 'lockfile-trust-'));
try {
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
const commit = (author, files) => {
for (const [file, contents] of Object.entries(files)) writeFileSync(path.join(root, file), contents);
git('add', '.');
git('-c', `user.name=${author}`, '-c', `user.email=${author}@example.invalid`, 'commit', '-qm', author);
return git('rev-parse', 'HEAD');
};
git('init', '-q');
const baseSha = commit('base', { 'pnpm-lock.yaml': 'lock: base\n', 'package.json': '{}\n' });
const { author, commits } = build(commit);
const bin = path.join(root, '.bin');
execFileSync('mkdir', [bin]);
writeFileSync(path.join(bin, 'gh'), '#!/bin/sh\nprintf "%s\\n" "$GH_STUB_OUTPUT"\n');
chmodSync(path.join(bin, 'gh'), 0o755);
const output = path.join(root, '.output');
writeFileSync(output, '');
const result = spawnSync(process.execPath, [path.resolve('.github/scripts/lockfile-trust.mjs'), 'pr'], {
cwd: root, encoding: 'utf8',
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, GITHUB_OUTPUT: output, GITHUB_REPOSITORY: repo,
PR_NUMBER: '7', BASE_SHA: baseSha, HEAD_SHA: git('rev-parse', 'HEAD'), PR_AUTHOR: author, HEAD_REPO: repo,
HEAD_REF: 'dependabot/npm_and_yarn/zod', GH_STUB_OUTPUT: commits.map((entry) => JSON.stringify(entry)).join('\n') },
});
return { status: result.status, stdout: result.stdout, output: readFileSync(output, 'utf8') };
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test('pr command admits a Dependabot lockfile after a later non-lockfile commit', () => {
const result = runPrCheck((commit) => {
const sha = commit('dependabot', { 'pnpm-lock.yaml': 'lock: dependabot\n', 'package.json': '{"zod":"4.6.6"}\n' });
commit('maintainer', { 'notes.md': 'fix\n' });
return { author: 'dependabot[bot]', commits: [{ sha, login: 'dependabot[bot]', committer: 'web-flow', verified: true }] };
});
assert.equal(result.status, 0, result.stdout);
assert.match(result.output, /^writer=dependabot$/m);
});
test('pr command blocks a lockfile edited after the Dependabot commit', () => {
const result = runPrCheck((commit) => {
const sha = commit('dependabot', { 'pnpm-lock.yaml': 'lock: dependabot\n' });
const edit = commit('maintainer', { 'pnpm-lock.yaml': 'lock: edited\n' });
return { author: 'dependabot[bot]', commits: [
{ sha, login: 'dependabot[bot]', committer: 'web-flow', verified: true },
{ sha: edit, login: 'maintainer', committer: 'web-flow', verified: true },
] };
});
assert.equal(result.status, 1);
assert.match(result.stdout, /Untrusted lockfile change/);
});
test('pr command blocks a lockfile commit whose Dependabot author another committer signed', () => {
const result = runPrCheck((commit) => {
const sha = commit('maintainer', { 'pnpm-lock.yaml': 'lock: spoofed\n' });
return { author: 'dependabot[bot]', commits: [{ sha, login: 'dependabot[bot]', committer: 'maintainer', verified: true }] };
});
assert.equal(result.status, 1);
assert.match(result.stdout, /Untrusted lockfile change/);
});
test('pr command blocks a contributor lockfile change', () => {
const result = runPrCheck((commit) => {
commit('someuser', { 'pnpm-lock.yaml': 'lock: contributor\n' });
return { author: 'someuser', commits: [] };
});
assert.equal(result.status, 1);
assert.match(result.output, /^writer=none$/m);
});
test('workflows run the trust policy from trusted code and pass the verified hash to the proof refresh', () => {
const pr = readFileSync('.github/workflows/pr-trusted.yml', 'utf8');
assert.match(pr, /git show "\$trusted_base:\.github\/scripts\/lockfile-trust\.mjs" > "\$policy"/);
assert.match(pr, /BASE_SHA="\$trusted_base" node "\$policy" pr/);
assert.match(pr, /PAPERCLIP_CI_PR_AUTHOR: \$\{\{ github\.event\.pull_request\.user\.login \}\}/);
assert.doesNotMatch(pr, /github\.head_ref != 'chore\/refresh-lockfile'/);
const refresh = readFileSync('.github/workflows/refresh-lockfile.yml', 'utf8');
assert.match(refresh, /node \.github\/scripts\/lockfile-trust\.mjs merged/);
assert.match(refresh, /--refresh --trusted-lock-sha256 "\$TRUSTED_LOCK_SHA256"/);
assert.ok(refresh.indexOf('lockfile-trust.mjs merged') < refresh.indexOf('--trusted-lock-sha256'));
});
// Run the policy job's step script, as GitHub would, against a merge commit.
// RUNNER_TEMP is a symlink, so the policy must still recognize itself as the entry point.
function runPolicyStep({ staleBase, githubSha, headSha, author, headRef, root }) {
const workflow = readFileSync('.github/workflows/pr-trusted.yml', 'utf8');
const step = workflow.split(' - name: Block untrusted lockfile edits\n')[1];
const lines = step.split('\n');
const start = lines.findIndex((line) => line === ' run: |') + 1;
const end = lines.findIndex((line, index) => index >= start && line.trim() !== '' && !line.startsWith(' '));
const script = lines.slice(start, end).map((line) => line.slice(10)).join('\n');
const output = path.join(root, '.output');
writeFileSync(output, '');
const runnerTemp = path.join(mkdtempSync(path.join(tmpdir(), 'policy-runner-')), 'temp');
symlinkSync(mkdtempSync(path.join(tmpdir(), 'policy-runner-real-')), runnerTemp);
const result = spawnSync('bash', ['-e', '-c', script], {
cwd: root, encoding: 'utf8',
env: { ...process.env, RUNNER_TEMP: runnerTemp, GITHUB_OUTPUT: output,
GITHUB_REPOSITORY: repo, GITHUB_SHA: githubSha, BASE_SHA: staleBase, HEAD_SHA: headSha, PR_NUMBER: '1',
PR_AUTHOR: author, HEAD_REPO: repo, HEAD_REF: headRef },
});
return { status: result.status, stdout: result.stdout + result.stderr, output: readFileSync(output, 'utf8') };
}
test('the policy step loads the policy from the merge commit when the recorded base is stale', () => {
const root = mkdtempSync(path.join(tmpdir(), 'lockfile-policy-step-'));
try {
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
const commit = (message) => {
git('add', '.');
git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-qm', message);
return git('rev-parse', 'HEAD');
};
git('init', '-q', '-b', 'master');
writeFileSync(path.join(root, 'pnpm-lock.yaml'), 'lock: base\n');
const staleBase = commit('base before the policy');
// A contributor branch named like the refresh branch, from the stale base. The earlier rule exempts it.
git('checkout', '-q', '-b', 'chore/refresh-lockfile');
writeFileSync(path.join(root, 'pnpm-lock.yaml'), 'lock: contributor\n');
const head = commit('contributor lockfile');
git('checkout', '-q', 'master');
execFileSync('mkdir', ['-p', path.join(root, '.github/scripts')]);
writeFileSync(path.join(root, '.github/scripts/lockfile-trust.mjs'), readFileSync('.github/scripts/lockfile-trust.mjs'));
commit('add the policy');
git('-c', 'user.name=GitHub', '-c', 'user.email=noreply@github.com', 'merge', '-q', '--no-ff', '-X', 'theirs', '-m', 'merge', head);
const merge = git('rev-parse', 'HEAD');
const blocked = runPolicyStep({ root, staleBase, githubSha: merge, headSha: head, author: 'someuser', headRef: 'chore/refresh-lockfile' });
assert.equal(blocked.status, 1, blocked.stdout);
assert.match(blocked.stdout, /Untrusted lockfile change/);
// A contributor pull request that does not touch lock content passes through the loaded policy.
git('checkout', '-q', '-b', 'docs', staleBase);
writeFileSync(path.join(root, 'notes.md'), 'docs\n');
const docs = commit('docs only');
git('checkout', '-q', 'master');
git('-c', 'user.name=GitHub', '-c', 'user.email=noreply@github.com', 'merge', '-q', '--no-ff', '-m', 'merge docs', docs);
const allowed = runPolicyStep({ root, staleBase, githubSha: git('rev-parse', 'HEAD'), headSha: docs, author: 'someuser', headRef: 'docs' });
assert.equal(allowed.status, 0, allowed.stdout);
assert.match(allowed.stdout, /does not change lock content/);
assert.match(allowed.output, /^writer=none$/m);
// Without GitHub's merge commit, the step keeps the recorded base, which predates the policy.
const fallback = runPolicyStep({ root, staleBase, githubSha: head, headSha: head, author: 'someuser', headRef: 'chore/refresh-lockfile' });
assert.equal(fallback.status, 0, fallback.stdout);
assert.match(fallback.output, /^writer=none$/m);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('the policy step fails when the loaded policy exits without reporting a writer', () => {
const root = mkdtempSync(path.join(tmpdir(), 'lockfile-policy-silent-'));
try {
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
const commit = (message) => {
git('add', '.');
git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-qm', message);
return git('rev-parse', 'HEAD');
};
git('init', '-q', '-b', 'master');
execFileSync('mkdir', ['-p', path.join(root, '.github/scripts')]);
writeFileSync(path.join(root, '.github/scripts/lockfile-trust.mjs'), 'process.exit(0);\n');
const baseSha = commit('a policy that skips its checks');
git('checkout', '-q', '-b', 'feature');
writeFileSync(path.join(root, 'pnpm-lock.yaml'), 'lock: contributor\n');
const head = commit('contributor lockfile');
git('checkout', '-q', 'master');
git('-c', 'user.name=GitHub', '-c', 'user.email=noreply@github.com', 'merge', '-q', '--no-ff', '-m', 'merge', head);
const result = runPolicyStep({ root, staleBase: baseSha, githubSha: git('rev-parse', 'HEAD'), headSha: head, author: 'someuser', headRef: 'feature' });
assert.equal(result.status, 1, result.stdout);
assert.match(result.stdout, /did not report a lockfile writer/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});