Files
qwen-code/scripts/verify-pr-prepare.mjs
T
Shaojin Wen 5d45b42940 feat(verify-pr): give the sandboxed round the PR's review threads (#16300)
* feat(verify-pr): give the sandboxed round the PR's review threads

The sandboxed /verify round had no token and no review context. Its only
inputs were the metadata snapshot and, on a follow-up round, the previous
report. On #13652, Critical R1-26 was confirmed and then marked fixed
three days before a round that published merge-ready without mentioning
it. Eight hours later the review bot, at the head that round had
certified, still could not rule out its residual.

The verify job's trusted resolve step now writes review-threads.json
beside pr.json. It holds every review thread, resolved ones included,
since resolving is the author's claim, together with each reviewer's
standing CHANGES_REQUESTED review. It comes from one paginated GraphQL
query reshaped with jq. The snapshot is best-effort: on failure no file
is written and the step warns.

verify-pr-prepare.mjs sends the same query and writes the same shape for
the local path. It removes a previous copy first, so a failed fetch
cannot leave a stale file. A test runs the workflow's block verbatim and
prepare's transform over the same pages and requires equal output.

The skill adds report item 4, Existing review findings. Each term the
verdict turns on is computed from snapshot fields:

- a thread is blocking when its first comment is tagged Critical,
  Blocker, P0 or P1 by an owner, member or collaborator;
- a blocking thread is open when it is unresolved, or when that comment
  belongs to a standing change request, resolved or not. That is the rule
  GitHub's review decision applies.

An open blocking thread left unmeasured rules out merge-ready, and with
nothing failed the verdict is inconclusive. Findings at fail 0 would
publish as "passed with non-blocking findings". Other blocking threads are
re-measured as budget allows, and the verdict line counts those left
unmeasured. Change request bodies are listed but do not hold the verdict,
because their severities are prose. The duty is also a line in the scope
budget. A missing file reads as unknown, never as "nothing open".

The file adds 2396 bytes, so the size baseline records the exact 378242.

* test(verify-pr): pin the review snapshot's jq guard, copy order and odd answers

- The CI/local parity test runs the step's real jq program. Like the
  file's four other jq cases, it now skips when jq is missing, instead of
  failing one assertion later with a message that names neither jq nor
  the missing tool.
- The local recipe must clear the cached review-threads.json before it
  copies prepare's file. The test pinned the three lines but not their
  order. With rm after cp, every local round would read the review state
  as unknown.
- prepare's two other failure arms are now driven through the CLI: an
  answer that is not JSON, and pages without a pull request. Each must
  leave no file and say why. An empty state there would read as "nothing
  open".

* test(verify-pr): drive the review snapshot through the shared gh harness

The review-snapshot case had its own copy of the executed-step harness:
a temp dir, a stub gh on PATH and GitHub's bash flags. makeGhHarness
exists so that fidelity contract lives in one place. The factory now
records every call's argv, returns its temp dir, and has an
empty-stdout arm for paginated reads, GH_STUB_EMPTY_LIST. The snapshot
case runs through it. Its failure arms are the factory's
GH_STUB_FAIL_LIST and GH_STUB_EMPTY_LIST.

Deleting the shared stub's fail arm, its empty-stdout arm or its argv
capture turns the case red, and so does recording only the first
argument.
2026-11-05 23:50:44 +08:00

771 lines
28 KiB
JavaScript

#!/usr/bin/env node
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Local preflight for a `/verify-pr` maintainer round.
*
* The CI lane supplies three things a workstation does not: the PR metadata
* snapshot (`$QWEN_VERIFY_CONTEXT`), a clone holding both the base and the head
* OID, and the artifact directory the publication step globs. Resolving them by
* hand is the part of the local path that fails silently — a snapshot from the
* wrong repository, `HEAD^1` mistaken for the base on a plain PR-head checkout,
* or a directory named without the `-verify-` infix all produce a round whose
* report is about another PR or is discarded outright.
*
* This script resolves those coordinates and checks the tree the round will
* reuse; it does not checkout a worktree, build, or execute PR code. The tree
* check exists because the round runs the head side of its A/B in the current
* tree: prepare refuses a HEAD other than the PR head or any uncommitted
* change, and never stashes for the maintainer. It also cannot gate on
* isolation: resolving the coordinates needs `gh`, and `gh` and the boundary
* are mutually exclusive, so this path always runs on the host. The gate is
* the separate `--require-isolation` mode, meant to run inside the boundary
* before the round executes anything.
*
* Usage:
* node scripts/verify-pr-prepare.mjs <pr> --repo <owner>/<repo>
* node scripts/verify-pr-prepare.mjs --require-isolation
*
* Options:
* --repo <owner>/<repo> required for the coordinate path: never inferred
* from `origin`, because a fork's `origin` resolves the
* same PR number to an unrelated PR
* --require-isolation check the process this runs in and exit 4 when no
* boundary is detected; run it inside the boundary
*
* Exit codes:
* 0 coordinates resolved, or the boundary check passed
* 2 usage error
* 3 metadata, OID, tree-state, or filesystem failure
* 4 --require-isolation found no boundary — do not run PR code here
*/
import { spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { isEntryPoint } from './lib/entry-point.js';
const OID = /^[0-9a-f]{40}$/;
const REPO = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
const FIELDS = [
'number',
'title',
'body',
'author',
'baseRefName',
'baseRefOid',
'headRefOid',
'commits',
].join(',');
const HELP = `Usage: node scripts/verify-pr-prepare.mjs <pr> --repo <owner>/<repo>
node scripts/verify-pr-prepare.mjs --require-isolation
Resolves the coordinates a local /verify-pr round needs and writes the
metadata snapshot the round reads as $QWEN_VERIFY_CONTEXT:
- fetches the PR snapshot with gh, from the repository you name
- verifies the base and head OIDs exist in this clone
- refuses a tree that is not a clean checkout of the PR head, so the round
can reuse the current tree as the head side of the A/B; the gitignored
install/build still needs a freshness check before it is reused
- creates tmp/pr<n>-verify-<YYYYMMDD-HHMMSS>/ with an evidence/ subdirectory
and a context.json holding repo, pr, baseRefOid and headRefOid
- snapshots the PR's review threads and standing change requests beside
the metadata, as the CI lane does; best-effort, so a failed fetch leaves
no file and the round lists that review state as not covered
- reports the isolation signals this process can observe
The coordinate path runs on the host by construction: it needs gh, and gh and
the boundary are mutually exclusive. Run --require-isolation inside the
boundary, as the round's first command; exit 4 means this process is not
isolated and must not execute PR code. A container marker alone does not pass:
the CLI's own Docker/Podman launcher mounts ~/.config/gcloud and the ADC file
and copies model API keys, so the check also requires the credential-free
attestation and refuses when a known credential signal is visible. The
built-in macOS sandbox (SANDBOX=sandbox-exec) never counts.
`;
/** Returns `{owner, name}` for a GitHub repository slug, or null. */
export function parseRepo(value) {
const match = REPO.exec(String(value ?? '').trim());
if (!match) return null;
return { owner: match[1], name: match[2] };
}
/**
* Returns the artifact directory name the publication step globs. The
* `-verify-` infix is part of that contract, not decoration.
*/
export function artifactDirName(pr, now = new Date()) {
const pad = (value) => String(value).padStart(2, '0');
const stamp = [
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate()),
].join('');
const clock = [
pad(now.getHours()),
pad(now.getMinutes()),
pad(now.getSeconds()),
].join('');
return `pr${pr}-verify-${stamp}-${clock}`;
}
/**
* The built-in macOS sandbox announces itself with this value while its
* default `permissive-open` profile still lets the process read host files, so
* it is not the credential-free boundary the round needs.
*/
export const SEATBELT = 'sandbox-exec';
/**
* Set this only after confirming the boundary mounts no host credentials. A
* marker says a container exists; it cannot say the container is clean, and
* the CLI's own Docker/Podman launcher mounts `~/.config/gcloud`, the ADC file
* and every model API key it can see, so the two questions are separate.
*/
export const CREDENTIAL_FREE_ATTESTATION = 'QWEN_VERIFY_CREDENTIAL_FREE';
const CREDENTIAL_ENV = [
'GOOGLE_APPLICATION_CREDENTIALS',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'OPENAI_API_KEY',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'AZURE_CLIENT_SECRET',
'SSH_AUTH_SOCK',
'GH_TOKEN',
'GITHUB_TOKEN',
'KUBECONFIG',
// An arbitrary mount list can carry anything, so it cannot be attested to.
'SANDBOX_MOUNTS',
];
const CREDENTIAL_PATHS = [
['.config/gcloud', '~/.config/gcloud'],
['.aws', '~/.aws'],
['.azure', '~/.azure'],
['.ssh', '~/.ssh'],
['.kube/config', '~/.kube/config'],
['.git-credentials', '~/.git-credentials'],
['.netrc', '~/.netrc'],
['.docker/config.json', '~/.docker/config.json'],
// The persistent form of GH_TOKEN/GITHUB_TOKEN and a registry token.
['.config/gh', '~/.config/gh'],
['.npmrc', '~/.npmrc'],
];
/**
* Known credential signals. An empty list is not proof of a clean boundary —
* it is what the process can see — which is why the gate also needs the
* attestation below rather than treating the absence as a pass.
*/
export function detectCredentialSignals(
env = process.env,
exists = existsSync,
home = homedir(),
) {
const signals = [];
for (const name of CREDENTIAL_ENV) {
if (env[name]) signals.push(name);
}
for (const [relative, label] of CREDENTIAL_PATHS) {
if (exists(join(home, relative))) signals.push(label);
}
return signals;
}
/**
* Reports the isolation signals this process can observe. Only a
* container-backed signal counts: a VM carries none of them, which is why the
* caller decides what a missing signal means, and why the seatbelt flag is
* reported separately rather than folded into `isolated`.
*/
export function detectIsolation(env = process.env, exists = existsSync) {
const signals = [];
if (env.container) signals.push(`container=${env.container}`);
if (exists('/.dockerenv')) signals.push('/.dockerenv');
if (exists('/run/.containerenv')) signals.push('/run/.containerenv');
// A boolean-looking value reads as "sandbox disabled"; the CLI's transport
// writes a container name or `sandbox-exec`, neither of which is a marker.
const sandbox = (env.SANDBOX ?? '').trim();
if (sandbox && !['false', 'true', '0', '1'].includes(sandbox)) {
if (sandbox !== SEATBELT) signals.push(`SANDBOX=${sandbox}`);
}
return {
isolated: signals.length > 0,
signals,
seatbelt: sandbox === SEATBELT,
};
}
/** Returns the normalized snapshot fields, or null when the shape is wrong. */
export function validateContext(json) {
if (!json || typeof json !== 'object') return null;
const pr = json.number;
const base = json.baseRefOid;
const head = json.headRefOid;
if (!Number.isInteger(pr) || pr <= 0) return null;
if (typeof base !== 'string' || !OID.test(base)) return null;
if (typeof head !== 'string' || !OID.test(head)) return null;
return {
pr,
base,
head,
baseRefName: typeof json.baseRefName === 'string' ? json.baseRefName : '',
title: typeof json.title === 'string' ? json.title : '',
};
}
// The CI lane's verify job runs this query and reshapes it with jq into the
// same review-threads.json; a test runs both over the same pages.
export const REVIEW_STATE_QUERY =
'query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { latestOpinionatedReviews(first: 100) { nodes { state submittedAt url body author { login } authorAssociation commit { oid } } } reviewThreads(first: 50, after: $endCursor) { pageInfo { hasNextPage endCursor } nodes { isResolved isOutdated path line originalLine comments(first: 50) { totalCount nodes { createdAt url body author { login } authorAssociation originalCommit { oid } pullRequestReview { url } } } } } } } }';
/**
* Reshapes `gh api graphql --paginate --slurp` pages of REVIEW_STATE_QUERY into
* the review state a round reads: every review thread, resolved ones included
* (resolving is the author's claim, not a measurement), and each reviewer's
* standing CHANGES_REQUESTED review. Null when the pages are not that query's
* answer.
*/
export function reviewState(pages) {
if (!Array.isArray(pages) || pages.length === 0) return null;
const isObject = (value) => value !== null && typeof value === 'object';
const pulls = pages.map((page) => page?.data?.repository?.pullRequest);
if (
pulls.some(
(pull) =>
!Array.isArray(pull?.reviewThreads?.nodes) ||
!Array.isArray(pull?.latestOpinionatedReviews?.nodes) ||
!pull.latestOpinionatedReviews.nodes.every(isObject) ||
!pull.reviewThreads.nodes.every(
(thread) =>
isObject(thread) &&
Array.isArray(thread.comments?.nodes) &&
thread.comments.nodes.every(isObject),
),
)
) {
return null;
}
const who = (node) => ({
author: node.author?.login ?? null,
association: node.authorAssociation ?? null,
});
return {
threads: pulls
.flatMap((pull) => pull.reviewThreads.nodes)
.map((thread) => ({
resolved: thread.isResolved ?? null,
outdated: thread.isOutdated ?? null,
path: thread.path ?? null,
line: thread.line ?? null,
originalLine: thread.originalLine ?? null,
commentCount: thread.comments.totalCount ?? null,
comments: thread.comments.nodes.map((comment) => ({
...who(comment),
createdAt: comment.createdAt ?? null,
url: comment.url ?? null,
review: comment.pullRequestReview?.url ?? null,
commit: comment.originalCommit?.oid ?? null,
body: comment.body ?? null,
})),
})),
changeRequests: pulls[0].latestOpinionatedReviews.nodes
.filter((review) => review.state === 'CHANGES_REQUESTED')
.map((review) => ({
...who(review),
submittedAt: review.submittedAt ?? null,
commit: review.commit?.oid ?? null,
url: review.url ?? null,
body: review.body ?? null,
})),
};
}
/** Drops C0/C1 control characters, which a terminal would interpret. */
export function printable(text) {
return [...String(text ?? '')]
.filter((char) => {
const code = char.codePointAt(0);
return code > 0x1f && (code < 0x7f || code > 0x9f);
})
.join('');
}
/** Parses argv into options, or `{error}` for a usage failure. */
export function parseArgs(argv) {
const options = {
pr: null,
repo: null,
requireIsolation: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--help' || arg === '-h') {
options.help = true;
} else if (arg === '--require-isolation') {
options.requireIsolation = true;
} else if (arg === '--repo') {
options.repo = argv[(index += 1)] ?? null;
if (!options.repo) return { error: '--repo needs <owner>/<repo>' };
} else if (arg.startsWith('--repo=')) {
options.repo = arg.slice('--repo='.length);
} else if (/^\d+$/.test(arg) && options.pr === null) {
options.pr = Number(arg);
} else {
return { error: `unexpected argument: ${arg}` };
}
}
if (options.help || options.requireIsolation) return options;
if (!Number.isInteger(options.pr) || options.pr <= 0) {
return { error: 'a PR number is required' };
}
if (!parseRepo(options.repo)) {
return { error: '--repo <owner>/<repo> is required' };
}
return options;
}
function run(command, args, options = {}) {
return spawnSync(command, args, { encoding: 'utf8', ...options });
}
/** `{state}` on success, `{error}` naming why the review state is unknown. */
function fetchReviewState(owner, name, pr) {
const view = run(
'gh',
[
'api',
'graphql',
'--paginate',
'--slurp',
'-f',
`owner=${owner}`,
'-f',
`name=${name}`,
'-F',
`number=${pr}`,
'-f',
`query=${REVIEW_STATE_QUERY}`,
],
// Every thread's comment bodies, resolved ones included, arrive before
// the filter: one busy PR's pages measured 790 KB, near spawnSync's 1 MB
// default.
{ maxBuffer: 256 * 1024 * 1024 },
);
if (view.status !== 0) {
const detail =
(view.stderr || '').trim().split('\n')[0] ||
view.error?.message ||
`exit ${view.status}`;
return { error: `gh api graphql failed: ${detail}` };
}
let pages;
try {
pages = JSON.parse(view.stdout);
} catch {
return { error: 'gh api graphql returned output that is not JSON' };
}
const state = reviewState(pages);
return state
? { state }
: { error: 'gh api graphql returned pages without review threads' };
}
// The tree under inspection is PR-controlled and can persist hostile git
// config in .git/config: fsmonitor, hooks and clean/smudge/process filters all
// execute on an ordinary `git status`. On the host these run as the maintainer,
// outside the boundary, so every git command disables the fixed vectors and
// empties every locally defined filter before reading the tree. The fixed
// vectors come LAST in every argument list: git applies the last `-c` for a
// key, so an enumerated entry must never be able to outrank them.
export const GIT_HARDENING = [
'-c',
'core.fsmonitor=false',
'-c',
'core.hooksPath=/dev/null',
'-c',
'credential.helper=',
'-c',
'core.excludesFile=/dev/null',
];
const FILTER_NAME = /^filter\..+\.(clean|smudge|process)$/;
/**
* A filter name git allows (e.g. `filter.a=b.clean`) but `-c key=value`
* cannot represent: git splits the argument at the FIRST `=`, so emitting an
* override would set a different key and leave the real filter armed. The
* gate refuses such names instead, with their own remedy.
*/
export class UnsafeFilterNameError extends Error {
constructor(name) {
super(
`filter name ${name} contains '=' and cannot be safely disabled with -c — remove the filter from the tree's git config, or re-clone, then re-run prepare`,
);
this.name = 'UnsafeFilterNameError';
}
}
/**
* Empty `-c` overrides for every locally defined filter command. The filter
* name is attacker-chosen, so the override set has to be enumerated from the
* tree's own config instead of hardcoded. Three properties are load-bearing:
* `--includes` follows `include.path` the way the config parsing `git status`
* uses does; `--null --name-only` frames KEY NAMES only, because a filter
* VALUE may contain newlines that would forge extra entries if the rendered
* `key value` output were parsed; and each name is re-validated before it
* becomes a `-c` argument. When `extensions.worktreeConfig` is set,
* `git status` also reads `.git/config.worktree`, so that scope is enumerated
* too. Names containing `=` throw UnsafeFilterNameError — no override can
* represent them.
*/
export function localFilterOverrides(root) {
const prefix = root ? ['-C', root] : [];
const scopes = ['--local'];
const worktreeConfig = run('git', [
...prefix,
'config',
'--local',
'--includes',
'--bool',
'extensions.worktreeConfig',
]);
if (worktreeConfig.status === 0 && worktreeConfig.stdout.trim() === 'true') {
scopes.push('--worktree');
}
const overrides = [];
for (const scope of scopes) {
const result = run('git', [
...prefix,
'config',
scope,
'--includes',
'--null',
'--name-only',
'--get-regexp',
'^filter\\..*\\.(clean|smudge|process)$',
]);
if (result.status === 1) continue;
if (result.status !== 0) {
throw new Error('could not enumerate local git filters');
}
for (const name of result.stdout.split('\0')) {
if (!name || !FILTER_NAME.test(name)) continue;
if (name.includes('=')) throw new UnsafeFilterNameError(name);
overrides.push('-c', `${name}=`);
}
}
return overrides;
}
export function gitStatusArgs(root) {
return [
...localFilterOverrides(root),
...GIT_HARDENING,
'-C',
root,
'status',
'--porcelain',
'--untracked-files=normal',
];
}
function runGit(args) {
return run('git', [...localFilterOverrides(), ...GIT_HARDENING, ...args]);
}
/**
* True when `.git/info/exclude` carries active patterns. A fresh clone has
* only git's commented default there; patterns hide untracked files from the
* clean-tree check, and unlike core.excludesFile no `-c` can neutralize this
* file, so the gate refuses it instead of reading a steered status.
*/
export function hasHostileInfoExclude(root) {
const path = join(root, '.git', 'info', 'exclude');
if (!existsSync(path)) return false;
return readFileSync(path, 'utf8')
.split('\n')
.some((line) => {
const trimmed = line.trim();
return trimmed !== '' && !trimmed.startsWith('#');
});
}
function report(lines) {
process.stdout.write(`${lines.join('\n')}\n`);
}
function fail(code, message) {
process.stderr.write(`verify-pr prepare: ${message}\n`);
return code;
}
function main(argv) {
const options = parseArgs(argv);
if (options.error) {
process.stderr.write(`verify-pr prepare: ${options.error}\n\n${HELP}`);
return 2;
}
if (options.help) {
process.stdout.write(HELP);
return 0;
}
if (options.requireIsolation) return requireBoundary();
const { owner, name } = parseRepo(options.repo);
const slug = `${owner}/${name}`;
let toplevel;
try {
toplevel = runGit(['rev-parse', '--show-toplevel']);
} catch (error) {
// A hostile filter name is a refusal with its own remedy, not a missing
// worktree; it must surface before any guarded status could run it.
if (error instanceof UnsafeFilterNameError) {
return fail(3, error.message);
}
return fail(3, 'not inside a git worktree — run this from the clone');
}
if (toplevel.status !== 0) {
return fail(3, 'not inside a git worktree — run this from the clone');
}
const root = toplevel.stdout.trim();
const view = run('gh', [
'pr',
'view',
String(options.pr),
'--repo',
slug,
'--json',
FIELDS,
]);
if (view.status !== 0) {
const detail =
(view.stderr || '').trim() ||
view.error?.message ||
`exit ${view.status}`;
return fail(3, `gh pr view failed: ${detail}`);
}
let snapshot;
try {
snapshot = JSON.parse(view.stdout);
} catch {
return fail(3, 'gh pr view returned output that is not JSON');
}
const context = validateContext(snapshot);
if (!context) {
return fail(
3,
'gh returned a snapshot without a numeric PR and two 40-character OIDs',
);
}
const probes = [
['base', context.base, context.baseRefName || context.base],
['head', context.head, `pull/${context.pr}/head`],
];
for (const [label, oid, ref] of probes) {
if (
runGit(['-C', root, 'cat-file', '-e', `${oid}^{commit}`]).status !== 0
) {
return fail(
3,
`${label} ${oid} is not in this clone — run: git fetch https://github.com/${slug}.git ${ref}`,
);
}
}
// The round runs the head side of its A/B in this tree, so a HEAD other
// than the PR head or an uncommitted change would measure code the report
// does not name. Checked before the artifact directory exists, so a refusal
// leaves nothing behind.
const treeHead = runGit(['-C', root, 'rev-parse', 'HEAD']);
if (treeHead.status !== 0) {
return fail(3, 'could not read HEAD in this clone');
}
if (treeHead.stdout.trim() !== context.head) {
return fail(
3,
`current tree is at ${treeHead.stdout.trim()}, not PR head ${context.head} — run: git checkout ${context.head}, then re-run prepare`,
);
}
// The ignore channel is steerable from the same PR-controlled .git:
// core.excludesFile is neutralized by GIT_HARDENING, but .git/info/exclude
// has no `-c` override, so active patterns there are refused outright
// rather than read through.
if (hasHostileInfoExclude(root)) {
return fail(
3,
'.git/info/exclude carries local exclude patterns, which can hide untracked files from the clean-tree check — re-clone (a fresh clone has only the commented default), or clear the file, then re-run prepare',
);
}
// Explicit mode: a maintainer's status.showUntrackedFiles=no would otherwise
// hide the untracked files this check exists to refuse.
const dirty = run('git', gitStatusArgs(root));
if (dirty.status !== 0) {
const detail = (dirty.stderr || '').trim() || `exit ${dirty.status}`;
return fail(3, `could not read the working tree status: ${detail}`);
}
if (dirty.stdout.trim() !== '') {
// Name the entries and a remedy that actually clears them: a plain
// `git stash` leaves untracked files behind, so the advice would loop.
const entries = dirty.stdout.trim().split('\n');
const shown = entries.slice(0, 5).map(printable).join(', ');
return fail(
3,
`working tree has uncommitted changes (${shown}${entries.length > 5 ? ', …' : ''}) — commit them, or run \`git stash push --include-untracked\` (a plain \`git stash\` leaves untracked files behind), or remove them, then re-run prepare; this script never stashes for you`,
);
}
// git status trusts the index: skip-worktree / assume-unchanged entries
// (a sparse checkout sets them en masse) hide tracked divergence from it,
// so refuse anything that is not a plain cached 'H' entry.
const flagged = runGit(['-C', root, 'ls-files', '-v']);
if (flagged.status !== 0) {
const detail = (flagged.stderr || '').trim() || `exit ${flagged.status}`;
return fail(3, `could not read the index flags: ${detail}`);
}
const opaque = flagged.stdout
.split('\n')
.filter((line) => line && !line.startsWith('H '));
if (opaque.length > 0) {
const shown = opaque.slice(0, 5).map(printable).join(', ');
return fail(
3,
`tracked entries hide their worktree state (${shown}${opaque.length > 5 ? ', …' : ''}) — re-clone without a sparse checkout, or clear the index flags, then re-run prepare`,
);
}
const contextDir = join(root, 'tmp', 'verify-context');
const contextPath = join(contextDir, `pr${context.pr}.json`);
const artifacts = join(root, 'tmp', artifactDirName(context.pr));
try {
mkdirSync(contextDir, { recursive: true });
// Recursive, so a retry within the same second lands in the directory this
// script just made instead of failing on it.
mkdirSync(artifacts, { recursive: true });
mkdirSync(join(artifacts, 'evidence'), { recursive: true });
} catch (error) {
return fail(3, `could not create the artifact directory: ${error.message}`);
}
writeFileSync(contextPath, `${JSON.stringify(snapshot, null, 2)}\n`);
// Best-effort, like the lane: the round reads a missing file as "review
// state unknown", so an earlier prepare's copy must not outlive a failed
// fetch.
const reviewPath = join(contextDir, `pr${context.pr}-review-threads.json`);
rmSync(reviewPath, { force: true });
const review = fetchReviewState(owner, name, context.pr);
if (review.state) {
writeFileSync(reviewPath, `${JSON.stringify(review.state, null, 2)}\n`);
}
writeFileSync(
join(artifacts, 'context.json'),
`${JSON.stringify({
repo: slug,
pr: context.pr,
baseRefOid: context.base,
headRefOid: context.head,
})}\n`,
);
const isolation = detectIsolation();
// The title comes from an untrusted fork, and a control character would reach
// the maintainer's terminal from a tool whose job is to protect it.
const title = printable(context.title).replace(/\s+/g, ' ').trim();
report([
'verify-pr prepare',
` repo ${slug}`,
` pr #${context.pr}${title ? ` ${title}` : ''}`,
` base ${context.base}`,
` head ${context.head}`,
' tree at PR head, clean — reuse this tree; verify or refresh its install/build first',
` context ${contextPath}`,
review.state
? ` review ${reviewPath} (${review.state.threads.filter((thread) => !thread.resolved).length} of ${review.state.threads.length} threads unresolved, ${review.state.changeRequests.length} change requests)`
: ` review not written — ${printable(review.error)}; the round lists review findings as not covered`,
` artifacts ${artifacts}`,
` isolation ${isolation.isolated ? isolation.signals.join(', ') : 'none detected'}`,
]);
if (isolation.seatbelt) {
report([
' seatbelt SANDBOX=sandbox-exec (default profile allows host reads —',
' not a boundary)',
]);
}
report([
'',
`Next: build the round in ${artifacts}. This preflight ran on the host,`,
'because resolving the coordinates needs `gh` and `gh` is mutually',
'exclusive with the boundary. Before executing PR code, run inside it:',
'',
' node scripts/verify-pr-prepare.mjs --require-isolation',
]);
return 0;
}
/**
* The in-boundary check, and the only place its answer means anything: the
* coordinate path necessarily runs on the host, so a gate there would fire
* every round and teach the maintainer to ignore it.
*/
function requireBoundary() {
const isolation = detectIsolation();
const credentials = detectCredentialSignals();
const attested = process.env[CREDENTIAL_FREE_ATTESTATION] === '1';
if (!isolation.isolated) {
const detail = isolation.seatbelt
? 'SANDBOX=sandbox-exec is the built-in macOS sandbox, whose default profile still reads host files'
: 'no container marker was found';
process.stderr.write(
`verify-pr boundary: ${detail} — do not execute PR code in this process\n`,
);
return 4;
}
const refusals = [];
if (!attested) {
refusals.push(
`${CREDENTIAL_FREE_ATTESTATION}=1 is not set: a marker says a container exists, not that it carries no host credentials`,
);
}
if (credentials.length > 0) {
refusals.push(
`credential signals contradict a credential-free boundary: ${credentials.join(', ')}`,
);
}
if (refusals.length > 0) {
process.stderr.write(
`verify-pr boundary: ${isolation.signals.join(', ')} but ${refusals.join('; ')} — do not execute PR code here\n`,
);
return 4;
}
report([
`verify-pr boundary: ${isolation.signals.join(', ')}; credential-free per attestation, no known credential signal`,
]);
return 0;
}
if (isEntryPoint(import.meta.url, process.argv[1])) {
process.exitCode = main(process.argv.slice(2));
}