mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(verify-pr): add local Docker recipe with a host-side model proxy (#16077)
* feat(verify-pr): add local Docker recipe with a host-side model proxy A local /verify-pr round needs the same isolation the CI lane provides, but the skill only documented the boundary check, not how to run the model credential through it. Add scripts/verify-pr-model-proxy.mjs, a host-side relay that holds the real key and lets the container call the model with a per-run bearer token, mirroring the CI lane's inline proxy (POST /chat/completions only, nonce health check, per-chunk idle watchdog). The listen address is a flag so the proxy can bind the Docker bridge, and readiness is printed as one JSON line. The SKILL recipe pairs it with a docker run template: the token travels as QWEN_VERIFY_PROXY_TOKEN (not OPENAI_API_KEY, which the isolation gate refuses), and the container's provider entry reads it via envKey. The CI lane keeps its inline copy — the proxy holds the real key, so it must not execute a script from the PR-head checkout. Verified: 11 new pin tests plus all existing verify-pr suites (120 passed), and a real docker smoke — the gate passes inside node:22-bookworm with the proxy token, refuses OPENAI_API_KEY with exit 4, and a container POST reaches a mock upstream with the real key injected and the /v1 prefix preserved. * fix(verify-pr): harden the model proxy after adversarial audit Audit round 1 found the relay forwarded client hop-by-hop headers (connection, transfer-encoding, expect, proxy-authorization, ...) to the upstream, which RFC 7230 forbids and which fights undici's own framing for the relayed stream. Strip them, with a chunked-body test that pins the behaviour. Round 1 also found the CLI entry had no coverage: add tests for the missing-config exit code and the printed JSON coordinates (token override, base-path prefix, no real-key leak, health nonce), and document that the printed baseUrl uses the listen address, so binding 0.0.0.0 yields a URL clients cannot dial. Rounds 2 and 3 (open-ended re-read plus adversarial passes over the updated diff) found nothing further. All verify-pr suites: 123 passed. * fix(verify-pr): address review findings in the model proxy and checker Round-1 review (12 inline findings at70aed891bf). Proxy: abort the upstream when the client disconnects before headers instead of paying for a completion nobody reads (R1-1); make the idle watchdog pause-aware, re-arm on drain, and match the client's own 240 s stream tolerance (R1-3); bracket IPv6 listen hosts via an exported formatter and normalize base paths once (R1-12). Checker: add --context so a host-held snapshot pins the expected head instead of the writable artifact copy, and refuse an unusable snapshot rather than silently falling back (R1-9). Each guard is pinned by a test that was verified to go red when the guard is removed (six mutations: pre-header abort, pause guard, per-chunk re-arm, body error handler, IPv6 bracket, --context). * docs(verify-pr): harden the local container recipe and record the design Recipe: run the key-holding proxy and the checker from a trusted checkout, never the PR tree (R1-6, R1-9); background the proxy with a pid file and stop it at the end (R1-4); read the key instead of inlining it (R1-5); use a named detached container driven by docker exec (R1-7); require a standalone clone, the host uid and a fresh HOME (R1-8); mount the full host-held metadata snapshot rather than the four-key artifact copy (R1-10); and attest only inside, after rejecting credential-shaped ignored files (R1-11). Add the bilingual design record (R1-2), cross-link it from the local-round design, and pin the recipe's trust lines in tests so reverting them turns the suite red. * fix(verify-pr): address round-2 verification findings Round 2 at3509c2d1aareported one Low and four Suggestions. - Relay the exact <basePath>/chat/completions path. A suffix match also forwarded /admin/chat/completions to the upstream. - Pin the response content-encoding strip with a gzip upstream: undici hands the proxy decoded bytes, so forwarding the header makes the client gunzip plain text. - Reject credential-shaped ignored entries at any depth (a nested secrets/.env included) instead of only a root .env/.env.*. - Name the round's engine as the released CLI and offer the tree build for a round that must exercise the PR's own CLI. The CI inline copies keep their existing two-header strip: that lane is untouched by design and a change there needs its own workflow review. Tests: 141 passed. Three mutations verified red (exact path, gzip header, nested credential pathspec). The review-pr check at this PR is red because the bot lane exhausted ten retries on "429 rpm exceeded", not because of this diff. * fix(verify-pr): close round-3 trust-boundary findings Round 3 at3ccf38697dreported two Criticals against the recipe, one fix-induced Critical, and four Suggestions. - prepare, the credential scan, the isolation gate and the checker now all come from <trusted-clone>; prepare runs with the PR clone as its cwd so the HEAD check still applies (R2-1). - The base commit's .qwen is extracted and mounted read-only over the tree, so the round's instructions and project settings cannot come from the PR (R2-2). - The attestation is passed to the shell that starts the round, because every docker exec is a fresh shell (R1-11, fix-induced). - The proxy token is exported from proxy.json and passed with a bare -e; the file is chmod 600 and removed at cleanup (R2-3). - Connection-nominated hop-by-hop headers are stripped (R2-6). - Recipe pins now enumerate every checker call site, require a non-empty docker-run slice, and seed the writable checker snapshot so a fallback regression is caught (R2-4, R2-5). - New verify-pr-credential-scan.mjs lists ignored files one by one, refuses credential-shaped names at any depth, and leaves node_modules and dist alone — replacing the pathspec check that refused every installed or built tree (round-3 Low). Tests: 148 passed; ten mutations verified red. * fix(verify-pr): address round-4 review findings Four P2 findings at441a523927: - The credential scan now limits git enumeration with credential pathspecs and depth-aware node_modules/dist excludes, so an installed tree's multi-megabyte ignored listing never overflows the output buffer. - Those excludes apply inside workspace packages too, so packages/*/dist and packages/*/node_modules are not mistaken for project credentials. - The recipe waits for proxy.json to carry a token and validates the exported value, so a truncated background write cannot reach Docker. - The pre-header disconnect test waits until the mock upstream has recorded the request before destroying the downstream socket, instead of racing the proxy's own (correct) early abort. Tests: 150 passed; three mutations verified red (enumeration pathspecs, excludes, readiness loop). * fix(verify-pr): refuse nested repositories in the credential scan Round-4 verification at19b14b1a8fleft three Suggestions. - A nested git repository is now refused instead of silently skipped: git does not descend into it, so a .env under tmp/pr-clone/ would pass. A vendored .git under node_modules/dist stays excluded like the files there. - isExcluded is removed: the pathspecs already exclude those directories, and the surviving JS filter was unpinnable. - The spaced Connection-nomination list is pinned; dropping trim made the proxy answer 502. - The node_modules/dist credential exclusions are documented as deliberate. Tests: 152 passed; two mutations verified red (nested refusal, trim). * fix(verify-pr): address round-5 review findings Four Criticals: - The key entry works without a TTY: an interactive shell prompts, an agent's shell reads REVIEW_OPENAI_KEY_FILE and fails with a named error. - prepare disables fsmonitor, hooks and credential helpers on the PR-controlled clone, and the scan refuses credential-bearing remote URLs. - The proxy token is re-derived from proxy.json in the docker run step, because each step is its own shell. - The credential scan covers .env~, *.p12, id_rsa*, gha-creds-*, and the other credential families the repo's own .gitignore names. Suggestions: - Guard call site moved to <trusted-clone>, <clone> defined for watch mode. - The newest verify comment is snapshotted as previous-report.md. - The real key is unset once the proxy owns it; the token is unset at cleanup. - `{1..100}` replaces seq for macOS; --user and the proxy token's non-credential status are pinned; the scan abort is structural (`|| exit 1`). - Step 6 wording matches the base-pin; the docs narrow the base-pin scope for root AGENTS.md/QWEN.md, list the real CI divergence, explain content-encoding in English, fix the zh token wording, and record the checker --context contract and the ownership attribution. - The readiness gate compares the health nonce; --port is removed. Test gaps closed: gzip header assertion, mid-body disconnect, reset event, PUT, bare-base exact path, positive header passthrough, 502 transport failure, full paused-stream equality, token shape, publish --context pin, checker second positional, scan path fixtures, headers-only idle window. Tests: 159 passed; twelve mutations verified red. * fix(verify-pr): close round-5 verification findings Two Lows reproduced atb5c717a5d5: - prepare's hardening named clean filters but only disabled fsmonitor, hooks and credential helpers. It now enumerates every local filter.*.clean/smudge/process and empties it with a -c override before any git read; a planted-filter fixture proves the override with a positive control that fires without it. - The nested-repository refusal over-refused installed trees whose node_modules holds a real vendored git repo (git reports the directory entry, which the exclude pathspec cannot match). The enumeration is now segment-filtered in JS, so vendored dependencies stay excluded while a nested project repository is still refused. Suggestions and nits: - The remote-URL guard reads url.*.insteadOf as well as remote.*.url and refuses any http(s) userinfo, including a token used as the username; ssh://git@ stays benign. - Public keys (.pub) are no longer treated as credentials, and a backup.env~ fixture pins the trailing-tilde family. Tests: 163 passed; six mutations verified red (filter overrides, vendored segment filter, userinfo breadth, insteadOf enumeration, public-key exclusion, backup.env~ pathspec). * fix(verify-pr): close round-6 verification findings Co-authored-by: Qwen-Coder <[email protected]> * fix(verify-pr): close round-7 verification findings Co-authored-by: Qwen-Coder <[email protected]> * fix(verify-pr): address round-8 review findings Co-authored-by: Qwen-Coder <[email protected]> * fix(verify-pr): close round-9 review findings Co-authored-by: Qwen-Coder <[email protected]> * fix(verify-pr): close round-10 review findings Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
02b8b0108c
commit
68c9fa27fb
13 files changed
+3432
-48
No files matched your search
+285
-16
@@ -20,8 +20,10 @@ Two entry paths share the contract below.
|
||||
supplies `$QWEN_VERIFY_CONTEXT`, an already-built tree and the time budget,
|
||||
and publishes the report. There is nothing to prepare.
|
||||
- **Maintainer round on a workstation** — `/verify-pr <n>`, starting with
|
||||
`node scripts/verify-pr-prepare.mjs <n> --repo <owner>/<repo>` (the
|
||||
preflight requires the repository explicitly; `origin` is not a fallback).
|
||||
`cd <pr-clone> && node <trusted-clone>/scripts/verify-pr-prepare.mjs <n> --repo <owner>/<repo>`
|
||||
(the trusted copy, with the PR clone only as its
|
||||
cwd; see the container recipe below). The preflight requires the repository
|
||||
explicitly; `origin` is not a fallback.
|
||||
It writes the round's coordinates (`repo`, `pr`, `baseRefOid`, `headRefOid`)
|
||||
to `context.json` and refuses a tree that is not a clean checkout of the PR
|
||||
head — when it passes, the current tree is the head side of the A/B, so stay
|
||||
@@ -35,8 +37,9 @@ Two entry paths share the contract below.
|
||||
`/verify-pr <n>` proves the change and posts nothing. `--publish` adds the
|
||||
report comment and its image evidence; `--watch` installs a standing trigger.
|
||||
Before publishing a local round, run
|
||||
`node scripts/verify-pr-check.mjs <artifact dir>`; it applies the consistency
|
||||
rules the publisher enforces in CI.
|
||||
`node <trusted-clone>/scripts/verify-pr-check.mjs --context <host snapshot> <artifact dir>`
|
||||
from a checkout the PR cannot write; it applies the
|
||||
consistency rules the publisher enforces in CI.
|
||||
|
||||
## Environment contract (CI verify job)
|
||||
|
||||
@@ -145,8 +148,8 @@ profiles, or `gh` token. Do not run it in an ordinary working copy on a
|
||||
maintainer's machine; if that isolation is unavailable, ask the maintainer to
|
||||
trigger the sandboxed `@qwen-code /verify` lane instead.
|
||||
|
||||
Run `node scripts/verify-pr-prepare.mjs <n> --repo <owner>/<repo>` on the host
|
||||
before anything else: it fetches the metadata snapshot, resolves and verifies
|
||||
Run `cd <pr-clone> && node <trusted-clone>/scripts/verify-pr-prepare.mjs <n> --repo <owner>/<repo>`
|
||||
on the host before anything else: it fetches the metadata snapshot, resolves and verifies
|
||||
both OIDs against this clone, refuses a tree that is not a clean, fully
|
||||
materialised checkout of the PR head (a wrong HEAD names the `git checkout`
|
||||
to run; uncommitted changes name the entries and a stash form that clears
|
||||
@@ -157,9 +160,11 @@ creates `tmp/pr<n>-verify-<ts>/` with a `context.json` holding `repo`, `pr`,
|
||||
`baseRefOid` and `headRefOid`, and reports the isolation signals it can see.
|
||||
It does not gate on them — resolving metadata
|
||||
needs `gh`, which the boundary excludes, so this path always runs on the host.
|
||||
The boundary check belongs inside: run
|
||||
`node scripts/verify-pr-prepare.mjs --require-isolation` as the round's first
|
||||
command there, and treat exit 4 as "this is not a boundary". A container
|
||||
The boundary check belongs inside: run the trusted copy of the preflight with
|
||||
`--require-isolation` as the round's first command there, and treat exit 4 as
|
||||
"this is not a boundary". The Docker recipe below mounts that copy at
|
||||
`/trusted-scripts/verify-pr-prepare.mjs`; a VM boundary runs the same file
|
||||
from the trusted checkout instead. A container
|
||||
marker alone does not pass: the CLI's own Docker/Podman launcher mounts
|
||||
`~/.config/gcloud`, the ADC file and every model API key it can see, so the
|
||||
check also requires `QWEN_VERIFY_CREDENTIAL_FREE=1` — set it only after
|
||||
@@ -205,6 +210,258 @@ intended `owner/repo` is a match to confirm, not a guess to avoid.
|
||||
`--watch` installs a standing trigger instead of running once; the contract is
|
||||
in **Watch mode** below.
|
||||
|
||||
## Local container recipe (Docker + model proxy)
|
||||
|
||||
The boundary and the model credential are separate problems: the container
|
||||
isolates PR code, and a host-side proxy keeps the real model key out of the
|
||||
container. `scripts/verify-pr-model-proxy.mjs` is that proxy — the same relay
|
||||
rules as the CI lane's inline one (only `POST /chat/completions`, a per-run
|
||||
bearer token, a per-chunk idle watchdog), plus hop-by-hop request header
|
||||
stripping. The CI lane deliberately keeps its own inline copy.
|
||||
|
||||
Two checkouts are involved, and they are not interchangeable:
|
||||
|
||||
- `<trusted-clone>` — a checkout of `main` (or another trusted revision) that
|
||||
the PR cannot write. Every script the round runs on the host or inside the
|
||||
boundary comes from here.
|
||||
- `<pr-clone>` — a **standalone** clone checked out at the PR head. The round
|
||||
runs here, so its `.git` lives inside the tree; a linked worktree's gitfile
|
||||
points at an absolute host path the container cannot see. Prepare is given
|
||||
this tree as its cwd and refuses any other HEAD.
|
||||
|
||||
Never execute a script from `<pr-clone>`: the proxy, the prepare preflight,
|
||||
the credential scan, the isolation gate, the publish guard and the checker all
|
||||
come from `<trusted-clone>`. A PR can rewrite a script in its own tree, and
|
||||
these steps run with the real key, the maintainer's `gh` access, or the
|
||||
authority to certify the round.
|
||||
|
||||
1. **Prepare from the trusted copy with the PR clone as its cwd**, then copy
|
||||
the full snapshot out of the tree and base-pin `.qwen`:
|
||||
|
||||
```bash
|
||||
cd <pr-clone> && node <trusted-clone>/scripts/verify-pr-prepare.mjs <n> --repo <owner>/<repo>
|
||||
rm -rf ~/.cache/qwen-verify/pr<n>/qwen-base
|
||||
mkdir -p ~/.cache/qwen-verify/pr<n>/qwen-base
|
||||
cp <pr-clone>/tmp/verify-context/pr<n>.json ~/.cache/qwen-verify/pr<n>/pr.json
|
||||
ME="$(gh api user --jq .login)" || { echo "could not read the gh user" >&2; exit 1; }
|
||||
PREV="$(gh api "repos/<owner>/<repo>/issues/<n>/comments" --paginate --slurp \
|
||||
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const all=JSON.parse(d).flat();const hit=all.filter(c=>(/bot$/.test(c.user.login)||c.user.login===process.argv[1])&&c.body.split("\n\n")[0].includes("Verdict:")).pop();process.stdout.write(hit?hit.body+"\n":"")})' "$ME")" ||
|
||||
{ echo "could not read PR comments" >&2; exit 1; }
|
||||
if [ -n "$PREV" ]; then printf '%s\n' "$PREV" > ~/.cache/qwen-verify/pr<n>/previous-report.md
|
||||
else rm -f ~/.cache/qwen-verify/pr<n>/previous-report.md; fi
|
||||
BASE_OID="$(node -e 'const fs=require("node:fs"); console.log(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).baseRefOid)' ~/.cache/qwen-verify/pr<n>/pr.json)"
|
||||
git -C <pr-clone> archive "$BASE_OID" -- .qwen \
|
||||
| tar -x -C ~/.cache/qwen-verify/pr<n>/qwen-base
|
||||
```
|
||||
|
||||
prepare resolves the tree from its cwd, so the trusted copy runs with
|
||||
`cwd=<pr-clone>` and still verifies that this tree is the PR head. The
|
||||
artifact directory's `context.json` carries only the four coordinates; the
|
||||
round reads the full gh snapshot (title, body, author, commits) from
|
||||
`$QWEN_VERIFY_CONTEXT`, and the newest substantive verify comment lands
|
||||
beside it as `previous-report.md` so a follow-up round keeps its
|
||||
carried-forward findings. That file exists only when a report was actually
|
||||
found — its presence is the follow-up trigger, so an empty file would
|
||||
misclassify every first round — and the selector matches the first
|
||||
paragraph of a bot's or your own report, not a reply quoting `Verdict:`.
|
||||
The `.qwen` extraction starts from an empty directory, so a file the base
|
||||
deleted or renamed cannot survive from an earlier round. The host copy is
|
||||
also what pins the expected head after the round, because PR code can
|
||||
rewrite anything inside `<pr-clone>` — including `.git/config`. After a
|
||||
round the clone is PR-controlled: re-clone before another round instead of
|
||||
trusting it again, and note that prepare's host-side git commands disable
|
||||
`core.fsmonitor`, hooks, credential helpers and `core.excludesFile`, and
|
||||
empty every locally defined clean/smudge/process filter — the enumeration
|
||||
follows `include.path`, reads key names NUL-framed and re-validated (a
|
||||
filter value with a newline must not forge overrides), covers
|
||||
`.git/config.worktree` when `extensions.worktreeConfig` is set, and the
|
||||
fixed hardening is applied last so nothing outranks it. A filter name
|
||||
containing `=` is refused outright: git splits `-c` at the first `=`, so
|
||||
such a name cannot be safely disabled by an override. Prepare also
|
||||
refuses a `.git/info/exclude` with active patterns: no `-c` can neutralize
|
||||
that file, and a fresh clone has only the commented default.
|
||||
Mounting the base commit's `.qwen` over the tree (step 3) keeps the
|
||||
round's instructions and project settings out of PR control, as the CI
|
||||
lane's base-pin does; the root `AGENTS.md`/`QWEN.md` are not covered and
|
||||
still come from the PR clone.
|
||||
|
||||
2. **Start the proxy from the trusted checkout, in the background**, with the
|
||||
key read rather than typed onto the command line (a literal assignment
|
||||
lands in shell history). An interactive shell may prompt; an agent's shell
|
||||
has no TTY, so the key comes from `REVIEW_OPENAI_KEY_FILE` (default
|
||||
`~/.config/qwen-verify/review-openai-key`, mode 600):
|
||||
|
||||
```bash
|
||||
if [ -t 0 ]; then
|
||||
read -rsp 'REVIEW_OPENAI_API_KEY: ' REVIEW_OPENAI_API_KEY; echo
|
||||
else
|
||||
# `read` returns 1 at EOF even when it assigned the variable, and key
|
||||
# files written by `security find-generic-password -w` / `op read` /
|
||||
# `printf '%s'` carry no trailing newline — judge the variable, not the
|
||||
# exit status.
|
||||
IFS= read -r REVIEW_OPENAI_API_KEY \
|
||||
< "${REVIEW_OPENAI_KEY_FILE:-$HOME/.config/qwen-verify/review-openai-key}" || true
|
||||
[ -n "$REVIEW_OPENAI_API_KEY" ] ||
|
||||
{ echo "REVIEW_OPENAI_API_KEY is unset and no key file was readable; set the file named by REVIEW_OPENAI_KEY_FILE" >&2; exit 1; }
|
||||
fi
|
||||
export REVIEW_OPENAI_API_KEY
|
||||
REVIEW_OPENAI_BASE_URL=<upstream-url> \
|
||||
node <trusted-clone>/scripts/verify-pr-model-proxy.mjs \
|
||||
--listen <bridge-ip> > ~/.cache/qwen-verify/pr<n>/proxy.json 2> ~/.cache/qwen-verify/pr<n>/proxy.err &
|
||||
echo $! > ~/.cache/qwen-verify/pr<n>/proxy.pid
|
||||
unset REVIEW_OPENAI_API_KEY
|
||||
for _ in {1..100}; do
|
||||
node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); if (!j.token) process.exit(1)' ~/.cache/qwen-verify/pr<n>/proxy.json 2>/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
chmod 600 ~/.cache/qwen-verify/pr<n>/proxy.json
|
||||
node -e 'const fs=require("node:fs"); const j=JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const u=new URL(j.baseUrl); fetch(`http://${u.hostname}:${u.port}/__health`).then(r=>r.text()).then(t=>process.exit(t===j.nonce?0:1)).catch(()=>process.exit(1))' ~/.cache/qwen-verify/pr<n>/proxy.json ||
|
||||
{ echo "proxy health check failed"; exit 1; }
|
||||
```
|
||||
|
||||
Bind the Docker bridge address, not `127.0.0.1` — a container cannot reach
|
||||
the host's loopback. On Docker Desktop (macOS/Windows) the bridge lives
|
||||
inside the VM and has no host-bindable address: this recipe is tested on a
|
||||
Linux host, and on Desktop you must bind an address the VM can reach and
|
||||
dial that same address from the container. The bearer check stays
|
||||
mandatory: the port is visible to every container on the bridge. Keep the
|
||||
proxy running for the whole round and stop it at the end — it holds the
|
||||
real key. The background process truncates `proxy.json` before it prints,
|
||||
so the loop waits for a complete line with a token; stderr goes to a
|
||||
separate `proxy.err`, because one diagnostic line folded into the JSON
|
||||
file would break every parse of it. The nonce is checked at the origin
|
||||
because the printed `baseUrl` carries the upstream path prefix. The key is
|
||||
unset as soon as the proxy owns its copy, so later diagnostics cannot leak
|
||||
it, and the token stays in the file rather than this shell.
|
||||
|
||||
3. **Start the boundary as a named, detached container** so every later step
|
||||
can be issued without a TTY. Each step is its own shell: the token is
|
||||
re-derived from `proxy.json` here, not inherited from step 2.
|
||||
|
||||
```bash
|
||||
export QWEN_VERIFY_PROXY_TOKEN="$(node -e 'const fs=require("node:fs"); console.log(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).token)' ~/.cache/qwen-verify/pr<n>/proxy.json)"
|
||||
test -n "$QWEN_VERIFY_PROXY_TOKEN" || { echo "proxy.json has no token" >&2; exit 1; }
|
||||
docker run -d --rm --name qwen-verify-<n> \
|
||||
--user "$(id -u):$(id -g)" -e HOME=/tmp/qwen-home \
|
||||
-v <pr-clone>:/work \
|
||||
-v <trusted-clone>/scripts:/trusted-scripts:ro \
|
||||
-v ~/.cache/qwen-verify/pr<n>/qwen-base/.qwen:/work/.qwen:ro \
|
||||
-v ~/.cache/qwen-verify/pr<n>:/verify-context:ro \
|
||||
-e QWEN_VERIFY_CONTEXT=/verify-context/pr.json \
|
||||
-e QWEN_VERIFY_PROXY_TOKEN \
|
||||
-w /work node:22-bookworm sleep infinity
|
||||
```
|
||||
|
||||
No host `HOME`, no `gh` token, no `SANDBOX_MOUNTS`. The token travels as
|
||||
`QWEN_VERIFY_PROXY_TOKEN`, **not** `OPENAI_API_KEY`: `--require-isolation`
|
||||
refuses any non-empty `OPENAI_API_KEY` (it cannot tell a proxy token from
|
||||
a real key), and this name is not a credential signal. Running as the host
|
||||
uid keeps files written into the mounted tree — including the round's
|
||||
artifacts — owned by the maintainer, not root.
|
||||
|
||||
4. **Point the provider at the proxy** in the container's fresh
|
||||
`$HOME/.qwen/settings.json`; the runtime reads the credential from
|
||||
`envKey`, so no `OPENAI_API_KEY` is needed:
|
||||
|
||||
```bash
|
||||
docker exec -i qwen-verify-<n> bash -lc 'mkdir -p "$HOME/.qwen" && cat > "$HOME/.qwen/settings.json"' <<'JSON'
|
||||
{
|
||||
"security": { "auth": { "selectedType": "openai" } },
|
||||
"model": { "name": "<model-id>" },
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "<model-id>",
|
||||
"envKey": "QWEN_VERIFY_PROXY_TOKEN",
|
||||
"baseUrl": "<baseUrl from proxy.json>"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
5. **Attest inside, after trusted checks — never on the `docker run` line.**
|
||||
The gate only sees known credential names and home paths, and gitignored
|
||||
files are invisible to its clean-tree check, so scan for project
|
||||
credential files and run the gate from the trusted copies first:
|
||||
|
||||
```bash
|
||||
docker exec -i qwen-verify-<n> bash -lc '
|
||||
node /trusted-scripts/verify-pr-credential-scan.mjs /work || exit 1
|
||||
export QWEN_VERIFY_CREDENTIAL_FREE=1
|
||||
node /trusted-scripts/verify-pr-prepare.mjs --require-isolation
|
||||
'
|
||||
```
|
||||
|
||||
The scan names credential-shaped ignored files (nested ones included, but
|
||||
not public keys; the private-key families cover RSA, Ed25519, ECDSA and
|
||||
DSA) and credential-bearing git config. It inspects every local config
|
||||
value rather than a list of keys — any value can hide a token URL,
|
||||
`http.proxy` included — and refuses http(s) userinfo (a token used as the
|
||||
username counts; the bare `git@` account name, in any spelling, carries
|
||||
no secret and stays benign, like `ssh://git@`) plus `Authorization` /
|
||||
`Proxy-Authorization` header values. The URL-bearing key families are
|
||||
judged too: `url.*.insteadOf` / `url.*.pushInsteadOf` rewrites and
|
||||
`http.<url>.*` sections. The config enumeration follows `include.path`
|
||||
and covers the enabled `.git/config.worktree` scope, so a credential in
|
||||
an included worktree file or the worktree config is refused too. The
|
||||
file is optional: absent means an empty scope, while an unreadable or
|
||||
malformed one still refuses. It leaves installed dependencies and build
|
||||
output alone — a vendored git dependency under `node_modules`/`dist` is
|
||||
excluded, not refused — and refuses a nested git repository outright on
|
||||
every surface (ignored clone, untracked clone, populated submodule): git
|
||||
does not descend into it, so a credential file inside one would
|
||||
otherwise pass silently. The abort is structural (`|| exit 1`), not a
|
||||
side effect of `set -e`. Exit 4 means this is not a boundary: stop and
|
||||
run nothing else.
|
||||
|
||||
6. **Run the round inside**, installing the CLI into the fresh home because
|
||||
the container is not root. Each `docker exec` is a new shell, so the
|
||||
attestation is passed to the shell that starts the round; it stays off the
|
||||
`docker run` line. The round's engine is the released CLI, and the PR
|
||||
clone supplies the code under test; the round's instructions come from the
|
||||
base commit's `.qwen` mounted in step 3, not from the PR. Root
|
||||
`AGENTS.md`/`QWEN.md` do come from the PR clone — when the diff touches
|
||||
them, treat the round's context as PR-influenced:
|
||||
|
||||
```bash
|
||||
docker exec -i -e QWEN_VERIFY_CREDENTIAL_FREE=1 qwen-verify-<n> bash -lc '
|
||||
set -e
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
npm install -g --prefix "$HOME/.local" @qwen-code/qwen-code
|
||||
qwen --auth-type openai --approval-mode yolo \
|
||||
--prompt "/verify-pr <n> --repo <owner>/<repo>"
|
||||
'
|
||||
```
|
||||
|
||||
To run the tree's own CLI instead, replace the install line with a build
|
||||
and a local install after resolving the dependencies:
|
||||
|
||||
```bash
|
||||
corepack pnpm install --frozen-lockfile
|
||||
npm run build && npm run bundle
|
||||
npm install -g --prefix "$HOME/.local" /work
|
||||
```
|
||||
|
||||
7. **After the round, check and clean up from the host.** The checker and its
|
||||
expected head both come from outside the round tree:
|
||||
|
||||
```bash
|
||||
node <trusted-clone>/scripts/verify-pr-check.mjs \
|
||||
--context ~/.cache/qwen-verify/pr<n>/pr.json <round-dir>
|
||||
docker rm -f qwen-verify-<n>
|
||||
kill "$(cat ~/.cache/qwen-verify/pr<n>/proxy.pid)"
|
||||
rm -f ~/.cache/qwen-verify/pr<n>/proxy.json ~/.cache/qwen-verify/pr<n>/proxy.err ~/.cache/qwen-verify/pr<n>/proxy.pid
|
||||
unset QWEN_VERIFY_PROXY_TOKEN
|
||||
```
|
||||
|
||||
`<round-dir>` is the artifact directory prepare printed —
|
||||
`<pr-clone>/tmp/pr<n>-verify-<ts>`, never `tmp/verify-context`, which also
|
||||
matches a loose `*verify*` glob. `<host snapshot>` throughout this recipe
|
||||
is `~/.cache/qwen-verify/pr<n>/pr.json`, the host copy step 1 made.
|
||||
Publishing still needs `gh`, so it stays on the host.
|
||||
|
||||
## Watch mode (local only)
|
||||
|
||||
`/verify-pr <n> --watch` installs a standing trigger instead of running once: a
|
||||
@@ -220,6 +477,9 @@ directly: the probe also reports comments and failed checks, and every
|
||||
non-empty gate output starts a turn whether or not the head moved. The wrapper
|
||||
forwards only `action: "resync"` (a moved head) and `action: "stop"`.
|
||||
|
||||
`<clone>` below is the trusted checkout the watch scripts run from; its main
|
||||
worktree holds the probe cursor under `.qwen/tmp/`.
|
||||
|
||||
Build the task in this order and stop at the first failure:
|
||||
|
||||
1. **Resolve a binary that carries the probe.** The gate runs in a fresh shell,
|
||||
@@ -423,17 +683,25 @@ _— <model> via Qwen Code /verify-pr (v<cli-version>; skill <skill-hash>)_
|
||||
Resolve the three values from the round's own runtime, never from memory:
|
||||
the model is `$QWEN_CODE_MODEL_IDENTITY` when non-empty, else
|
||||
`$QWEN_CODE_MODEL`; the CLI version is `qwen --version`; the skill hash is
|
||||
the first 12 characters of
|
||||
`git hash-object --no-filters .qwen/skills/verify-pr/SKILL.md` — the content
|
||||
hash of the instructions this round actually loaded, which needs no git
|
||||
history. When the model cannot be resolved, omit the footer rather than
|
||||
the first 12 characters of the blob hash of the instructions this round
|
||||
actually loaded. In the container recipe that is the base-pinned copy, so
|
||||
hash `git -C <pr-clone> rev-parse "<baseRefOid>:.qwen/skills/verify-pr/SKILL.md"`
|
||||
(equivalently, `git hash-object --no-filters` of the mounted
|
||||
`/work/.qwen/skills/verify-pr/SKILL.md`) — never the PR head's copy: the two
|
||||
blobs differ whenever the PR touches the skill, and hashing the head would
|
||||
certify instructions the round never ran. A non-container round that loads
|
||||
its own checkout hashes that file with
|
||||
`git hash-object --no-filters .qwen/skills/verify-pr/SKILL.md`, which needs
|
||||
no git history. When the model cannot be resolved, omit the footer rather than
|
||||
post an empty identity — and never claim the CI lane's `sandboxed
|
||||
verification` signature for a workstation round. Assemble the exact comment
|
||||
body — report content plus the resolved footer — into `comment.md` in the
|
||||
artifact directory and post that file: it is what the read-back below
|
||||
compares against, and it gives the checker a place to verify the footer
|
||||
actually landed. The local path has no publisher enforcing the fail/mismatch
|
||||
rule, so run `node scripts/verify-pr-check.mjs <artifact dir>` and clear its
|
||||
rule, so run
|
||||
`node <trusted-clone>/scripts/verify-pr-check.mjs --context <host snapshot> <artifact dir>`
|
||||
and clear its
|
||||
errors and its verdict-shape and footer warnings before publishing (a
|
||||
missing footer is legitimate only when the model could not be resolved),
|
||||
act on a gates-size warning by recording the suites it ran under `gates`
|
||||
@@ -467,8 +735,9 @@ GitHub rewrites the six-character escape text for NUL and SOH (a backslash,
|
||||
`u`, then `0000` or `0001`) into a caret form, inside code spans and fences
|
||||
too.
|
||||
|
||||
**Run both halves of that guard through `scripts/verify-pr-guard.mjs`, because
|
||||
the hand-run version fails open.** `--pr <n> --repo <owner>/<repo> --snapshot
|
||||
**Run both halves of that guard through
|
||||
`<trusted-clone>/scripts/verify-pr-guard.mjs`, because the hand-run version
|
||||
fails open.** `--pr <n> --repo <owner>/<repo> --snapshot
|
||||
<file>` at round start and again before posting, then `--compare --start <a>
|
||||
--now <b>`; it exits 1 on anything dirty. A fetch that errors returns no items,
|
||||
so "is anything new?" answers "no" and a hand-rolled comparison reads clean —
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# Host-side model proxy for a containerised /verify-pr round
|
||||
|
||||
[English](verify-pr-local-model-proxy.md) | [简体中文](verify-pr-local-model-proxy.zh-CN.md)
|
||||
|
||||
Status: implemented.
|
||||
|
||||
## Problem
|
||||
|
||||
The local `/verify-pr` path must execute untrusted PR code inside a
|
||||
credential-free boundary, and the skill's isolation gate says so. The recipe
|
||||
stopped at that requirement: it had no way to let the round reach a model
|
||||
without putting the real key inside the container, no container invocation,
|
||||
and no host-side shape for the steps that must not run from the PR tree.
|
||||
|
||||
Three concrete failures followed:
|
||||
|
||||
- **The key had no safe path.** Exporting `OPENAI_API_KEY` inside the
|
||||
container either hands the real credential to PR code or trips the isolation
|
||||
gate, which refuses any non-empty `OPENAI_API_KEY` because it cannot tell a
|
||||
proxy token from a real key.
|
||||
- **The proxy and the checker are host steps with host privileges.** A
|
||||
repo-relative `node scripts/...` executed from the round tree runs the PR's
|
||||
own copy, while the proxy holds the real key and the checker reads the
|
||||
artifact that certifies the round. The artifact directory is writable by the
|
||||
code the round just executed.
|
||||
- **The obvious container invocation does not support the round.** An
|
||||
interactive `docker run -it` with no name cannot be driven by an agent;
|
||||
mounting only the tree leaves a linked worktree's absolute gitfile
|
||||
unreachable and produces root-owned files in the maintainer's checkout; and
|
||||
the four-key artifact `context.json` is not the full metadata snapshot the
|
||||
round reads.
|
||||
|
||||
## Proposal
|
||||
|
||||
A host-side relay plus a recipe that names its trust boundaries.
|
||||
|
||||
- `scripts/verify-pr-model-proxy.mjs` listens on a chosen host address (the
|
||||
Docker bridge for container clients), holds `REVIEW_OPENAI_API_KEY`, and
|
||||
relays only the exact `${basePath}/chat/completions` path. Clients present a
|
||||
per-run bearer token (`QWEN_VERIFY_PROXY_TOKEN`); the proxy swaps it for the
|
||||
real key upstream. A nonce-echoing health endpoint distinguishes the process
|
||||
from a squatter, and the local recipe compares that nonce before it uses
|
||||
the proxy. Hop-by-hop request headers — both the fixed set and any name a
|
||||
`Connection` header nominates — and the upstream `content-encoding` are
|
||||
stripped; undici has already decoded the body, so forwarding that header
|
||||
would make the client decompress it twice. The idle watchdog is per-chunk,
|
||||
matches the qwen client's own 240 s stream tolerance, stays quiet while the
|
||||
downstream is paused, and re-arms on drain; a disconnect before upstream
|
||||
headers aborts the fetch instead of paying for a completion nobody reads.
|
||||
- The recipe uses two checkouts. `<trusted-clone>` supplies every script the
|
||||
round runs, including the prepare preflight, the credential scan, the
|
||||
isolation gate, the publish guard and the post-round checker; `<pr-clone>`
|
||||
is a standalone clone at the PR head that the container mounts and that
|
||||
prepare is given as its cwd. The clone's `.git` is PR-controlled after a
|
||||
round, so it is not reused, and host-side git commands disable fsmonitor,
|
||||
hooks, credential helpers and `core.excludesFile`, and empty every locally
|
||||
defined clean/smudge/process filter. The enumeration follows
|
||||
`include.path`, reads key names NUL-framed and re-validated (a filter
|
||||
value carrying a newline must not forge extra `-c` entries), covers
|
||||
`.git/config.worktree` when `extensions.worktreeConfig` is set, and the
|
||||
fixed hardening is applied last so no enumerated entry can outrank it. A
|
||||
filter name containing `=` cannot be represented by `-c key=value` at all
|
||||
(git splits the argument at the first `=`), so it is refused with its own
|
||||
remedy rather than emitted as a broken override. `.git/info/exclude` has
|
||||
no `-c` override, so active patterns there are refused outright — a fresh
|
||||
clone ships only the commented default. A linked worktree is rejected by
|
||||
construction because its gitfile points outside the mount.
|
||||
- The container is named and detached (`sleep infinity` plus `docker exec`)
|
||||
and runs with `--user "$(id -u):$(id -g)"` and `HOME=/tmp/qwen-home`, so
|
||||
files written into the mounted tree stay maintainer-owned; the CLI installs
|
||||
into the fresh home because there is no writable global prefix. The base
|
||||
commit's `.qwen` is mounted read-only over the tree, so the round's skill
|
||||
and project settings cannot come from the PR; the root `AGENTS.md`/`QWEN.md`
|
||||
are not covered and still come from the PR clone. The full snapshot is
|
||||
mounted from a host copy outside the tree, and the per-run proxy token is
|
||||
derived from the proxy's JSON file in the step that starts the container, so
|
||||
it stays out of shell history. The credential-free attestation is passed to
|
||||
the shell that starts the round, after `verify-pr-credential-scan.mjs`
|
||||
(trusted copy) rejects credential-shaped ignored files and
|
||||
credential-bearing git config. The config guard inspects VALUES, not a
|
||||
list of keys — any value can hide a token URL (`http.proxy` included), so
|
||||
a key allowlist never converges — plus the URL-bearing key families
|
||||
(`url.*.insteadOf` / `url.*.pushInsteadOf` rewrites, `http.<url>.*`
|
||||
sections), following `include.path` and covering the enabled
|
||||
`.git/config.worktree` scope like prepare's filter enumeration. That file
|
||||
is optional: absent means an empty scope (an explicit `--worktree --list`
|
||||
would exit 128 and false-refuse a clean repo), while an existing but
|
||||
unreadable or malformed one still refuses.
|
||||
The file scan lists ignored files one by one and leaves
|
||||
`node_modules`/`dist` alone, so a built tree is not refused; a nested git
|
||||
repository is refused on every surface git collapses to a directory
|
||||
entry — ignored clone, untracked clone, populated submodule. The round's
|
||||
engine is the released CLI unless the tree's own build is installed over
|
||||
it.
|
||||
- The round reads `$QWEN_VERIFY_CONTEXT` from the full snapshot. After the
|
||||
round, the checker runs from `<trusted-clone>` with `--context <host copy>`,
|
||||
so a writable artifact `context.json` cannot pin its own expected head.
|
||||
|
||||
The CI lane keeps its inline proxy: that copy runs in a root-owned step on the
|
||||
base-pinned workflow, and pointing it at the PR checkout would let a PR read
|
||||
the real key.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Export the real key into the container.** Rejected: the boundary exists to
|
||||
keep it out, and PR code can read its own environment.
|
||||
- **Reuse the CI heredoc by extracting it and calling it from the workflow.**
|
||||
Rejected for the CI lane: the script would come from the PR checkout. The
|
||||
local script is therefore a deliberate second copy. It diverges in more than
|
||||
transport wiring: it matches the exact path instead of a suffix, strips
|
||||
`Connection`-nominated headers and `content-encoding`, uses a 240 s
|
||||
pause-aware idle window instead of the CI copy's fixed 120 s, and aborts a
|
||||
pre-header disconnect. A sync between the copies must carry those
|
||||
behavioural differences, not just the flags.
|
||||
- **Print `0.0.0.0` or normalise the listen host to loopback.** Rejected: the
|
||||
printed value must be the address clients actually dial.
|
||||
- **Mount the tree read-only.** Rejected as the only control: the round must
|
||||
build in the tree and write its artifacts there. The trust boundary is
|
||||
enforced by running the privileged host steps from `<trusted-clone>` and by
|
||||
pinning the expected head from a host-held snapshot.
|
||||
- **Scan `credential.helper` commands for inline tokens.** Declined as out
|
||||
of the URL/header contract: a helper is a command, and spotting an inline
|
||||
token in one means parsing shell. Prepare's own git reads already disable
|
||||
`credential.helper`, and the recipe re-clones rather than reusing a
|
||||
PR-controlled `.git`; the residual shape is recorded here rather than
|
||||
silently dropped.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Proxies: bearer rejection, exact path allowlist, base-path prefixing,
|
||||
real-key injection, hop-by-hop stripping with a chunked body (fixed set
|
||||
and `Connection`-nominated names, with a non-nominated custom header as
|
||||
the passthrough control), a gzip upstream body with the header stripped,
|
||||
pre-header disconnect abort, paused-downstream survival, slow-but-healthy
|
||||
streams, a slow request upload kept alive by the per-chunk re-arm,
|
||||
upstream reset survival, a genuinely refused dial (bound-then-closed
|
||||
port, asserted `ECONNREFUSED` — a blocked port would never dial), an
|
||||
empty or flag-looking `--listen` rejected, IPv6 listen formatting, the
|
||||
idle default read from the core constant's source rather than restated,
|
||||
and the CLI's JSON coordinates.
|
||||
- Credential scan: a nested ignored `.env`, `.env~`, `backup.env~`, `*.pem`,
|
||||
`*.key`, `*.p12`, `*.pfx`, `id_rsa*`, `id_ed25519*`, `id_ecdsa*`, `id_dsa*`,
|
||||
`credentials*`, `.git-credentials`, `.npmrc` (at depth too), `.netrc` or
|
||||
`gha-creds-*.json` is refused (a `.pub` public key is not). The config
|
||||
guard inspects every local value: any http(s) userinfo is refused,
|
||||
including a token used as the username (the bare `git@` account name
|
||||
carries no secret and stays benign in any spelling, like `ssh://git@`),
|
||||
and so is any `Authorization` / `Proxy-Authorization` header value (other
|
||||
header names stay clean); the URL-bearing key families (`insteadOf` /
|
||||
`pushInsteadOf` rewrites, `http.<url>.*` sections) are judged on the
|
||||
embedded URL. A novel key outside any historical list (`custom.mirror`,
|
||||
`http.proxy`) is the witness that value inspection, not a key allowlist,
|
||||
is the contract; the refusal names the key, never the value. Credentials
|
||||
arriving through `include.path` or in the enabled `.git/config.worktree`
|
||||
scope are refused; a credential-free include and benign worktree config
|
||||
stay clean, and with the extension disabled the worktree file is ignored
|
||||
exactly as git ignores it. The worktree file is optional: an enabled
|
||||
extension with no file (or an empty file) is an empty scope on both the
|
||||
scan and prepare paths, while an unreadable or malformed file refuses.
|
||||
Installed `node_modules`/`dist` are ignored at any
|
||||
depth, including inside a workspace package, and a vendored git
|
||||
repository under them is excluded rather than refused; file enumeration
|
||||
is path-limited so a large ignored dependency tree is never buffered
|
||||
(the buffer fixture's files match a credential pathspec, so dropping the
|
||||
restriction really overflows it). A nested git repository is refused on
|
||||
every surface — ignored clone, untracked non-ignored clone, populated
|
||||
submodule — while an unpopulated gitlink stays clean (its URL is covered
|
||||
by the value scan); a missing `--ignored` mode or a dropped pattern turns
|
||||
its test red.
|
||||
- Host git hardening: prepare disables fsmonitor, hooks, credential helpers
|
||||
and `core.excludesFile`, and empties every locally defined
|
||||
clean/smudge/process filter — following `include.path`, NUL-framed key
|
||||
names re-validated against forgery, `.git/config.worktree` covered when
|
||||
`extensions.worktreeConfig` is set, fixed hardening applied last — before
|
||||
reading the PR-controlled tree. Planted-filter fixtures prove the
|
||||
override for a directly defined filter, one arriving through an included
|
||||
worktree file, one in the worktree config, and a newline-bearing value
|
||||
that must not forge a `-c` outranking the hardening, each with a positive
|
||||
control where the vector fires without the guard. A filter name containing
|
||||
`=` is refused with its own remedy on the module and the CLI path — no
|
||||
override can represent it, and the refusal precedes any guarded status.
|
||||
A hostile
|
||||
`core.excludesFile` cannot hide untracked files from the hardened status;
|
||||
active patterns in `.git/info/exclude` are refused with a re-clone
|
||||
remedy, while the commented default passes. A non-git cwd is refused with
|
||||
the worktree message (exit 3), not an uncaught enumeration error.
|
||||
- Checker: `--context` overrides the writable artifact copy, refuses an
|
||||
unusable snapshot instead of falling back (with a usable writable copy
|
||||
seeded), and keeps its existing exit codes.
|
||||
- Recipe: every script names the trusted checkout (including prepare, the
|
||||
scan, the gate, the guard and the checker), the base commit's `.qwen` is
|
||||
mounted read-only and extracted into a freshly emptied directory, both
|
||||
credentials come from files rather than command lines (the key falls back
|
||||
to `REVIEW_OPENAI_KEY_FILE` when stdin is not a TTY, is judged by the
|
||||
variable rather than `read`'s EOF status so a newline-less key file works,
|
||||
and is unset once the proxy owns it), the proxy's coordinates and nonce
|
||||
are awaited before the container starts with stderr in a separate file so
|
||||
the JSON stays parseable, the token is re-derived in the `docker run`
|
||||
step, and the attestation is on the round's shell but not the `docker run`
|
||||
line. `previous-report.md` is written only when a report was found — its
|
||||
presence is the follow-up trigger — and selected by first-paragraph marker
|
||||
from a bot or the maintainer, not by a substring any reply could quote.
|
||||
The publish footer hashes the base-pinned skill copy the round actually
|
||||
loaded, never the PR head's; reverting any of those turns a recipe pin
|
||||
red.
|
||||
- Mutation evidence: removing each of those guards turns its pin red.
|
||||
- A real Docker smoke covers the gate passing with `QWEN_VERIFY_PROXY_TOKEN`,
|
||||
the gate refusing `OPENAI_API_KEY`, and a container request reaching a mock
|
||||
upstream with the real key injected.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 容器化 /verify-pr 轮次的宿主机模型代理
|
||||
|
||||
[English](verify-pr-local-model-proxy.md) | [简体中文](verify-pr-local-model-proxy.zh-CN.md)
|
||||
|
||||
状态:已实现。
|
||||
|
||||
## 问题
|
||||
|
||||
本地 `/verify-pr` 路径必须在无凭据边界内执行不可信的 PR 代码,skill 的隔离门禁也这样要求。但配方只停在要求上:它没有让轮次访问模型的途径而不把真实 key 放进容器,没有容器启动方式,也没有为「不能从 PR 树运行的宿主步骤」定义形态。
|
||||
|
||||
由此产生三个具体失败:
|
||||
|
||||
- **key 没有安全通道。** 在容器里导出 `OPENAI_API_KEY`,要么把真实凭据交给 PR 代码,要么触发隔离门禁——门禁拒绝任何非空的 `OPENAI_API_KEY`,因为它无法区分代理 token 与真实 key。
|
||||
- **代理与检查器是持宿主权限的宿主步骤。** 从轮次树执行仓库相对路径的 `node scripts/...` 会运行 PR 自己的副本,而代理持有真实 key、检查器读取用于认证本轮产物的文件;产物目录恰恰可被轮次刚执行的代码写入。
|
||||
- **显而易见的容器调用方式支撑不了轮次。** 没有名字的交互式 `docker run -it` 无法由 agent 驱动;只挂载树会让链接 worktree 的绝对 gitfile 不可达,并在维护者检出里留下 root 属主文件;四键的产物 `context.json` 也不是轮次读取的完整元数据快照。
|
||||
|
||||
## 方案
|
||||
|
||||
一个宿主机侧转发器,加一套点明信任边界的配方。
|
||||
|
||||
- `scripts/verify-pr-model-proxy.mjs` 监听选定的宿主地址(容器客户端用 Docker 网桥),持有 `REVIEW_OPENAI_API_KEY`,只转发精确的 `${basePath}/chat/completions` 路径。客户端出示每轮一个、整轮有效的 bearer token(`QWEN_VERIFY_PROXY_TOKEN`,非单次使用),代理在上游替换为真实 key。回显 nonce 的健康端点用于区分本进程与抢占者,本地配方在使用代理前比较该 nonce。hop-by-hop 请求头(固定集合与 `Connection` 点名的头)与上游 `content-encoding` 被剥离(undici 已解码,转发该头会让客户端二次解压)。空闲看门狗按块续期,与 qwen 客户端自身的 240 秒流容忍一致;下游暂停期间不中止,drain 时续期;上游响应头之前客户端断开时中止 fetch,而不是为无人读取的补全付费。
|
||||
- 配方使用两个检出。`<trusted-clone>` 提供轮次运行的每个脚本,包括 prepare 预检、凭据扫描、隔离门禁、发布守卫与轮次后的检查器;`<pr-clone>` 是停在 PR head 的独立克隆,供容器挂载,并作为 prepare 的 cwd。轮次结束后该克隆的 `.git` 属 PR 控制,不复用;宿主机侧 git 命令禁用 fsmonitor、hooks、credential helper 与 `core.excludesFile`,并把本地定义的 clean/smudge/process filter 全部置空——枚举跟随 `include.path`,以 NUL 分隔只读键名并逐个重新校验(filter 值里的换行不得伪造额外的 `-c` 条目),`extensions.worktreeConfig` 为真时连 `.git/config.worktree` 一起枚举,固定加固项放在最后使任何枚举项都无法压过它。含 `=` 的 filter 名根本无法用 `-c key=value` 表示(git 在第一个 `=` 处切分参数),因此带自己的补救被拒绝,而不是生成一个失效的 override。`.git/info/exclude` 没有 `-c` 可压制,因此其中的有效模式被直接拒绝——新鲜克隆只有注释默认内容。链接 worktree 因其 gitfile 指向挂载外而在构造上被排除。
|
||||
- 容器有名字且后台运行(`sleep infinity` 加 `docker exec`),以 `--user "$(id -u):$(id -g)"` 与 `HOME=/tmp/qwen-home` 运行,因此写入挂载树的文件保持维护者所有;CLI 装进新 home 是因为容器没有可写的全局前缀。base 提交的 `.qwen` 只读挂到树上(轮次的 skill 与项目设置不能来自 PR);根 `AGENTS.md`/`QWEN.md` 不在覆盖范围内,仍来自 PR 克隆。完整快照从树外宿主副本挂载,代理 token 在启动容器的那一步从 JSON 文件重新取得,避免进入 shell history。无凭据宣誓传给启动轮次的 shell,且在可信的 `verify-pr-credential-scan.mjs` 拒绝凭据形状 ignored 文件与带凭据的 git config 之后才设置;config 守卫检查的是值而不是键名列表——任何值都可能藏 token URL(含 `http.proxy`),键名白名单永远不会收敛——外加携带 URL 的键族(`url.*.insteadOf`/`url.*.pushInsteadOf` 改写、`http.<url>.*` 段),并与 prepare 的 filter 枚举一样跟随 `include.path`、覆盖启用的 `.git/config.worktree` 作用域;该文件是可选的:缺失即空作用域(显式 `--worktree --list` 会退出 128 误拒干净仓库),存在但不可读或损坏仍拒绝;文件扫描逐文件列出 ignored 条目并放过 `node_modules`/`dist`,因此已构建的树不会被误拒;嵌套 git 仓库在 git 会折叠成目录条目的每个面上都被拒绝——被忽略的克隆、未跟踪的克隆、已填充的子模块。轮次的引擎是发布版 CLI,除非把树内构建装到同一前缀覆盖它。
|
||||
- 轮次从完整快照读取 `$QWEN_VERIFY_CONTEXT`。轮次结束后,检查器从 `<trusted-clone>` 运行并带 `--context <宿主副本>`,因此可写的产物 `context.json` 无法自证其期望 head。
|
||||
|
||||
CI 车道保留内联代理:那份副本运行在 base-pinned workflow 的 root 步骤里,若改为指向 PR 检出,PR 就能读到真实 key。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- **把真实 key 导出进容器。** 否决:边界的存在就是为了把它挡在外面,而 PR 代码能读取自己的环境。
|
||||
- **抽取 CI heredoc 并由 workflow 调用。** 对 CI 车道否决:脚本会来自 PR 检出。因此本地脚本是有意的第二份副本。它的差异不止传输接线:精确路径而非后缀、剥离 `Connection` 点名的头与 `content-encoding`、240 秒 pause 感知空闲窗口而非 CI 的固定 120 秒、以及响应头前断连中止。两份同步时必须带上这些行为差异,而不只是参数。
|
||||
- **打印 `0.0.0.0`,或把监听地址归一化成 loopback。** 否决:打印值必须是客户端实际拨号的地址。
|
||||
- **仅把树挂成只读。** 否决:轮次必须在树里构建并写产物。信任边界靠「特权宿主步骤从 `<trusted-clone>` 运行」以及「从宿主快照钉住期望 head」来落实。
|
||||
- **扫描 `credential.helper` 命令里的内联 token。** 否决,超出 URL/头契约:helper 是命令,从中识别内联 token 需要解析 shell。prepare 自身的 git 读取已禁用 `credential.helper`,配方也要求重新克隆而不是复用 PR 可控的 `.git`;这一残余形态在此记录,而不是悄悄丢掉。
|
||||
|
||||
## 验收
|
||||
|
||||
- 代理:bearer 拒绝、精确路径白名单、base path 前缀、真实 key 注入、chunked 请求体下 hop-by-hop 剥离(固定集合与 `Connection` 点名的头,并以未被点名的自定义头作透传正对照)、gzip 上游响应(头被剥离)、响应头前断连中止、下游暂停存活、慢但健康的流、慢请求上传由按块续期保活、上游重置存活、真实被拒的拨号(先绑定后关闭的端口,断言 `ECONNREFUSED`——被屏蔽的端口根本不会拨号)、空值或形似 flag 的 `--listen` 被拒、IPv6 监听格式化、空闲默认值从 core 常量源码读取而非复述,以及 CLI 的 JSON 坐标。
|
||||
- 凭据扫描:嵌套 ignored 的 `.env`、`.env~`、`backup.env~`、`*.pem`、`*.key`、`*.p12`、`*.pfx`、`id_rsa*`、`id_ed25519*`、`id_ecdsa*`、`id_dsa*`、`credentials*`、`.git-credentials`、`.npmrc`(含深层目录)、`.netrc`、`gha-creds-*.json` 被拒绝(`.pub` 公钥不算凭据)。config 守卫检查每个本地值:http(s) 的任意 userinfo 都被拒绝(含把 token 当用户名;裸 `git@` 账号名不含秘密,任意大小写拼写都与 `ssh://git@` 一样保持良性),带 `Authorization`/`Proxy-Authorization` 头的值也被拒绝(其他头名保持干净);携带 URL 的键族(`insteadOf`/`pushInsteadOf` 改写、`http.<url>.*` 段)按键中内嵌的 URL 判定。任何历史列表之外的新键(`custom.mirror`、`http.proxy`)是"契约是值检查而非键名白名单"的见证;拒绝消息只点名键、不打印值。经 `include.path` 引入或位于启用的 `.git/config.worktree` 作用域的凭据被拒绝;不含凭据的 include 与良性 worktree 配置保持干净,扩展禁用时 worktree 文件与 git 一样被忽略。worktree 文件是可选的:扩展启用但文件缺失(或为空)在 scan 与 prepare 两路都是空作用域,不可读或损坏的文件仍拒绝。`node_modules`/`dist` 在任意深度(含 workspace 包内)被忽略,其下的 vendored git 仓库被排除而非拒绝;文件枚举由 pathspec 限制,不会先缓冲整棵依赖树(缓冲夹具的文件本身就匹配凭据 pathspec,去掉限制真的会溢出)。嵌套 git 仓库在每个面上都被拒绝——被忽略的克隆、未跟踪且未被忽略的克隆、已填充的子模块——未填充的 gitlink 保持干净(其 URL 由值扫描覆盖);去掉 `--ignored` 模式或删掉某条模式会让测试变红。
|
||||
- 宿主机 git 硬化:prepare 禁用 fsmonitor、hooks、credential helper 与 `core.excludesFile`,并在读取 PR 可控树之前把本地定义的 clean/smudge/process filter 全部置空——枚举跟随 `include.path`、NUL 分隔的键名逐个重新校验防伪造、`extensions.worktreeConfig` 为真时覆盖 `.git/config.worktree`、固定加固项放在最后。植入夹具证明四种 filter 形态的覆盖:直接定义、经被 include 的工作树文件、worktree config、以及值含换行不得伪造压过加固的 `-c`,各带一个不覆盖就会触发的正对照。含 `=` 的 filter 名在模块与 CLI 两路都带自己的补救被拒绝——没有 override 能表示它,且拒绝先于任何加固 status。恶意 `core.excludesFile` 无法对加固后的 status 隐藏未跟踪文件;`.git/info/exclude` 的有效模式以重新克隆为补救被拒绝,注释默认内容放行。非 git 目录以 worktree 消息干净拒绝(exit 3),而不是未捕获的枚举错误。
|
||||
- 检查器:`--context` 覆盖可写产物副本、不可用快照时拒绝而不是回退(产物副本先种一个合法 head),并保持既有退出码。
|
||||
- 配方:每个脚本都点名可信 checkout(含 prepare、扫描、门禁、守卫与检查器)、base 提交的 `.qwen` 只读挂载且解压到先清空的目录、两个凭据都来自文件而非命令行(stdin 非 TTY 时 key 从 `REVIEW_OPENAI_KEY_FILE` 读取,按变量而非 `read` 的 EOF 返回码判断,因此无结尾换行的 key 文件可用,代理拿到副本后立即 unset)、启动容器前等待代理坐标与 nonce 且 stderr 写入独立文件以保持 JSON 可解析、token 在 `docker run` 那一步重新取得、宣誓在轮次 shell 但不在 `docker run` 行。`previous-report.md` 只在确实找到报告时写入——它的存在就是跟进轮触发器——并按首段标记从 bot 或维护者本人的评论中选取,而不是任何回复都能引用的子串。发布 footer 哈希轮次实际加载的 base-pin skill 副本,绝不哈希 PR head 的;任一被还原都会让配方 pin 变红。
|
||||
- 变异证据:移除上述任一守卫,对应 pin 必须变红。
|
||||
- 真实 Docker 冒烟:`QWEN_VERIFY_PROXY_TOKEN` 时门禁通过、`OPENAI_API_KEY` 时门禁拒绝、容器请求到达 mock 上游且真实 key 已注入。
|
||||
@@ -71,7 +71,10 @@ them rather than at the end of the document.
|
||||
self-consistent assertion triple, and no `merge-ready` carrying a failed
|
||||
assertion. Report shape and evidence naming are warnings, because a
|
||||
documentation-only round has no capture and a checker cannot know whether a
|
||||
harness ran.
|
||||
harness ran. With `--context <snapshot>` the expected head comes from that
|
||||
host-held file instead of the artifact's writable `context.json`, and an
|
||||
unusable snapshot is a hard exit 1, not a warning: the artifact directory is
|
||||
writable by the PR code the round just ran.
|
||||
- `scripts/verify-pr-guard.mjs` owns both halves of the pre-publish guard.
|
||||
`--pr <n> --repo <owner>/<repo> --snapshot <file>` fetches the head, the PR
|
||||
state and all three comment lists with `--paginate`, and writes the file only
|
||||
@@ -417,6 +420,11 @@ A published verification comment ends with the same attribution shape
|
||||
later preparation into a different artifact directory. Publishable round
|
||||
snapshots contain no PR title or body.
|
||||
|
||||
## Related designs
|
||||
|
||||
- The container boundary and its host-side model proxy:
|
||||
[verify-pr-local-model-proxy.md](verify-pr-local-model-proxy.md).
|
||||
|
||||
## Open questions
|
||||
|
||||
None at this scope.
|
||||
@@ -25,7 +25,7 @@
|
||||
新增三个独立脚本,并让 skill 在流程需要的位置指向它们,而不是放在文末。
|
||||
|
||||
- `scripts/verify-pr-prepare.mjs <pr> --repo <owner>/<repo>` 负责解析坐标:用 lane 相同的字段列表抓取快照、校验两个 OID 在本克隆中存在、拒绝不是 PR head 干净检出的树、创建带 `evidence/` 子目录的产物目录,并报告当前进程能观测到的隔离信号。它不据此设闸:解析元数据需要 `gh`,而 `gh` 与边界互斥,因此这条路径总是在宿主上运行。闸门是 `--require-isolation`,用于在边界内、轮次执行任何东西之前运行。
|
||||
- `scripts/verify-pr-check.mjs <dir>` 把发布侧的规则套到本地产物目录:必需文件、四个判定词、为正且自洽的断言三元组、以及不允许带失败断言的 `merge-ready`。报告形态与证据命名只作为警告,因为纯文档轮次没有截图,检查器也无法知道是否跑过 harness。
|
||||
- `scripts/verify-pr-check.mjs <dir>` 把发布侧的规则套到本地产物目录:必需文件、四个判定词、为正且自洽的断言三元组、以及不允许带失败断言的 `merge-ready`。报告形态与证据命名只作为警告,因为纯文档轮次没有截图,检查器也无法知道是否跑过 harness。带 `--context <snapshot>` 时期望 head 来自该宿主持有的文件,而不是产物里可写的 `context.json`;不可用快照是硬性 exit 1 而非警告:产物目录可被轮次刚执行的 PR 代码写入。
|
||||
- `scripts/verify-pr-guard.mjs` 接管发布前守卫的两个半程。`--pr <n> --repo <owner>/<repo> --snapshot <file>` 抓取 head、PR 状态以及三个评论列表(带 `--paginate`),只有每次调用都成功才写文件。`--compare --start <a> --now <b>` 列出本轮 dirty 的全部原因并退出 1。
|
||||
|
||||
检查器在产物契约要求轮次写出的产物上新增三条规则。产物契约里三者都要点名:产物清单没要求的文件,轮次就不会产出,而对一个不存在的文件做检查是静默的。
|
||||
@@ -97,6 +97,11 @@
|
||||
- 所有脚本都不执行 PR 代码。
|
||||
- 对于自带轮次快照的报告,在不同产物目录中准备后续轮次不会改变检查所用的提交号。可发布的轮次快照不包含 PR 标题或正文。
|
||||
|
||||
## 相关设计
|
||||
|
||||
- 容器边界与它的宿主机模型代理:
|
||||
[verify-pr-local-model-proxy.zh-CN.md](verify-pr-local-model-proxy.zh-CN.md)。
|
||||
|
||||
## 未决问题
|
||||
|
||||
本范围内无。
|
||||
@@ -762,4 +762,78 @@ describe.skipIf(process.platform === 'win32')('check (CLI)', () => {
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
// The artifact directory is writable by the PR code the round just ran, so
|
||||
// its own context.json cannot pin the head. --context reads a copy the round
|
||||
// never saw and must not fall back to the writable one.
|
||||
it('pins the head from a host-held snapshot over the artifact copy', () => {
|
||||
const dir = makeArtifactDir(validFiles(), ['01-ab-cells.png']);
|
||||
writeFileSync(
|
||||
join(dir, 'context.json'),
|
||||
JSON.stringify({ headRefOid: 'e'.repeat(40) }),
|
||||
);
|
||||
const hostSnapshot = join(tmpdir(), `verify-pr-host-${Date.now()}.json`);
|
||||
tmpDirs.push(hostSnapshot);
|
||||
writeFileSync(hostSnapshot, JSON.stringify({ headRefOid: HEAD }));
|
||||
|
||||
const pinned = spawnSync(
|
||||
process.execPath,
|
||||
[SCRIPT, '--context', hostSnapshot, dir],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
expect(pinned.status).toBe(0);
|
||||
expect(pinned.stdout).toContain(`head source: ${hostSnapshot}`);
|
||||
expect(pinned.stdout).not.toContain('does not name the verified head');
|
||||
|
||||
const artifactOnly = run(dir);
|
||||
expect(artifactOnly.status).toBe(0);
|
||||
expect(artifactOnly.stdout).toContain('does not name the verified head');
|
||||
});
|
||||
|
||||
it('refuses an unusable --context instead of falling back', () => {
|
||||
const dir = makeArtifactDir(validFiles(), ['01-ab-cells.png']);
|
||||
// Seed a usable writable copy: a fallback regression would exit 0 here
|
||||
// and print its head source, so the exit-1 assertion pins the property.
|
||||
writeFileSync(
|
||||
join(dir, 'context.json'),
|
||||
JSON.stringify({ headRefOid: HEAD }),
|
||||
);
|
||||
const empty = join(tmpdir(), `verify-pr-host-empty-${Date.now()}.json`);
|
||||
tmpDirs.push(empty);
|
||||
writeFileSync(empty, '{}');
|
||||
for (const snapshot of [join(tmpdir(), 'verify-pr-no-such.json'), empty]) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[SCRIPT, '--context', snapshot, dir],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('--context');
|
||||
expect(result.stdout).not.toContain('head source:');
|
||||
}
|
||||
});
|
||||
|
||||
it('exits 2 for an unknown option or a missing --context value', () => {
|
||||
const dir = makeArtifactDir(validFiles(), ['01-ab-cells.png']);
|
||||
expect(
|
||||
spawnSync(process.execPath, [SCRIPT, '--bogus', dir], {
|
||||
encoding: 'utf8',
|
||||
}).status,
|
||||
).toBe(2);
|
||||
expect(
|
||||
spawnSync(process.execPath, [SCRIPT, '--context'], {
|
||||
encoding: 'utf8',
|
||||
}).status,
|
||||
).toBe(2);
|
||||
// A forgotten --context turns the snapshot into a second positional:
|
||||
// refuse instead of silently using the artifact's writable copy.
|
||||
const hostSnapshot = join(tmpdir(), `verify-pr-host-${Date.now()}.json`);
|
||||
tmpDirs.push(hostSnapshot);
|
||||
writeFileSync(hostSnapshot, JSON.stringify({ headRefOid: HEAD }));
|
||||
expect(
|
||||
spawnSync(process.execPath, [SCRIPT, dir, hostSnapshot], {
|
||||
encoding: 'utf8',
|
||||
}).status,
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,702 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
gitScanArgs,
|
||||
isCredentialShaped,
|
||||
scanIgnoredCredentials,
|
||||
} from '../../scripts/verify-pr-credential-scan.mjs';
|
||||
|
||||
const SCRIPT = fileURLToPath(
|
||||
new URL('../../scripts/verify-pr-credential-scan.mjs', import.meta.url),
|
||||
);
|
||||
|
||||
let tmpDirs = [];
|
||||
afterEach(() => {
|
||||
for (const dir of tmpDirs) rmSync(dir, { recursive: true, force: true });
|
||||
tmpDirs = [];
|
||||
});
|
||||
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_SYSTEM: '/dev/null',
|
||||
GIT_AUTHOR_NAME: 't',
|
||||
GIT_AUTHOR_EMAIL: '[email protected]',
|
||||
GIT_COMMITTER_NAME: 't',
|
||||
GIT_COMMITTER_EMAIL: '[email protected]',
|
||||
};
|
||||
|
||||
function makeRepo(files, ignore) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'verify-pr-scan-'));
|
||||
tmpDirs.push(dir);
|
||||
execFileSync('git', ['init', '-q', '.'], { cwd: dir, env: gitEnv });
|
||||
writeFileSync(join(dir, '.gitignore'), `${ignore.join('\n')}\n`);
|
||||
for (const file of files) {
|
||||
mkdirSync(join(dir, file, '..'), { recursive: true });
|
||||
writeFileSync(join(dir, file), 'secret');
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
const run = (dir) =>
|
||||
spawnSync(process.execPath, [SCRIPT, dir], { encoding: 'utf8' });
|
||||
|
||||
describe('credential basename matching', () => {
|
||||
it('matches the shapes a provider key or certificate uses', () => {
|
||||
for (const path of [
|
||||
'.env',
|
||||
'.env.local',
|
||||
'app.env',
|
||||
'secrets/prod.env',
|
||||
'.env~',
|
||||
'tls.pem',
|
||||
'id.key',
|
||||
'auth.p12',
|
||||
'tls.pfx',
|
||||
'id_rsa',
|
||||
'id_ed25519',
|
||||
'id_ecdsa',
|
||||
'id_dsa',
|
||||
'credentials',
|
||||
'credentials.json',
|
||||
'.git-credentials',
|
||||
'gha-creds-123.json',
|
||||
'.npmrc',
|
||||
'.netrc',
|
||||
]) {
|
||||
expect(isCredentialShaped(path), path).toBe(true);
|
||||
}
|
||||
for (const path of [
|
||||
'README.md',
|
||||
'package.json',
|
||||
'keymap.ts',
|
||||
'envelope.ts',
|
||||
'environment.md',
|
||||
'id_rsa.pub',
|
||||
]) {
|
||||
expect(isCredentialShaped(path), path).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitScanArgs', () => {
|
||||
it('enumerates with depth-aware credential pathspecs', () => {
|
||||
const args = gitScanArgs('/repo');
|
||||
expect(args).toContain(':(glob)**/.env');
|
||||
expect(args).toContain(':(glob)**/*.pem');
|
||||
expect(args).toContain(':(glob)**/*.env~');
|
||||
expect(args).toContain(':(glob)**/*.p12');
|
||||
expect(args).toContain(':(glob)**/*.pfx');
|
||||
expect(args).toContain(':(glob)**/id_rsa*');
|
||||
expect(args).toContain(':(glob)**/id_ed25519*');
|
||||
expect(args).toContain(':(glob)**/id_ecdsa*');
|
||||
expect(args).toContain(':(glob)**/id_dsa*');
|
||||
expect(args).toContain(':(glob)**/*credentials*');
|
||||
expect(args).toContain(':(glob)**/gha-creds-*.json');
|
||||
// Excludes apply at workspace depth, not only the repository root.
|
||||
expect(args).toContain(':(exclude,glob)**/node_modules/**');
|
||||
expect(args).toContain(':(exclude,glob)**/dist/**');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanIgnoredCredentials', () => {
|
||||
it('reports nested and top-level ignored credential files', () => {
|
||||
const dir = makeRepo(
|
||||
[
|
||||
'vault/.env',
|
||||
'secrets/prod.env',
|
||||
'.env~',
|
||||
'.env.local',
|
||||
'tls.pem',
|
||||
'secrets/id.key',
|
||||
'auth.p12',
|
||||
'secrets/tls.pfx',
|
||||
'id_rsa',
|
||||
'id_rsa.pub',
|
||||
'secrets/id_ed25519',
|
||||
'secrets/id_ecdsa',
|
||||
'secrets/id_dsa',
|
||||
'backup.env~',
|
||||
'secrets/credentials.json',
|
||||
'.git-credentials',
|
||||
'gha-creds-123.json',
|
||||
'.npmrc',
|
||||
'packages/foo/.npmrc',
|
||||
'.netrc',
|
||||
],
|
||||
[
|
||||
'.env*',
|
||||
'*.env',
|
||||
'*.env~',
|
||||
'*.pem',
|
||||
'*.key',
|
||||
'*.p12',
|
||||
'*.pfx',
|
||||
'id_rsa*',
|
||||
'id_ed25519*',
|
||||
'id_ecdsa*',
|
||||
'id_dsa*',
|
||||
'*credentials*',
|
||||
'.npmrc',
|
||||
'.netrc',
|
||||
'gha-creds-*.json',
|
||||
'vault/',
|
||||
],
|
||||
);
|
||||
expect(scanIgnoredCredentials(dir)).toEqual([
|
||||
'.env.local',
|
||||
'.env~',
|
||||
'.git-credentials',
|
||||
'.netrc',
|
||||
'.npmrc',
|
||||
'auth.p12',
|
||||
'backup.env~',
|
||||
'gha-creds-123.json',
|
||||
'id_rsa',
|
||||
'packages/foo/.npmrc',
|
||||
'secrets/credentials.json',
|
||||
'secrets/id.key',
|
||||
'secrets/id_dsa',
|
||||
'secrets/id_ecdsa',
|
||||
'secrets/id_ed25519',
|
||||
'secrets/prod.env',
|
||||
'secrets/tls.pfx',
|
||||
'tls.pem',
|
||||
'vault/.env',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a remote URL that carries a credential', () => {
|
||||
const dir = makeRepo(['src/app.ts'], ['node_modules/']);
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'remote.origin.url',
|
||||
'https://x-access-token:[email protected]/o/r.git',
|
||||
],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(() => scanIgnoredCredentials(dir)).toThrow(
|
||||
/credential-bearing git config/,
|
||||
);
|
||||
const result = run(dir);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('credential-bearing git config');
|
||||
});
|
||||
|
||||
it('refuses a token used as the URL username and insteadOf credentials', () => {
|
||||
const dir = makeRepo(['src/app.ts'], []);
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'remote.origin.url',
|
||||
'https://[email protected]/o/r.git',
|
||||
],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(() => scanIgnoredCredentials(dir)).toThrow(
|
||||
/credential-bearing git config/,
|
||||
);
|
||||
const dir2 = makeRepo(['src/app.ts'], []);
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'url.https://x:[email protected]/.insteadOf',
|
||||
'https://github.com/',
|
||||
],
|
||||
{ cwd: dir2, env: gitEnv },
|
||||
);
|
||||
expect(() => scanIgnoredCredentials(dir2)).toThrow(
|
||||
/credential-bearing git config/,
|
||||
);
|
||||
});
|
||||
|
||||
// The same credential class as remote.*.url: a push URL is the natural
|
||||
// place for a PAT, and submodule URLs and the Actions-written extraheader
|
||||
// are equally readable from inside the tree with plain `git config`.
|
||||
it('refuses pushurl, submodule URL and Authorization extraheader credentials', () => {
|
||||
const cases = [
|
||||
['remote.origin.pushurl', 'https://[email protected]/o/r.git'],
|
||||
['submodule.lib.url', 'https://x:[email protected]/o/lib.git'],
|
||||
[
|
||||
'http.https://github.com/.extraheader',
|
||||
'AUTHORIZATION: basic eC1hY2Nlc3MtdG9rZW46Z2hwX0ZBS0VQQVQ=',
|
||||
],
|
||||
[
|
||||
'http.https://proxy.internal/.extraheader',
|
||||
'Proxy-Authorization: Bearer ghp_FAKEPAT',
|
||||
],
|
||||
];
|
||||
for (const [key, value] of cases) {
|
||||
const dir = makeRepo(['src/app.ts'], []);
|
||||
execFileSync('git', ['config', '--local', key, value], {
|
||||
cwd: dir,
|
||||
env: gitEnv,
|
||||
});
|
||||
expect(() => scanIgnoredCredentials(dir), key).toThrow(
|
||||
/credential-bearing git config/,
|
||||
);
|
||||
const result = run(dir);
|
||||
expect(result.status, key).toBe(1);
|
||||
expect(result.stderr, key).toContain('credential-bearing git config');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a non-Authorization extraheader out of the refusal', () => {
|
||||
const dir = makeRepo(['src/app.ts'], []);
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'http.https://github.com/.extraheader',
|
||||
'X-Request-Id: trace-123',
|
||||
],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(scanIgnoredCredentials(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
// The guard inspects VALUES, not a key allowlist: any config value can
|
||||
// carry a token URL, and the URL-bearing key families (rewrites, http
|
||||
// sections) carry theirs in the key. Restoring a fixed key list must turn
|
||||
// these red — a novel key is the witness that no list converges.
|
||||
it('refuses credential-bearing values and keys outside any fixed list', () => {
|
||||
const cases = [
|
||||
// pushInsteadOf: the one-word sibling of insteadOf, credential in KEY.
|
||||
[
|
||||
'url.https://x:[email protected]/.pushInsteadOf',
|
||||
'https://github.com/',
|
||||
],
|
||||
// http.proxy: credential in VALUE, a key no allowlist enumerated.
|
||||
['http.proxy', 'http://user:[email protected]:3128'],
|
||||
// A novel key entirely: only value inspection can see this.
|
||||
['custom.mirror', 'https://[email protected]/o/r.git'],
|
||||
// http.<url> section with the token in the KEY, benign value.
|
||||
['http.https://[email protected]/.extraheader', 'X-Thing: 1'],
|
||||
];
|
||||
for (const [key, value] of cases) {
|
||||
const dir = makeRepo(['src/app.ts'], []);
|
||||
execFileSync('git', ['config', '--local', key, value], {
|
||||
cwd: dir,
|
||||
env: gitEnv,
|
||||
});
|
||||
expect(() => scanIgnoredCredentials(dir), key).toThrow(
|
||||
/credential-bearing git config/,
|
||||
);
|
||||
const result = run(dir);
|
||||
expect(result.status, key).toBe(1);
|
||||
// The refusal names the key, never the value: when the credential sits
|
||||
// in the value, the token must not appear in the output at all. Git
|
||||
// lowercases the variable name but preserves the URL subsection's
|
||||
// case, so only the last segment is folded here.
|
||||
const reportedKey = key.replace(/\.[^.]+$/, (segment) =>
|
||||
segment.toLowerCase(),
|
||||
);
|
||||
expect(result.stderr, key).toContain(reportedKey);
|
||||
if (!key.toLowerCase().includes('ghp_fakepat')) {
|
||||
expect(result.stderr, key).not.toContain('ghp_FAKEPAT');
|
||||
}
|
||||
}
|
||||
// A proxy without userinfo stays clean: the widening added recognition,
|
||||
// not a blanket refusal of the key.
|
||||
const dir = makeRepo(['src/app.ts'], []);
|
||||
execFileSync(
|
||||
'git',
|
||||
['config', '--local', 'http.proxy', 'http://proxy.internal:3128'],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(scanIgnoredCredentials(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
// The credential-side twin of prepare's include.path filter bypass:
|
||||
// `--local` alone names one file, while the `git config --get` PR code
|
||||
// uses follows includes — an included credential must be refused too.
|
||||
it('refuses credentials that arrive through include.path', () => {
|
||||
const cases = [
|
||||
'[remote "origin"]\n\turl = https://[email protected]/o/r.git\n',
|
||||
'[remote "origin"]\n\tpushurl = https://[email protected]/o/r.git\n',
|
||||
'[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic eC1hY2Nlc3MtdG9rZW46Z2hwX0ZBS0VQQVQ=\n',
|
||||
];
|
||||
for (const content of cases) {
|
||||
const dir = makeRepo(['src/app.ts'], ['tmp/']);
|
||||
mkdirSync(join(dir, 'tmp'), { recursive: true });
|
||||
writeFileSync(join(dir, 'tmp', 'creds.inc'), content);
|
||||
execFileSync(
|
||||
'git',
|
||||
['config', '--local', 'include.path', '../tmp/creds.inc'],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(() => scanIgnoredCredentials(dir), content).toThrow(
|
||||
/credential-bearing git config/,
|
||||
);
|
||||
const result = run(dir);
|
||||
expect(result.status, content).toBe(1);
|
||||
expect(result.stderr, content).toContain('credential-bearing git config');
|
||||
}
|
||||
// A credential-free include is zero collateral: same shape, benign URL.
|
||||
const dir = makeRepo(['src/app.ts'], ['tmp/']);
|
||||
mkdirSync(join(dir, 'tmp'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'tmp', 'creds.inc'),
|
||||
'[remote "origin"]\n\turl = https://github.com/o/r.git\n',
|
||||
);
|
||||
execFileSync(
|
||||
'git',
|
||||
['config', '--local', 'include.path', '../tmp/creds.inc'],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(scanIgnoredCredentials(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
// Ordinary `git config --get` reads .git/config.worktree when the
|
||||
// extension is enabled; --local alone would report a credential there as
|
||||
// clean while PR code reads it. The scan must enumerate the enabled scope.
|
||||
it('refuses credentials in the enabled worktree config scope', () => {
|
||||
const dir = makeRepo(['src/app.ts'], []);
|
||||
execFileSync(
|
||||
'git',
|
||||
['config', '--local', 'extensions.worktreeConfig', 'true'],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--worktree',
|
||||
'http.https://github.com/.extraheader',
|
||||
'Authorization: Bearer FAKE_WORKTREE',
|
||||
],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(() => scanIgnoredCredentials(dir)).toThrow(
|
||||
/credential-bearing git config/,
|
||||
);
|
||||
expect(run(dir).status).toBe(1);
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--worktree',
|
||||
'--unset',
|
||||
'http.https://github.com/.extraheader',
|
||||
],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--worktree',
|
||||
'remote.origin.url',
|
||||
'https://[email protected]/repo.git',
|
||||
],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(() => scanIgnoredCredentials(dir)).toThrow(
|
||||
/credential-bearing git config/,
|
||||
);
|
||||
expect(run(dir).status).toBe(1);
|
||||
execFileSync(
|
||||
'git',
|
||||
['config', '--worktree', '--unset', 'remote.origin.url'],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
// Benign worktree config stays clean.
|
||||
execFileSync(
|
||||
'git',
|
||||
['config', '--worktree', 'core.logAllRefUpdates', 'true'],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(scanIgnoredCredentials(dir)).toEqual([]);
|
||||
// With the extension disabled git itself ignores the worktree file, and
|
||||
// so does the scan: the same planted credential reads as clean.
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--worktree',
|
||||
'remote.origin.url',
|
||||
'https://[email protected]/repo.git',
|
||||
],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
execFileSync(
|
||||
'git',
|
||||
['config', '--local', '--unset', 'extensions.worktreeConfig'],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(scanIgnoredCredentials(dir)).toEqual([]);
|
||||
expect(run(dir).status).toBe(0);
|
||||
});
|
||||
|
||||
// Enabling the extension does not require config.worktree to exist: an
|
||||
// absent optional file is an empty scope, not a refusal (explicit
|
||||
// `--worktree --list` exits 128 on it). Unreadable or malformed files
|
||||
// must still refuse — the tolerance is for absence only.
|
||||
it('treats an absent optional worktree config as an empty scope', () => {
|
||||
const dir = makeRepo(['src/app.ts'], []);
|
||||
execFileSync(
|
||||
'git',
|
||||
['config', '--local', 'extensions.worktreeConfig', 'true'],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(scanIgnoredCredentials(dir)).toEqual([]);
|
||||
expect(run(dir).status).toBe(0);
|
||||
// An existing empty file is the same empty scope.
|
||||
writeFileSync(join(dir, '.git', 'config.worktree'), '');
|
||||
expect(scanIgnoredCredentials(dir)).toEqual([]);
|
||||
expect(run(dir).status).toBe(0);
|
||||
// A malformed file refuses on both paths.
|
||||
writeFileSync(join(dir, '.git', 'config.worktree'), '[broken\n');
|
||||
expect(() => scanIgnoredCredentials(dir)).toThrow(
|
||||
/could not (read local git config|list nested repositories)/,
|
||||
);
|
||||
expect(run(dir).status).toBe(1);
|
||||
});
|
||||
|
||||
// POSIX permissions only: on Windows chmod 000 does not block reads.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'refuses an unreadable worktree config instead of treating it as absent',
|
||||
() => {
|
||||
const dir = makeRepo(['src/app.ts'], []);
|
||||
execFileSync(
|
||||
'git',
|
||||
['config', '--local', 'extensions.worktreeConfig', 'true'],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
writeFileSync(join(dir, '.git', 'config.worktree'), '');
|
||||
chmodSync(join(dir, '.git', 'config.worktree'), 0o000);
|
||||
try {
|
||||
expect(() => scanIgnoredCredentials(dir)).toThrow(
|
||||
/could not (read local git config|list nested repositories)/,
|
||||
);
|
||||
expect(run(dir).status).toBe(1);
|
||||
} finally {
|
||||
chmodSync(join(dir, '.git', 'config.worktree'), 0o644);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps ssh URLs, the bare git username and public keys out of the refusal', () => {
|
||||
const dir = makeRepo(['id_rsa.pub'], ['id_rsa*']);
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'remote.origin.url',
|
||||
'ssh://[email protected]/o/r.git',
|
||||
],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(scanIgnoredCredentials(dir)).toEqual([]);
|
||||
// The conventional account name carries no secret on http(s) either;
|
||||
// refusing it would disagree with ssh://git@ on the same non-secret.
|
||||
const dir2 = makeRepo(['src/app.ts'], []);
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'remote.origin.url',
|
||||
'https://[email protected]/o/r.git',
|
||||
],
|
||||
{ cwd: dir2, env: gitEnv },
|
||||
);
|
||||
expect(scanIgnoredCredentials(dir2)).toEqual([]);
|
||||
expect(run(dir2).status).toBe(0);
|
||||
// Account names are case-insensitive: the uppercase spellings carry
|
||||
// the same non-secret and must not re-introduce the false refusal.
|
||||
for (const spelling of ['GIT', 'Git']) {
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'remote.origin.url',
|
||||
`https://${spelling}@github.com/o/r.git`,
|
||||
],
|
||||
{ cwd: dir2, env: gitEnv },
|
||||
);
|
||||
expect(scanIgnoredCredentials(dir2), spelling).toEqual([]);
|
||||
expect(run(dir2).status, spelling).toBe(0);
|
||||
}
|
||||
// A password after the same username is still a credential.
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'remote.origin.url',
|
||||
'https://git:[email protected]/o/r.git',
|
||||
],
|
||||
{ cwd: dir2, env: gitEnv },
|
||||
);
|
||||
expect(() => scanIgnoredCredentials(dir2)).toThrow(
|
||||
/credential-bearing git config/,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not refuse a vendored real git repository under node_modules', () => {
|
||||
const dir = makeRepo(['node_modules/pkg/index.js'], ['node_modules/']);
|
||||
const nested = join(dir, 'node_modules', 'pkg');
|
||||
execFileSync('git', ['init', '-q', '.'], { cwd: nested, env: gitEnv });
|
||||
execFileSync('git', ['add', '.'], { cwd: nested, env: gitEnv });
|
||||
execFileSync('git', ['commit', '-q', '-m', 'dep'], {
|
||||
cwd: nested,
|
||||
env: gitEnv,
|
||||
});
|
||||
expect(scanIgnoredCredentials(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not refuse an installed tree or build output at any depth', () => {
|
||||
const dir = makeRepo(
|
||||
[
|
||||
'node_modules/pkg/credentials.json',
|
||||
'dist/app.pem',
|
||||
'packages/foo/node_modules/pkg/credentials.json',
|
||||
'packages/foo/dist/credentials.js',
|
||||
'packages/foo/dist/app.pem',
|
||||
'src/app.ts',
|
||||
],
|
||||
['node_modules/', 'dist/', '*.pem', 'credentials*'],
|
||||
);
|
||||
expect(scanIgnoredCredentials(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('limits enumeration with pathspecs instead of buffering the tree', () => {
|
||||
// The dependency files MATCH a credential pathspec (*credentials*): with
|
||||
// the pathspecs dropped, this listing overflows the 64 KiB ceiling, so
|
||||
// the test actually exercises the restriction it names.
|
||||
const files = Array.from(
|
||||
{ length: 3000 },
|
||||
(_, index) =>
|
||||
`packages/foo/node_modules/pkg/pkg-credentials-${index}.json`,
|
||||
);
|
||||
files.push('vault/.env');
|
||||
const dir = makeRepo(files, ['node_modules/', 'vault/']);
|
||||
// A 64 KiB ceiling is far below the dependency listing: only the
|
||||
// pathspec-restricted output (the nested .env) fits without ENOBUFS.
|
||||
expect(scanIgnoredCredentials(dir, { maxBuffer: 64 * 1024 })).toEqual([
|
||||
'vault/.env',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a nested repository instead of silently skipping it', () => {
|
||||
const dir = makeRepo(['tmp/pr-clone/.env'], ['tmp/']);
|
||||
execFileSync('git', ['init', '-q', '.'], {
|
||||
cwd: join(dir, 'tmp', 'pr-clone'),
|
||||
env: gitEnv,
|
||||
});
|
||||
expect(() => scanIgnoredCredentials(dir)).toThrow(
|
||||
/nested git repository under tmp\/pr-clone/,
|
||||
);
|
||||
const result = run(dir);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('nested git repository');
|
||||
});
|
||||
|
||||
// A round's own `git clone` leaves an UNTRACKED, non-ignored clone: the
|
||||
// ignored-only enumeration never saw it, and git does not descend into it
|
||||
// for the credential file scan either.
|
||||
it('refuses an untracked non-ignored nested clone', () => {
|
||||
const dir = makeRepo(['src/app.ts'], []);
|
||||
const nested = join(dir, 'tools', 'clone');
|
||||
mkdirSync(nested, { recursive: true });
|
||||
execFileSync('git', ['init', '-q', '.'], { cwd: nested, env: gitEnv });
|
||||
writeFileSync(join(nested, '.env'), 'OPENAI_API_KEY=sk-from-clone\n');
|
||||
expect(() => scanIgnoredCredentials(dir)).toThrow(
|
||||
/nested git repository under tools\/clone/,
|
||||
);
|
||||
const result = run(dir);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('nested git repository');
|
||||
});
|
||||
|
||||
// A populated submodule is a gitlink whose working tree git will not
|
||||
// descend into; an unpopulated one has no tree to read (its URL is covered
|
||||
// by the config value scan instead).
|
||||
it('refuses a populated submodule but not an unpopulated gitlink', () => {
|
||||
const dir = makeRepo(['src/app.ts'], []);
|
||||
const nested = join(dir, 'vendor', 'up');
|
||||
mkdirSync(nested, { recursive: true });
|
||||
execFileSync('git', ['init', '-q', '.'], { cwd: nested, env: gitEnv });
|
||||
writeFileSync(join(nested, '.env'), 'OPENAI_API_KEY=sk-from-submodule\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: nested, env: gitEnv });
|
||||
execFileSync('git', ['commit', '-q', '-m', 'sub'], {
|
||||
cwd: nested,
|
||||
env: gitEnv,
|
||||
});
|
||||
const sha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: nested,
|
||||
env: gitEnv,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
execFileSync(
|
||||
'git',
|
||||
['update-index', '--add', '--cacheinfo', `160000,${sha},vendor/up`],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(() => scanIgnoredCredentials(dir)).toThrow(
|
||||
/nested git repository under vendor\/up/,
|
||||
);
|
||||
expect(run(dir).status).toBe(1);
|
||||
|
||||
// Unpopulated: the gitlink stays, the working tree goes.
|
||||
rmSync(nested, { recursive: true, force: true });
|
||||
expect(scanIgnoredCredentials(dir)).toEqual([]);
|
||||
expect(run(dir).status).toBe(0);
|
||||
});
|
||||
|
||||
it('stays silent on a clean tree', () => {
|
||||
const dir = makeRepo(['src/app.ts'], ['node_modules/']);
|
||||
const result = run(dir);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe('');
|
||||
});
|
||||
|
||||
it('exits 1 and names each entry when one is found', () => {
|
||||
const dir = makeRepo(['vault/.env'], ['.env', 'vault/']);
|
||||
const result = run(dir);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain(
|
||||
'credential-shaped ignored entry: vault/.env',
|
||||
);
|
||||
});
|
||||
|
||||
it('exits 2 without a root and 1 when git cannot list files', () => {
|
||||
expect(
|
||||
spawnSync(process.execPath, [SCRIPT], { encoding: 'utf8' }).status,
|
||||
).toBe(2);
|
||||
const notRepo = mkdtempSync(join(tmpdir(), 'verify-pr-scan-nogit-'));
|
||||
tmpDirs.push(notRepo);
|
||||
const result = run(notRepo);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toMatch(
|
||||
/could not list (nested repositories|ignored files)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,906 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DEFAULT_IDLE_TIMEOUT_MS,
|
||||
createModelProxy,
|
||||
formatLocalBaseUrl,
|
||||
normalizeBasePath,
|
||||
parseArgs,
|
||||
} from '../../scripts/verify-pr-model-proxy.mjs';
|
||||
|
||||
const SCRIPT = fileURLToPath(
|
||||
new URL('../../scripts/verify-pr-model-proxy.mjs', import.meta.url),
|
||||
);
|
||||
|
||||
const TOKEN = 'test-proxy-token';
|
||||
const NONCE = 'test-nonce';
|
||||
const REAL_KEY = 'real-upstream-key';
|
||||
|
||||
let cleanups = [];
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups) await cleanup();
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address();
|
||||
cleanups.push(
|
||||
() =>
|
||||
new Promise((done) => {
|
||||
server.closeAllConnections();
|
||||
server.close(done);
|
||||
}),
|
||||
);
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function waitFor(predicate, timeoutMs = 1000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await sleep(5);
|
||||
}
|
||||
throw new Error('condition not met before timeout');
|
||||
}
|
||||
|
||||
/**
|
||||
* A local upstream stand-in: records the request and the sequence it took to
|
||||
* answer (or to be aborted). No outbound network is involved.
|
||||
*/
|
||||
async function startUpstream({
|
||||
status = 200,
|
||||
chunks = ['data: ok\n\n'],
|
||||
delayMs = 0,
|
||||
chunkDelayMs = 0,
|
||||
resetAfterFirstChunk = false,
|
||||
respectBackpressure = false,
|
||||
gzipBody = false,
|
||||
headersOnly = false,
|
||||
} = {}) {
|
||||
const requests = [];
|
||||
const events = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const body = [];
|
||||
req.on('data', (chunk) => body.push(chunk));
|
||||
req.on('end', async () => {
|
||||
requests.push({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
authorization: req.headers.authorization,
|
||||
body: Buffer.concat(body).toString('utf8'),
|
||||
});
|
||||
res.on('finish', () => events.push('upstream-finish'));
|
||||
if (delayMs) await sleep(delayMs);
|
||||
if (res.destroyed) {
|
||||
events.push('aborted-before-headers');
|
||||
return;
|
||||
}
|
||||
if (gzipBody) {
|
||||
// The gzip header is what the relay must drop: undici hands the proxy
|
||||
// decoded bytes, so forwarding `content-encoding: gzip` makes the
|
||||
// client gunzip plain text.
|
||||
res.writeHead(status, {
|
||||
'content-type': 'text/event-stream',
|
||||
'content-encoding': 'gzip',
|
||||
'content-length': String(
|
||||
gzipSync(Buffer.from(chunks.join(''))).length,
|
||||
),
|
||||
});
|
||||
events.push('write-head');
|
||||
requests.at(-1).sentContentEncoding = 'gzip';
|
||||
res.end(gzipSync(Buffer.from(chunks.join(''))));
|
||||
return;
|
||||
}
|
||||
res.writeHead(status, { 'content-type': 'text/event-stream' });
|
||||
events.push('write-head');
|
||||
if (headersOnly) {
|
||||
// Flush so the proxy's fetch resolves on headers; the test is the
|
||||
// first-body idle window, not the pre-header one.
|
||||
res.flushHeaders();
|
||||
return;
|
||||
}
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
if (chunkDelayMs) await sleep(chunkDelayMs);
|
||||
if (res.destroyed) {
|
||||
events.push('aborted-mid-body');
|
||||
return;
|
||||
}
|
||||
// Respecting backpressure keeps the stream alive while the proxy's
|
||||
// downstream is paused; without it the mock buffers the whole body in
|
||||
// its own process and the pause never propagates.
|
||||
if (!res.write(chunk) && respectBackpressure) {
|
||||
await once(res, 'drain');
|
||||
}
|
||||
if (resetAfterFirstChunk && index === 0) {
|
||||
// Register on the socket, not unconditionally: the test asserts
|
||||
// the upstream really reset after headers.
|
||||
res.on('close', () => events.push('upstream-reset'));
|
||||
await sleep(50);
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
const port = await listen(server);
|
||||
return { port, requests, events };
|
||||
}
|
||||
|
||||
async function startProxy(upstream, overrides = {}, basePath = '/v1') {
|
||||
const server = createModelProxy({
|
||||
baseUrl: `http://127.0.0.1:${upstream.port}${basePath}`,
|
||||
apiKey: REAL_KEY,
|
||||
token: TOKEN,
|
||||
nonce: NONCE,
|
||||
...overrides,
|
||||
});
|
||||
const port = await listen(server);
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
const chatRequest = (body = '{"model":"m"}') => ({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${TOKEN}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
describe('verify-pr-model-proxy', () => {
|
||||
it('health endpoint echoes the nonce without touching the upstream', async () => {
|
||||
const upstream = await startUpstream();
|
||||
const proxy = await startProxy(upstream);
|
||||
const res = await fetch(`${proxy}/__health`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe(NONCE);
|
||||
expect(upstream.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a missing or wrong bearer token before the upstream', async () => {
|
||||
const upstream = await startUpstream();
|
||||
const proxy = await startProxy(upstream);
|
||||
for (const authorization of [undefined, 'Bearer wrong']) {
|
||||
const res = await fetch(`${proxy}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: authorization ? { authorization } : {},
|
||||
body: '{}',
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
}
|
||||
expect(upstream.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('allows only POST /chat/completions', async () => {
|
||||
const upstream = await startUpstream();
|
||||
const proxy = await startProxy(upstream);
|
||||
const get = await fetch(`${proxy}/v1/models`, {
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(get.status).toBe(403);
|
||||
const post = await fetch(`${proxy}/v1/models`, chatRequest());
|
||||
expect(post.status).toBe(403);
|
||||
// Exact path, not a suffix: this must not reach the upstream as
|
||||
// /v1/admin/chat/completions.
|
||||
const admin = await fetch(`${proxy}/admin/chat/completions`, chatRequest());
|
||||
expect(admin.status).toBe(403);
|
||||
// The method half matters on its own: a PUT to the allowed path would
|
||||
// otherwise spend the real key upstream.
|
||||
const put = await fetch(`${proxy}/chat/completions`, {
|
||||
...chatRequest(),
|
||||
method: 'PUT',
|
||||
});
|
||||
expect(put.status).toBe(403);
|
||||
expect(upstream.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('relays chat completions with the real key and the base-path prefix', async () => {
|
||||
const upstream = await startUpstream({
|
||||
chunks: ['data: a\n\n', 'data: b\n\n'],
|
||||
});
|
||||
const proxy = await startProxy(upstream);
|
||||
const res = await fetch(
|
||||
`${proxy}/chat/completions?stream=true`,
|
||||
chatRequest('{"model":"m","stream":true}'),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe('data: a\n\ndata: b\n\n');
|
||||
expect(upstream.requests).toHaveLength(1);
|
||||
const seen = upstream.requests[0];
|
||||
expect(seen.method).toBe('POST');
|
||||
// The proxy's base URL carries /v1; the client path does not repeat it.
|
||||
expect(seen.url).toBe('/v1/chat/completions?stream=true');
|
||||
expect(seen.authorization).toBe(`Bearer ${REAL_KEY}`);
|
||||
expect(seen.body).toBe('{"model":"m","stream":true}');
|
||||
});
|
||||
|
||||
it('does not double-prefix a client path that already carries the base path', async () => {
|
||||
const upstream = await startUpstream();
|
||||
const proxy = await startProxy(upstream);
|
||||
const res = await fetch(`${proxy}/v1/chat/completions`, chatRequest());
|
||||
expect(res.status).toBe(200);
|
||||
expect(upstream.requests[0].url).toBe('/v1/chat/completions');
|
||||
});
|
||||
|
||||
it('matches the exact path even without a base-path prefix', async () => {
|
||||
const upstream = await startUpstream();
|
||||
const proxy = await startProxy(upstream, {}, '');
|
||||
const admin = await fetch(`${proxy}/admin/chat/completions`, chatRequest());
|
||||
expect(admin.status).toBe(403);
|
||||
expect(upstream.requests).toHaveLength(0);
|
||||
const ok = await fetch(`${proxy}/chat/completions`, chatRequest());
|
||||
expect(ok.status).toBe(200);
|
||||
expect(upstream.requests[0].url).toBe('/chat/completions');
|
||||
});
|
||||
|
||||
it('strips the upstream content-encoding instead of forwarding it', async () => {
|
||||
const upstream = await startUpstream({
|
||||
gzipBody: true,
|
||||
chunks: ['data: gzip-ok\n\n'],
|
||||
});
|
||||
const proxy = await startProxy(upstream);
|
||||
const res = await fetch(`${proxy}/chat/completions`, chatRequest());
|
||||
expect(res.status).toBe(200);
|
||||
// The fixture must really have compressed; otherwise this test would
|
||||
// pass against an uncompressed upstream and pin nothing.
|
||||
expect(upstream.requests[0].sentContentEncoding).toBe('gzip');
|
||||
expect(res.headers.get('content-type')).toBe('text/event-stream');
|
||||
// undici already decoded the body; forwarding the header would make the
|
||||
// client gunzip plain text.
|
||||
expect(res.headers.get('content-encoding')).toBeNull();
|
||||
expect(await res.text()).toBe('data: gzip-ok\n\n');
|
||||
});
|
||||
|
||||
it('strips hop-by-hop headers and relays a chunked request body', async () => {
|
||||
const upstream = await startUpstream();
|
||||
const proxy = await startProxy(upstream);
|
||||
const { port } = new URL(proxy);
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${TOKEN}`,
|
||||
connection: ' x-client-hop , keep-alive ',
|
||||
'x-client-hop': 'client-secret',
|
||||
'x-keep-me': 'kept',
|
||||
'proxy-authorization': 'Basic client-proxy-secret',
|
||||
'transfer-encoding': 'chunked',
|
||||
expect: '100-continue',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on('end', () => resolve(res.statusCode));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write('{"model":');
|
||||
req.end('"m"}');
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const seen = upstream.requests[0];
|
||||
expect(seen.body).toBe('{"model":"m"}');
|
||||
expect(seen.authorization).toBe(`Bearer ${REAL_KEY}`);
|
||||
expect(seen.headers['proxy-authorization']).toBeUndefined();
|
||||
expect(seen.headers.expect).toBeUndefined();
|
||||
// The Connection-nominated name must not survive under its own spelling;
|
||||
// this is the assertion that goes red when the nominated-strip loop is
|
||||
// removed (the fixed-set strip alone would still drop `connection`).
|
||||
expect(seen.headers['x-client-hop']).toBeUndefined();
|
||||
// Passthrough in the positive direction: a custom header NOT nominated
|
||||
// by Connection, and the content type, must survive — the stripping is
|
||||
// selective, not a wholesale header drop.
|
||||
expect(seen.headers['x-keep-me']).toBe('kept');
|
||||
expect(seen.headers['content-type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('propagates an upstream error status and body', async () => {
|
||||
const upstream = await startUpstream({
|
||||
status: 429,
|
||||
chunks: ['slow down'],
|
||||
});
|
||||
const proxy = await startProxy(upstream);
|
||||
const res = await fetch(`${proxy}/chat/completions`, chatRequest());
|
||||
expect(res.status).toBe(429);
|
||||
expect(await res.text()).toBe('slow down');
|
||||
});
|
||||
|
||||
it('answers 504 when the upstream stalls past the idle window', async () => {
|
||||
const server = http.createServer(() => {
|
||||
// Never respond: the watchdog must fire.
|
||||
});
|
||||
const port = await listen(server);
|
||||
const proxy = await startProxy({ port }, { idleTimeoutMs: 50 });
|
||||
const res = await fetch(`${proxy}/chat/completions`, chatRequest());
|
||||
expect(res.status).toBe(504);
|
||||
});
|
||||
|
||||
it('answers 502 and stays alive when the upstream is unreachable', async () => {
|
||||
// Bind an ephemeral port and close it: the dial must really be refused.
|
||||
// Port 9 would never dial at all — it is on undici's blocked-port list,
|
||||
// so fetch rejects with `bad port` before a socket opens and the
|
||||
// connect-refused path this test names would go unexercised.
|
||||
const dead = http.createServer(() => {});
|
||||
const deadPort = await listen(dead);
|
||||
await new Promise((resolve) => {
|
||||
dead.closeAllConnections();
|
||||
dead.close(resolve);
|
||||
});
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const proxy = await startProxy({ port: deadPort }, {}, '');
|
||||
const res = await fetch(`${proxy}/chat/completions`, chatRequest());
|
||||
expect(res.status).toBe(502);
|
||||
const failure = logged.mock.calls
|
||||
.map((call) => call.find((arg) => arg instanceof Error))
|
||||
.find(Boolean);
|
||||
expect(failure?.cause?.code).toBe('ECONNREFUSED');
|
||||
const health = await fetch(`${proxy}/__health`);
|
||||
expect(health.status).toBe(200);
|
||||
} finally {
|
||||
logged.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// A slow request UPLOAD is activity: fetch resolves only on the upstream
|
||||
// headers, so a window meant for stream silence must not tear a large
|
||||
// prompt down mid-upload.
|
||||
it('keeps a slow request upload alive past the idle window', async () => {
|
||||
const upstream = await startUpstream();
|
||||
const proxy = await startProxy(upstream, { idleTimeoutMs: 300 });
|
||||
const { port } = new URL(proxy);
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${TOKEN}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on('end', () => resolve(res.statusCode));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
// Three chunks 150 ms apart: total 450 ms outlives the 300 ms window,
|
||||
// and no single gap exceeds it — only the per-chunk re-arm survives.
|
||||
req.write('{"model"');
|
||||
setTimeout(() => req.write(':"m"'), 150);
|
||||
setTimeout(() => req.write(',"stream":false}'), 300);
|
||||
setTimeout(() => req.end(), 450);
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(upstream.requests).toHaveLength(1);
|
||||
expect(upstream.requests[0].body).toBe('{"model":"m","stream":false}');
|
||||
});
|
||||
|
||||
it('aborts when headers arrive but no body follows', async () => {
|
||||
const upstream = await startUpstream({ headersOnly: true });
|
||||
const proxy = await startProxy(upstream, { idleTimeoutMs: 100 });
|
||||
// The upstream flushes headers, then stalls: the first-body idle window
|
||||
// must tear the stream down instead of letting it hang.
|
||||
await expect(
|
||||
fetch(`${proxy}/chat/completions`, chatRequest()),
|
||||
).rejects.toThrow();
|
||||
const health = await fetch(`${proxy}/__health`);
|
||||
expect(health.status).toBe(200);
|
||||
});
|
||||
|
||||
it('refuses to start without the full configuration', () => {
|
||||
expect(() =>
|
||||
createModelProxy({
|
||||
baseUrl: 'http://x',
|
||||
apiKey: '',
|
||||
token: 't',
|
||||
nonce: 'n',
|
||||
}),
|
||||
).toThrow('required');
|
||||
});
|
||||
|
||||
it('aborts the upstream when the client disconnects before headers', async () => {
|
||||
const upstream = await startUpstream({ delayMs: 100 });
|
||||
const proxy = await startProxy(upstream);
|
||||
const { port } = new URL(proxy);
|
||||
const req = http.request({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/chat/completions',
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
req.on('error', () => {});
|
||||
req.end('{}');
|
||||
// The end callback does not prove the proxy's upstream request exists yet;
|
||||
// wait for the mock to record it, then disconnect while headers are still
|
||||
// delayed so the pre-header abort is what the test exercises.
|
||||
await waitFor(() => upstream.requests.length === 1);
|
||||
req.destroy();
|
||||
await sleep(400);
|
||||
expect(upstream.requests).toHaveLength(1);
|
||||
// The upstream must never answer a client that is already gone: with the
|
||||
// abort registered only after the fetch, it writes headers and the whole
|
||||
// completion to a socket nobody reads.
|
||||
expect(upstream.events).toContain('aborted-before-headers');
|
||||
expect(upstream.events).not.toContain('write-head');
|
||||
});
|
||||
|
||||
it('aborts the upstream when the client disconnects after headers', async () => {
|
||||
const upstream = await startUpstream({
|
||||
chunks: ['data: first\n\n', 'data: second\n\n', 'data: third\n\n'],
|
||||
chunkDelayMs: 100,
|
||||
});
|
||||
const proxy = await startProxy(upstream, { idleTimeoutMs: 5000 });
|
||||
const { port } = new URL(proxy);
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/chat/completions',
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
},
|
||||
(res) => {
|
||||
res.once('data', () => {
|
||||
res.destroy();
|
||||
req.destroy();
|
||||
});
|
||||
res.on('error', () => {});
|
||||
},
|
||||
);
|
||||
req.on('error', () => {});
|
||||
req.end('{}');
|
||||
await waitFor(() => upstream.events.includes('aborted-mid-body'), 3000);
|
||||
expect(upstream.events).toContain('aborted-mid-body');
|
||||
});
|
||||
|
||||
it('does not abort a healthy stream while the downstream is paused', async () => {
|
||||
const chunks = Array.from(
|
||||
{ length: 128 },
|
||||
(_, index) => `${String(index).padStart(3, '0')}:${'x'.repeat(65_520)}\n`,
|
||||
);
|
||||
chunks.push('TAIL-MARKER');
|
||||
const upstream = await startUpstream({
|
||||
chunks,
|
||||
respectBackpressure: true,
|
||||
});
|
||||
// The pause (800 ms) must exceed the window (300 ms) or the pause guard
|
||||
// is never exercised; the margin keeps a loaded runner from flipping it.
|
||||
const proxy = await startProxy(upstream, { idleTimeoutMs: 300 });
|
||||
const { port } = new URL(proxy);
|
||||
const body = await new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/chat/completions',
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
},
|
||||
(res) => {
|
||||
const parts = [];
|
||||
res.on('data', (chunk) => parts.push(chunk));
|
||||
res.on('end', () => resolve(Buffer.concat(parts).toString('utf8')));
|
||||
res.on('error', reject);
|
||||
// Pause past the idle window while the upstream keeps producing:
|
||||
// a slow but live client must not be read as upstream idleness.
|
||||
res.pause();
|
||||
setTimeout(() => res.resume(), 800);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end('{}');
|
||||
});
|
||||
expect(body).toBe(chunks.join(''));
|
||||
});
|
||||
|
||||
it('keeps a slow-but-healthy stream alive past the window', async () => {
|
||||
const upstream = await startUpstream({
|
||||
chunks: [
|
||||
'data: 1\n\n',
|
||||
'data: 2\n\n',
|
||||
'data: 3\n\n',
|
||||
'data: 4\n\n',
|
||||
'data: 5\n\n',
|
||||
],
|
||||
chunkDelayMs: 80,
|
||||
});
|
||||
const proxy = await startProxy(upstream, { idleTimeoutMs: 300 });
|
||||
const res = await fetch(`${proxy}/chat/completions`, chatRequest());
|
||||
expect(res.status).toBe(200);
|
||||
// Total delivery (400 ms) outlives the 300 ms window while every gap
|
||||
// (80 ms) stays well inside it: only the per-chunk re-arm keeps this
|
||||
// from being cut, and the 220 ms margin survives a loaded runner.
|
||||
expect(await res.text()).toBe(
|
||||
'data: 1\n\ndata: 2\n\ndata: 3\n\ndata: 4\n\ndata: 5\n\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('survives an upstream reset after headers', async () => {
|
||||
const upstream = await startUpstream({
|
||||
chunks: ['data: partial\n\n'],
|
||||
resetAfterFirstChunk: true,
|
||||
});
|
||||
const child = spawn(process.execPath, [SCRIPT], {
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
REVIEW_OPENAI_BASE_URL: `http://127.0.0.1:${upstream.port}/v1`,
|
||||
REVIEW_OPENAI_API_KEY: REAL_KEY,
|
||||
},
|
||||
// The reset is the point of this test; the proxy's diagnostic for it
|
||||
// would only look like a failure in the suite output.
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
child.kill('SIGTERM');
|
||||
});
|
||||
const line = await new Promise((resolve, reject) => {
|
||||
let buffer = '';
|
||||
child.stdout.on('data', (data) => {
|
||||
buffer += data;
|
||||
if (buffer.includes('\n')) resolve(buffer.split('\n')[0]);
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) =>
|
||||
reject(new Error(`proxy exited before printing: ${code}`)),
|
||||
);
|
||||
});
|
||||
const info = JSON.parse(line);
|
||||
// The CLI-generated token is the container's only credential.
|
||||
expect(info.token).toMatch(/^[0-9a-f]{48}$/);
|
||||
const res = await fetch(`http://127.0.0.1:${info.port}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${info.token}` },
|
||||
body: '{}',
|
||||
}).catch(() => null);
|
||||
if (res) await res.text().catch(() => {});
|
||||
await waitFor(() => upstream.events.includes('upstream-reset'), 2000);
|
||||
await sleep(200);
|
||||
// The client may see a torn stream; the proxy process must not die on it.
|
||||
expect(child.exitCode).toBeNull();
|
||||
expect(upstream.events).toContain('upstream-reset');
|
||||
const health = await fetch(`http://127.0.0.1:${info.port}/__health`);
|
||||
expect(health.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy defaults and base url formatting', () => {
|
||||
it('keeps the idle window at the client default', () => {
|
||||
// Read the core constant from its source instead of restating it: two
|
||||
// hardcoded copies can drift with the suite green. A tighter proxy would
|
||||
// cut streams its own client still tolerates.
|
||||
const constants = readFileSync(
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
'../../packages/core/src/core/openaiContentGenerator/constants.ts',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const match = /DEFAULT_STREAM_IDLE_TIMEOUT_MS\s*=\s*(\d+)/.exec(constants);
|
||||
expect(match).not.toBeNull();
|
||||
expect(DEFAULT_IDLE_TIMEOUT_MS).toBe(Number(match[1]));
|
||||
});
|
||||
|
||||
it('brackets an IPv6 listen host', () => {
|
||||
expect(formatLocalBaseUrl('::1', 8080, '/v1')).toBe('http://[::1]:8080/v1');
|
||||
});
|
||||
|
||||
it('leaves an IPv4 host alone and tolerates an empty base path', () => {
|
||||
expect(formatLocalBaseUrl('192.0.2.10', 8080)).toBe(
|
||||
'http://192.0.2.10:8080',
|
||||
);
|
||||
expect(formatLocalBaseUrl('192.0.2.10', 8080, '/v1')).toBe(
|
||||
'http://192.0.2.10:8080/v1',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes base paths', () => {
|
||||
expect(normalizeBasePath('/v1/')).toBe('/v1');
|
||||
expect(normalizeBasePath('/v1///')).toBe('/v1');
|
||||
expect(normalizeBasePath('/')).toBe('');
|
||||
expect(normalizeBasePath('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseArgs', () => {
|
||||
it('defaults to loopback with an ephemeral port', () => {
|
||||
expect(parseArgs([])).toEqual({ listen: '127.0.0.1' });
|
||||
});
|
||||
|
||||
it('parses --listen', () => {
|
||||
expect(parseArgs(['--listen', '172.17.0.1'])).toEqual({
|
||||
listen: '172.17.0.1',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects --port, which the recipe does not use', () => {
|
||||
expect(() => parseArgs(['--port', '8080'])).toThrow('unknown argument');
|
||||
});
|
||||
|
||||
it('rejects unknown arguments and missing values', () => {
|
||||
expect(() => parseArgs(['--docker'])).toThrow('unknown argument');
|
||||
expect(() => parseArgs(['--listen'])).toThrow('requires a value');
|
||||
});
|
||||
|
||||
// server.listen(port, '') binds EVERY interface — past the bridge a
|
||||
// key-holding proxy is meant to stay on — and a flag-looking value is a
|
||||
// missing value, not a host.
|
||||
it('rejects an empty or flag-looking --listen value', () => {
|
||||
expect(() => parseArgs(['--listen', ''])).toThrow('requires a value');
|
||||
expect(() => parseArgs(['--listen', '--other'])).toThrow(
|
||||
'requires a value',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI', () => {
|
||||
it('exits 2 when the upstream configuration is missing', () => {
|
||||
const res = spawnSync(process.execPath, [SCRIPT], {
|
||||
env: { PATH: process.env.PATH },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(res.status).toBe(2);
|
||||
expect(res.stderr).toContain('REVIEW_OPENAI_BASE_URL');
|
||||
});
|
||||
|
||||
it('prints the listen coordinates as JSON and honors the token override', async () => {
|
||||
const upstream = await startUpstream();
|
||||
const child = spawn(process.execPath, [SCRIPT, '--listen', '0.0.0.0'], {
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
REVIEW_OPENAI_BASE_URL: `http://127.0.0.1:${upstream.port}/v1`,
|
||||
REVIEW_OPENAI_API_KEY: REAL_KEY,
|
||||
QWEN_VERIFY_PROXY_TOKEN: 'fixed-token',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
child.kill('SIGTERM');
|
||||
});
|
||||
const line = await new Promise((resolve, reject) => {
|
||||
let buffer = '';
|
||||
child.stdout.on('data', (data) => {
|
||||
buffer += data;
|
||||
if (buffer.includes('\n')) resolve(buffer.split('\n')[0]);
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) =>
|
||||
reject(new Error(`proxy exited before printing: ${code}`)),
|
||||
);
|
||||
});
|
||||
const info = JSON.parse(line);
|
||||
expect(info.token).toBe('fixed-token');
|
||||
// The listen value must reach the printed URL; hard-coding loopback here
|
||||
// would hand the container an address it cannot dial.
|
||||
expect(info.baseUrl).toBe(`http://0.0.0.0:${info.port}/v1`);
|
||||
expect(info.nonce).toMatch(/^[0-9a-f]{48}$/);
|
||||
// The printed baseUrl must not leak the real key, and the health
|
||||
// endpoint must echo the printed nonce.
|
||||
expect(line).not.toContain(REAL_KEY);
|
||||
const health = await fetch(`http://127.0.0.1:${info.port}/__health`);
|
||||
expect(await health.text()).toBe(info.nonce);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The recipe is the only place where the trust delegation lives, and a
|
||||
* pleasing-looking revert (relative path, attestation on the run line) is
|
||||
* invisible to the runtime tests. Pin the load-bearing lines.
|
||||
*/
|
||||
describe('local container recipe pins', () => {
|
||||
const skill = readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../../.qwen/skills/verify-pr/SKILL.md', import.meta.url),
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
it('runs every round script from the trusted checkout', () => {
|
||||
expect(skill).toContain(
|
||||
'cd <pr-clone> && node <trusted-clone>/scripts/verify-pr-prepare.mjs',
|
||||
);
|
||||
expect(skill).toContain(
|
||||
'node /trusted-scripts/verify-pr-prepare.mjs --require-isolation',
|
||||
);
|
||||
expect(skill).toContain(
|
||||
'node /trusted-scripts/verify-pr-credential-scan.mjs /work',
|
||||
);
|
||||
expect(skill).toContain(
|
||||
'node <trusted-clone>/scripts/verify-pr-model-proxy.mjs',
|
||||
);
|
||||
expect(skill).toContain('<trusted-clone>/scripts/verify-pr-guard.mjs');
|
||||
expect(skill).toContain(
|
||||
'`<clone>` below is the trusted checkout the watch scripts run from',
|
||||
);
|
||||
// Enumerate the checker call sites: the preamble also names the trusted
|
||||
// path, so a single positive assertion cannot detect step 7 reverting to
|
||||
// <pr-clone>/scripts.
|
||||
const checkerCalls = [
|
||||
...skill.matchAll(/node (\S+)\/scripts\/verify-pr-check\.mjs/g),
|
||||
].map((match) => match[1]);
|
||||
expect(checkerCalls.length).toBeGreaterThanOrEqual(3);
|
||||
for (const prefix of checkerCalls) expect(prefix).toBe('<trusted-clone>');
|
||||
expect(skill).not.toMatch(
|
||||
/node <pr-clone>\/scripts\/verify-pr-(prepare|check|model-proxy|guard)/,
|
||||
);
|
||||
expect(skill).not.toMatch(
|
||||
/node scripts\/verify-pr-(prepare|check|model-proxy|guard)\.mjs/,
|
||||
);
|
||||
});
|
||||
|
||||
it('base-pins `.qwen` and states that root AGENTS/QWEN.md still come from the PR', () => {
|
||||
expect(skill).toContain('git -C <pr-clone> archive "$BASE_OID" -- .qwen');
|
||||
expect(skill).toContain(
|
||||
'-v ~/.cache/qwen-verify/pr<n>/qwen-base/.qwen:/work/.qwen:ro',
|
||||
);
|
||||
expect(skill).toContain('`AGENTS.md`/`QWEN.md` do come from the PR clone');
|
||||
});
|
||||
|
||||
it('mounts the round tree writable and the metadata snapshot read-only', () => {
|
||||
expect(skill).toContain('--user "$(id -u):$(id -g)"');
|
||||
expect(skill).toContain('-v <pr-clone>:/work');
|
||||
// toContain alone is satisfied by `:/work:ro` — the exact mutation this
|
||||
// pin exists to catch — so the read-only spelling is excluded outright.
|
||||
expect(skill).not.toContain('-v <pr-clone>:/work:ro');
|
||||
expect(skill).toContain('-v <trusted-clone>/scripts:/trusted-scripts:ro');
|
||||
expect(skill).toContain('-v ~/.cache/qwen-verify/pr<n>:/verify-context:ro');
|
||||
expect(skill).toContain('QWEN_VERIFY_CONTEXT=/verify-context/pr.json');
|
||||
});
|
||||
|
||||
it('reads both credentials from files, including without a TTY', () => {
|
||||
expect(skill).toContain('if [ -t 0 ]; then');
|
||||
expect(skill).toContain('REVIEW_OPENAI_KEY_FILE');
|
||||
// `read` returns 1 at EOF even when it assigned the variable: a key file
|
||||
// without a trailing newline must be judged by the variable, not the
|
||||
// exit status, or the documented agent path aborts with the opposite
|
||||
// diagnosis.
|
||||
expect(skill).toContain('[ -n "$REVIEW_OPENAI_API_KEY" ]');
|
||||
expect(skill).not.toContain('REVIEW_OPENAI_API_KEY=<real-key>');
|
||||
// The proxy owns its copy: later diagnostics must not see the key.
|
||||
expect(skill).toContain('unset REVIEW_OPENAI_API_KEY');
|
||||
expect(skill).toContain('QWEN_VERIFY_PROXY_TOKEN="$(node -e');
|
||||
expect(skill).toContain('chmod 600');
|
||||
// The background process truncates proxy.json before printing; the loop
|
||||
// and the non-empty check keep an empty read from reaching Docker.
|
||||
expect(skill).toContain('for _ in {1..100}; do');
|
||||
expect(skill).not.toContain('$(seq 1 100)');
|
||||
expect(skill).toContain('test -n "$QWEN_VERIFY_PROXY_TOKEN"');
|
||||
// The nonce, not just the JSON shape, is checked before use.
|
||||
expect(skill).toContain('/__health');
|
||||
expect(skill).toContain('fetch(`http://${u.hostname}:${u.port}/__health`)');
|
||||
expect(skill).toContain('process.exit(t===j.nonce?0:1)');
|
||||
// Each step is its own shell: the token is re-derived where it is used.
|
||||
const dockerRun =
|
||||
skill.match(/export QWEN_VERIFY_PROXY_TOKEN[\s\S]*?```/)?.[0] ?? '';
|
||||
expect(dockerRun).toContain('proxy.json');
|
||||
expect(dockerRun).toContain('test -n "$QWEN_VERIFY_PROXY_TOKEN"');
|
||||
expect(skill).toMatch(/-e QWEN_VERIFY_PROXY_TOKEN[ \t]*\\?\n/);
|
||||
expect(skill).not.toContain('-e QWEN_VERIFY_PROXY_TOKEN=<token>');
|
||||
expect(skill).not.toContain('--port');
|
||||
});
|
||||
|
||||
it('keeps the attestation off the docker run line and on the round shell', () => {
|
||||
const dockerRunMatch = skill.match(/docker run -d[\s\S]*?```/);
|
||||
expect(dockerRunMatch).not.toBeNull();
|
||||
expect(dockerRunMatch[0]).not.toContain('QWEN_VERIFY_CREDENTIAL_FREE');
|
||||
// A fresh docker exec is a new shell: the round's shell must carry the
|
||||
// attestation or its first isolation check exits 4.
|
||||
expect(skill).toContain(
|
||||
'docker exec -i -e QWEN_VERIFY_CREDENTIAL_FREE=1 qwen-verify-<n>',
|
||||
);
|
||||
const roundBlock = skill.split('qwen --auth-type openai')[0].split('```');
|
||||
expect(roundBlock.at(-1)).toContain('QWEN_VERIFY_CREDENTIAL_FREE');
|
||||
// The credential hit aborts structurally, not via `set -e`.
|
||||
expect(skill).toContain(
|
||||
'node /trusted-scripts/verify-pr-credential-scan.mjs /work || exit 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('names the released CLI as the engine and gives the tree-build route', () => {
|
||||
expect(skill).toContain("The round's engine is the released CLI");
|
||||
expect(skill).toContain('npm run build && npm run bundle');
|
||||
expect(skill).toContain('npm install -g --prefix "$HOME/.local" /work');
|
||||
});
|
||||
|
||||
it('refuses a nested repository in the scan description', () => {
|
||||
expect(skill).toMatch(/nested\s+git repository outright/);
|
||||
expect(skill).toMatch(/vendored\s+git\s+dependency/);
|
||||
expect(skill).toContain('not public keys');
|
||||
expect(skill).toContain('untracked clone, populated submodule');
|
||||
});
|
||||
|
||||
it('names the value-inspection contract and the include-following enumeration', () => {
|
||||
expect(skill).toContain('inspects every local config');
|
||||
expect(skill).toContain('`http.proxy` included');
|
||||
expect(skill).toContain('`url.*.pushInsteadOf`');
|
||||
expect(skill).toContain('`Authorization` /');
|
||||
expect(skill).toContain('`Proxy-Authorization` header values');
|
||||
expect(skill).toContain('bare `git@` account name');
|
||||
expect(skill).toMatch(/enumeration\s+follows\s+`include\.path`/);
|
||||
expect(skill).toContain('covers the enabled `.git/config.worktree` scope');
|
||||
expect(skill).toContain('absent means an empty scope');
|
||||
expect(skill).toMatch(/filter name\s+containing `=` is refused outright/);
|
||||
});
|
||||
|
||||
it('snapshots the previous report and pins both checker call sites', () => {
|
||||
expect(skill).toContain('previous-report.md');
|
||||
expect(skill).toContain('issues/<n>/comments');
|
||||
const contextCalls = [
|
||||
...skill.matchAll(/verify-pr-check\.mjs --context <host snapshot>/g),
|
||||
];
|
||||
expect(contextCalls.length).toBeGreaterThanOrEqual(2);
|
||||
// The prose occurrences alone cannot catch step 7 dropping the flag on
|
||||
// its continuation line — pin the executable call too.
|
||||
expect(skill).toContain(
|
||||
'--context ~/.cache/qwen-verify/pr<n>/pr.json <round-dir>',
|
||||
);
|
||||
});
|
||||
|
||||
it('writes previous-report.md only when a report exists', () => {
|
||||
// The file's PRESENCE is the follow-up trigger: an empty file from a
|
||||
// first round (or gh error text) misclassifies every round.
|
||||
expect(skill).toContain('if [ -n "$PREV" ]; then');
|
||||
expect(skill).toContain(
|
||||
'else rm -f ~/.cache/qwen-verify/pr<n>/previous-report.md; fi',
|
||||
);
|
||||
expect(skill).toContain(
|
||||
'{ echo "could not read PR comments" >&2; exit 1; }',
|
||||
);
|
||||
// First-paragraph marker + bot-or-self author, not a bare substring any
|
||||
// reply quoting `Verdict:` would satisfy. gh's --slurp cannot combine
|
||||
// with --jq, so the selection runs in node over the piped pages.
|
||||
expect(skill).toContain('c.body.split("\\n\\n")[0].includes("Verdict:")');
|
||||
expect(skill).toContain(
|
||||
'/bot$/.test(c.user.login)||c.user.login===process.argv[1]',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the proxy JSON parseable and the base extraction fresh', () => {
|
||||
// One stderr line folded into proxy.json breaks every parse of it.
|
||||
expect(skill).toContain(
|
||||
'> ~/.cache/qwen-verify/pr<n>/proxy.json 2> ~/.cache/qwen-verify/pr<n>/proxy.err',
|
||||
);
|
||||
// Add-only extraction would keep a .qwen file the base deleted.
|
||||
expect(skill).toContain('rm -rf ~/.cache/qwen-verify/pr<n>/qwen-base');
|
||||
});
|
||||
|
||||
it('hashes the base-pinned skill copy in the publish footer', () => {
|
||||
// The round loads the base commit's .qwen, not the PR head's: hashing
|
||||
// the head would certify instructions the round never ran.
|
||||
expect(skill).toContain(
|
||||
'git -C <pr-clone> rev-parse "<baseRefOid>:.qwen/skills/verify-pr/SKILL.md"',
|
||||
);
|
||||
expect(skill).toContain("never the PR head's copy");
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,12 @@ import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
GIT_HARDENING,
|
||||
artifactDirName,
|
||||
detectCredentialSignals,
|
||||
detectIsolation,
|
||||
gitStatusArgs,
|
||||
localFilterOverrides,
|
||||
parseArgs,
|
||||
parseRepo,
|
||||
validateContext,
|
||||
@@ -213,6 +216,264 @@ describe('detectCredentialSignals', () => {
|
||||
'/home/t',
|
||||
),
|
||||
).toEqual(['SANDBOX_MOUNTS']);
|
||||
// The recipe's proxy credential must stay outside the signal set, or a
|
||||
// container round would fail the gate before it starts.
|
||||
expect(
|
||||
detectCredentialSignals(
|
||||
{ QWEN_VERIFY_PROXY_TOKEN: 'x' },
|
||||
() => false,
|
||||
'/home/t',
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('git hardening', () => {
|
||||
it('disables fsmonitor, hooks, credential helpers and excludesFile on every git read', () => {
|
||||
expect(GIT_HARDENING).toEqual([
|
||||
'-c',
|
||||
'core.fsmonitor=false',
|
||||
'-c',
|
||||
'core.hooksPath=/dev/null',
|
||||
'-c',
|
||||
'credential.helper=',
|
||||
'-c',
|
||||
'core.excludesFile=/dev/null',
|
||||
]);
|
||||
const { dir } = makeRepo();
|
||||
expect(localFilterOverrides(dir)).toEqual([]);
|
||||
expect(gitStatusArgs(dir)).toEqual([
|
||||
...GIT_HARDENING,
|
||||
'-C',
|
||||
dir,
|
||||
'status',
|
||||
'--porcelain',
|
||||
'--untracked-files=normal',
|
||||
]);
|
||||
});
|
||||
|
||||
// A filter VALUE may contain newlines; parsing the rendered `key value`
|
||||
// output would accept the second line as a key and forge a `-c` that
|
||||
// outranks the hardening (git applies the last -c for a key).
|
||||
it('does not let a newline-bearing filter value forge overrides', () => {
|
||||
const { dir } = makeRepo();
|
||||
const hooks = join(dir, '.githooks');
|
||||
mkdirSync(hooks, { recursive: true });
|
||||
const marker = join(dir, 'hook-fired');
|
||||
writeFileSync(
|
||||
join(hooks, 'post-checkout'),
|
||||
`#!/bin/sh\necho fired > ${marker}\n`,
|
||||
);
|
||||
chmodSync(join(hooks, 'post-checkout'), 0o755);
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'filter.decoy.clean',
|
||||
`harmless\ncore.hooksPath=${hooks}`,
|
||||
],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
mkdirSync(join(dir, '.git', 'info'), { recursive: true });
|
||||
writeFileSync(join(dir, '.git', 'info', 'attributes'), '* filter=decoy\n');
|
||||
expect(localFilterOverrides(dir)).toEqual(['-c', 'filter.decoy.clean=']);
|
||||
// The forged hooksPath must not reach git: the name list never contained
|
||||
// it, and the fixed hardening is applied last in any case.
|
||||
const args = gitStatusArgs(dir);
|
||||
expect(args.filter((a) => a === 'core.hooksPath=/dev/null')).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(args.join(' ')).not.toContain(hooks);
|
||||
spawnSync('git', args, { encoding: 'utf8' });
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
});
|
||||
|
||||
// git allows '=' inside a filter subsection (filter.a=b.clean), but -c
|
||||
// splits its argument at the FIRST '=': the override would set a different
|
||||
// key and leave the real filter armed. Such a name must be refused before
|
||||
// any guarded status could run it, not emitted as a broken override.
|
||||
it('refuses a filter name containing = instead of emitting a broken override', () => {
|
||||
const { dir } = makeRepo();
|
||||
const marker = join(dir, 'filter-fired');
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'filter.a=b.clean',
|
||||
`sh -c 'echo fired > ${marker} && cat'`,
|
||||
],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
mkdirSync(join(dir, '.git', 'info'), { recursive: true });
|
||||
writeFileSync(join(dir, '.git', 'info', 'attributes'), '* filter=a=b\n');
|
||||
// Same-byte-length change: a size change would let git skip the clean
|
||||
// conversion and the positive control would prove nothing.
|
||||
writeFileSync(join(dir, 'base.txt'), 'BaSe\n');
|
||||
spawnSync('git', [...GIT_HARDENING, '-C', dir, 'status', '--porcelain'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(existsSync(marker)).toBe(true);
|
||||
rmSync(marker);
|
||||
expect(() => localFilterOverrides(dir)).toThrow(
|
||||
/cannot be safely disabled/,
|
||||
);
|
||||
expect(() => gitStatusArgs(dir)).toThrow(/cannot be safely disabled/);
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
});
|
||||
|
||||
// extensions.worktreeConfig makes git status read .git/config.worktree,
|
||||
// which --local never reads; a filter planted there must be emptied too.
|
||||
it('empties a filter planted in .git/config.worktree', () => {
|
||||
const { dir } = makeRepo();
|
||||
const marker = join(dir, 'filter-fired');
|
||||
execFileSync(
|
||||
'git',
|
||||
['config', '--local', 'extensions.worktreeConfig', 'true'],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--worktree',
|
||||
'filter.wt.clean',
|
||||
`sh -c 'echo fired > ${marker} && cat'`,
|
||||
],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
mkdirSync(join(dir, '.git', 'info'), { recursive: true });
|
||||
writeFileSync(join(dir, '.git', 'info', 'attributes'), '* filter=wt\n');
|
||||
// Same byte length as 'base\n': a size change lets git skip the clean
|
||||
// conversion, which makes the positive control depend on racy timing.
|
||||
writeFileSync(join(dir, 'base.txt'), 'BaSe\n');
|
||||
// Positive control: without the worktree-scope override the filter runs.
|
||||
spawnSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'core.fsmonitor=false',
|
||||
'-c',
|
||||
'core.hooksPath=/dev/null',
|
||||
'-C',
|
||||
dir,
|
||||
'status',
|
||||
'--porcelain',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
expect(existsSync(marker)).toBe(true);
|
||||
rmSync(marker);
|
||||
expect(localFilterOverrides(dir)).toEqual(['-c', 'filter.wt.clean=']);
|
||||
spawnSync('git', gitStatusArgs(dir), { encoding: 'utf8' });
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
});
|
||||
|
||||
// The extension does not require config.worktree to exist. prepare's
|
||||
// --get-regexp enumeration already treats the absent file as no matches
|
||||
// (exit 1); pin that so a future switch to --list (exit 128 on absence)
|
||||
// cannot silently false-refuse a clean repo the way the scan once did.
|
||||
it('tolerates an enabled worktree extension with no config.worktree file', () => {
|
||||
const { dir, base, head } = makeRepo();
|
||||
execFileSync(
|
||||
'git',
|
||||
['config', '--local', 'extensions.worktreeConfig', 'true'],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
expect(localFilterOverrides(dir)).toEqual([]);
|
||||
const status = spawnSync('git', gitStatusArgs(dir), { encoding: 'utf8' });
|
||||
expect(status.status).toBe(0);
|
||||
|
||||
const env = installFakeGh(snapshotFor({ base, head }));
|
||||
const result = runPrepare(dir, ['42', '--repo', REPO], env);
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
// core.excludesFile is steerable from the same PR-controlled config; the
|
||||
// hardened status must see untracked files even when it points at a
|
||||
// catch-all exclude file.
|
||||
it('sees untracked files through a hostile core.excludesFile', () => {
|
||||
const { dir } = makeRepo();
|
||||
writeFileSync(join(dir, '.git', 'info', 'leave'), '*\n');
|
||||
execFileSync(
|
||||
'git',
|
||||
['config', '--local', 'core.excludesFile', '.git/info/leave'],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
writeFileSync(join(dir, 'leftover-modified.ts'), 'export {};\n');
|
||||
const steered = spawnSync(
|
||||
'git',
|
||||
['-C', dir, 'status', '--porcelain', '--untracked-files=normal'],
|
||||
{ encoding: 'utf8', env: gitEnv },
|
||||
);
|
||||
expect(steered.stdout.trim()).toBe('');
|
||||
const hardened = spawnSync('git', gitStatusArgs(dir), {
|
||||
encoding: 'utf8',
|
||||
env: gitEnv,
|
||||
});
|
||||
expect(hardened.stdout).toContain('?? leftover-modified.ts');
|
||||
});
|
||||
|
||||
it('empties a planted clean filter before reading status', () => {
|
||||
const { dir } = makeRepo();
|
||||
const marker = join(dir, 'filter-fired');
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'filter.pwn.clean',
|
||||
`sh -c 'echo fired > ${marker}'`,
|
||||
],
|
||||
{ cwd: dir, env: gitEnv },
|
||||
);
|
||||
mkdirSync(join(dir, '.git', 'info'), { recursive: true });
|
||||
writeFileSync(join(dir, '.git', 'info', 'attributes'), '* filter=pwn\n');
|
||||
// Same byte length as 'base\n': a size change lets git skip the clean
|
||||
// conversion, which makes the positive control depend on racy timing.
|
||||
writeFileSync(join(dir, 'base.txt'), 'BaSe\n');
|
||||
// Positive control: without the filter overrides the planted filter runs
|
||||
// on the host during an ordinary status.
|
||||
spawnSync('git', [...GIT_HARDENING, '-C', dir, 'status', '--porcelain'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(existsSync(marker)).toBe(true);
|
||||
rmSync(marker);
|
||||
expect(localFilterOverrides(dir)).toEqual(['-c', 'filter.pwn.clean=']);
|
||||
spawnSync('git', gitStatusArgs(dir), { encoding: 'utf8' });
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
});
|
||||
|
||||
// --local alone names one file and skips include.path, while the config
|
||||
// parsing `git status` uses follows it: a filter defined in an included
|
||||
// worktree file must be enumerated too, or it runs on the host unopposed.
|
||||
it('empties a clean filter that arrives through include.path', () => {
|
||||
const { dir } = makeRepo();
|
||||
const marker = join(dir, 'filter-fired');
|
||||
writeFileSync(
|
||||
join(dir, 'evil.inc'),
|
||||
`[filter "pwn"]\n\tclean = sh -c 'echo fired > ${marker}'\n`,
|
||||
);
|
||||
execFileSync('git', ['config', '--local', 'include.path', '../evil.inc'], {
|
||||
cwd: dir,
|
||||
env: gitEnv,
|
||||
});
|
||||
mkdirSync(join(dir, '.git', 'info'), { recursive: true });
|
||||
writeFileSync(join(dir, '.git', 'info', 'attributes'), '* filter=pwn\n');
|
||||
// Same byte length as 'base\n': a size change lets git skip the clean
|
||||
// conversion, which makes the positive control depend on racy timing.
|
||||
writeFileSync(join(dir, 'base.txt'), 'BaSe\n');
|
||||
// Positive control: an ordinary hardened status follows the include and
|
||||
// executes the planted filter on the host.
|
||||
spawnSync('git', [...GIT_HARDENING, '-C', dir, 'status', '--porcelain'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(existsSync(marker)).toBe(true);
|
||||
rmSync(marker);
|
||||
expect(localFilterOverrides(dir)).toEqual(['-c', 'filter.pwn.clean=']);
|
||||
spawnSync('git', gitStatusArgs(dir), { encoding: 'utf8' });
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -545,6 +806,49 @@ describe.skipIf(process.platform === 'win32')('prepare (CLI)', () => {
|
||||
expect(existsSync(join(realpathSync(dir), 'tmp'))).toBe(false);
|
||||
});
|
||||
|
||||
// The refusal must reach the CLI as a clean exit 3 with its own remedy,
|
||||
// before any guarded status runs — not as an uncaught enumeration stack.
|
||||
it('refuses a tree whose filter name cannot be safely disabled', () => {
|
||||
const { dir, base, head } = makeRepo();
|
||||
const env = installFakeGh(snapshotFor({ base, head }));
|
||||
execFileSync('git', ['config', '--local', 'filter.a=b.clean', 'true'], {
|
||||
cwd: dir,
|
||||
env: gitEnv,
|
||||
});
|
||||
|
||||
const result = runPrepare(dir, ['42', '--repo', REPO], env);
|
||||
expect(result.status).toBe(3);
|
||||
expect(result.stderr).toContain("contains '='");
|
||||
expect(result.stderr).toContain('cannot be safely disabled');
|
||||
expect(existsSync(join(realpathSync(dir), 'tmp'))).toBe(false);
|
||||
});
|
||||
|
||||
// .git/info/exclude has no -c override: active patterns there could hide
|
||||
// untracked files from the clean-tree check, so the gate refuses the file
|
||||
// itself. A fresh clone carries only git's commented default.
|
||||
it('refuses a .git/info/exclude with active patterns', () => {
|
||||
const { dir, base, head } = makeRepo();
|
||||
const env = installFakeGh(snapshotFor({ base, head }));
|
||||
mkdirSync(join(dir, '.git', 'info'), { recursive: true });
|
||||
writeFileSync(join(dir, '.git', 'info', 'exclude'), '*\n');
|
||||
writeFileSync(join(dir, 'hidden.ts'), 'export {};\n');
|
||||
|
||||
const refused = runPrepare(dir, ['42', '--repo', REPO], env);
|
||||
expect(refused.status).toBe(3);
|
||||
expect(refused.stderr).toContain('.git/info/exclude');
|
||||
expect(refused.stderr).toContain('re-clone');
|
||||
expect(existsSync(join(realpathSync(dir), 'tmp'))).toBe(false);
|
||||
|
||||
// The commented default a fresh clone ships with must pass.
|
||||
writeFileSync(
|
||||
join(dir, '.git', 'info', 'exclude'),
|
||||
'# git ls-files --others --exclude-standard\n# *.[oa]\n',
|
||||
);
|
||||
rmSync(join(dir, 'hidden.ts'));
|
||||
const clean = runPrepare(dir, ['42', '--repo', REPO], env);
|
||||
expect(clean.status).toBe(0);
|
||||
});
|
||||
|
||||
// git status trusts the index: skip-worktree / assume-unchanged entries
|
||||
// (a sparse checkout sets them en masse) hide tracked divergence, so the
|
||||
// gate must refuse them instead of stamping a partial tree as clean.
|
||||
@@ -608,6 +912,16 @@ describe.skipIf(process.platform === 'win32')('prepare (CLI)', () => {
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stderr).toContain('--repo <owner>/<repo> is required');
|
||||
});
|
||||
|
||||
// Outside a worktree the filter enumeration fails before any git read;
|
||||
// the refusal must stay the clean exit 3, not an uncaught stack trace.
|
||||
it('refuses a non-git cwd with the worktree message, not a stack trace', () => {
|
||||
const dir = makeTmpDir();
|
||||
const result = runPrepare(dir, ['42', '--repo', REPO], gitEnv);
|
||||
expect(result.status).toBe(3);
|
||||
expect(result.stderr).toContain('not inside a git worktree');
|
||||
expect(result.stderr).not.toContain('Error: could not enumerate');
|
||||
});
|
||||
});
|
||||
|
||||
// The gate requires an empty `git status`, and prepare writes its artifacts
|
||||
|
||||
+63
-18
@@ -56,7 +56,7 @@ const HEADING_TITLE_MAX = 120;
|
||||
const FOOTER =
|
||||
/^_— .+ via Qwen Code \/verify-pr \(v[^;]+; skill [0-9a-f]{12}\)_$/;
|
||||
|
||||
const HELP = `Usage: node scripts/verify-pr-check.mjs <artifact-dir>
|
||||
const HELP = `Usage: node scripts/verify-pr-check.mjs [--context <snapshot>] <artifact-dir>
|
||||
|
||||
Checks a local round against the rules the CI publisher enforces:
|
||||
|
||||
@@ -80,6 +80,10 @@ ${SUITE_SIZED_TOTAL} assertions come with no gate results (an existing test suit
|
||||
counted as the round's own), or when comment.md — the exact body a local round
|
||||
posts, optional — repeats the heading shape or lacks the attribution footer.
|
||||
|
||||
With --context <snapshot>, the head-OID check reads that file instead of the
|
||||
round's own context.json. Pass a copy the round never saw: the artifact
|
||||
directory is writable by the PR code the round executed.
|
||||
|
||||
Exit codes: 0 no errors, 1 errors found, 2 usage.
|
||||
`;
|
||||
|
||||
@@ -416,10 +420,29 @@ function main(argv) {
|
||||
process.stdout.write(HELP);
|
||||
return 0;
|
||||
}
|
||||
const dir = argv.find((arg) => !arg.startsWith('-'));
|
||||
if (!dir) {
|
||||
let contextPath = null;
|
||||
const positional = [];
|
||||
for (let index = 0; index < argv.length; index++) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--context') {
|
||||
contextPath = argv[++index];
|
||||
if (!contextPath) {
|
||||
process.stderr.write(
|
||||
`verify-pr check: --context requires a snapshot path\n\n${HELP}`,
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
} else if (arg.startsWith('-')) {
|
||||
process.stderr.write(`verify-pr check: unknown option ${arg}\n\n${HELP}`);
|
||||
return 2;
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
}
|
||||
const dir = positional[0];
|
||||
if (!dir || positional.length > 1) {
|
||||
process.stderr.write(
|
||||
`verify-pr check: an artifact directory is required\n\n${HELP}`,
|
||||
`verify-pr check: exactly one artifact directory is required\n\n${HELP}`,
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
@@ -448,25 +471,47 @@ function main(argv) {
|
||||
// rather than silently downgrading the check.
|
||||
let expectedHead = null;
|
||||
let snapshotWarning = null;
|
||||
let snapshotPath = join(dir, 'context.json');
|
||||
const prNumber = /^pr(\d+)-verify-/.exec(basename(resolve(dir)))?.[1];
|
||||
if (!existsSync(snapshotPath) && prNumber) {
|
||||
snapshotPath = join(
|
||||
dirname(resolve(dir)),
|
||||
'verify-context',
|
||||
`pr${prNumber}.json`,
|
||||
);
|
||||
}
|
||||
if (existsSync(snapshotPath)) {
|
||||
let snapshotPath = null;
|
||||
if (contextPath) {
|
||||
// A host-held snapshot: the artifact directory is writable by the PR code
|
||||
// the round just executed, so its own context.json cannot pin the head
|
||||
// when the maintainer supplies a copy that code never saw.
|
||||
try {
|
||||
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
||||
const snapshot = JSON.parse(readFileSync(contextPath, 'utf8'));
|
||||
if (/^[0-9a-f]{40}$/.test(snapshot?.headRefOid ?? '')) {
|
||||
expectedHead = snapshot.headRefOid;
|
||||
} else {
|
||||
snapshotWarning = `snapshot ${snapshotPath} has no valid head OID; the head-OID check fell back to presence-only`;
|
||||
snapshotPath = contextPath;
|
||||
}
|
||||
} catch {
|
||||
snapshotWarning = `snapshot ${snapshotPath} is unreadable; the head-OID check fell back to presence-only`;
|
||||
// Reported below; never falls back to the writable artifact copy.
|
||||
}
|
||||
if (!expectedHead) {
|
||||
process.stderr.write(
|
||||
`verify-pr check: --context ${contextPath} is missing, unreadable, or carries no valid head OID\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
snapshotPath = join(dir, 'context.json');
|
||||
const prNumber = /^pr(\d+)-verify-/.exec(basename(resolve(dir)))?.[1];
|
||||
if (!existsSync(snapshotPath) && prNumber) {
|
||||
snapshotPath = join(
|
||||
dirname(resolve(dir)),
|
||||
'verify-context',
|
||||
`pr${prNumber}.json`,
|
||||
);
|
||||
}
|
||||
if (existsSync(snapshotPath)) {
|
||||
try {
|
||||
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
||||
if (/^[0-9a-f]{40}$/.test(snapshot?.headRefOid ?? '')) {
|
||||
expectedHead = snapshot.headRefOid;
|
||||
} else {
|
||||
snapshotWarning = `snapshot ${snapshotPath} has no valid head OID; the head-OID check fell back to presence-only`;
|
||||
}
|
||||
} catch {
|
||||
snapshotWarning = `snapshot ${snapshotPath} is unreadable; the head-OID check fell back to presence-only`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Refuses credential-shaped ignored files in a `/verify-pr` round tree.
|
||||
*
|
||||
* The isolation gate checks known environment names and home-relative paths,
|
||||
* and `verify-pr-prepare.mjs` refuses untracked files; a gitignored credential
|
||||
* file is invisible to both. This scan lists ignored files one by one (git
|
||||
* status collapses ignored directories into one line, which is why a widened
|
||||
* pathspec set used to refuse every installed tree) and matches the basenames
|
||||
* a provider key or certificate typically uses.
|
||||
*
|
||||
* Installed dependencies and build output are excluded at any depth: a
|
||||
* workspace package's own `node_modules` and `dist` are as irrelevant as the
|
||||
* root ones, and `node_modules` alone ships hundreds of `credentials*` files.
|
||||
* The pathspecs both restrict enumeration to credential-shaped names and
|
||||
* exclude those directories, so an installed tree's multi-megabyte ignored
|
||||
* listing never reaches the output buffer. A credential file placed in the
|
||||
* project (including a nested ignored directory) is still reported. Two
|
||||
* exclusions are deliberate: credentials inside `node_modules`/`dist` are
|
||||
* accepted as installed/build content, and a nested git repository is refused
|
||||
* outright because git will not descend into it — scan that clone separately.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-pr-credential-scan.mjs <root>
|
||||
*
|
||||
* Exit codes: 0 clean, 1 credential-shaped entries found, 2 usage error.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import { isEntryPoint } from './lib/entry-point.js';
|
||||
|
||||
const CREDENTIAL_BASENAME =
|
||||
/^(?:\.env.*|.*\.env|.*\.env~|.*\.pem|.*\.key|.*\.p12|.*\.pfx|id_(?:rsa|ed25519|ecdsa|dsa).*|.*credentials.*|\.git-credentials|\.npmrc|\.netrc|gha-creds-.*\.json)$/;
|
||||
|
||||
// Git applies these pathspecs while enumerating, before any output is
|
||||
// buffered: a full ignored listing of an installed checkout is several MB and
|
||||
// used to overflow spawnSync's buffer before the JS filter could run.
|
||||
const CREDENTIAL_PATHSPECS = [
|
||||
':(glob)**/.env',
|
||||
':(glob)**/.env*',
|
||||
':(glob)**/*.env',
|
||||
':(glob)**/*.env~',
|
||||
':(glob)**/*.pem',
|
||||
':(glob)**/*.key',
|
||||
':(glob)**/*.p12',
|
||||
':(glob)**/*.pfx',
|
||||
':(glob)**/id_rsa*',
|
||||
':(glob)**/id_ed25519*',
|
||||
':(glob)**/id_ecdsa*',
|
||||
':(glob)**/id_dsa*',
|
||||
':(glob)**/*credentials*',
|
||||
':(glob)**/.git-credentials',
|
||||
':(glob)**/.npmrc',
|
||||
':(glob)**/.netrc',
|
||||
':(glob)**/gha-creds-*.json',
|
||||
':(exclude,glob)**/node_modules/**',
|
||||
':(exclude,glob)**/dist/**',
|
||||
];
|
||||
|
||||
// A nested clone is a repository git does not descend into, so a credential
|
||||
// file inside it would silently pass an ignored-file scan. Dependencies and
|
||||
// build output keep their own exclusion: a vendored `.git` is no more a
|
||||
// project credential than a vendored `.env`.
|
||||
const NESTED_REPO_PATHSPECS = [
|
||||
':(glob)**/.git',
|
||||
':(exclude,glob)**/node_modules/**',
|
||||
':(exclude,glob)**/dist/**',
|
||||
];
|
||||
|
||||
const EXCLUDED_SEGMENTS = new Set(['node_modules', 'dist']);
|
||||
|
||||
/** True for a path whose basename is credential-shaped. */
|
||||
export function isCredentialShaped(path) {
|
||||
const name = basename(path);
|
||||
// A public key is not a credential; refusing id_rsa.pub would be noise.
|
||||
if (name.endsWith('.pub')) return false;
|
||||
return CREDENTIAL_BASENAME.test(name);
|
||||
}
|
||||
|
||||
export function gitScanArgs(root) {
|
||||
return [
|
||||
'-C',
|
||||
root,
|
||||
'ls-files',
|
||||
'--others',
|
||||
'--ignored',
|
||||
'--exclude-standard',
|
||||
'-z',
|
||||
'--',
|
||||
...CREDENTIAL_PATHSPECS,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the nested repositories found under `root`, sorted. Three surfaces
|
||||
* are enumerated, because git collapses a nested working tree to a directory
|
||||
* entry on each of them: an ignored clone, an untracked NON-ignored clone
|
||||
* (the shape a round's own `git clone` leaves behind), and a populated
|
||||
* submodule (a mode-160000 gitlink whose directory carries a `.git`).
|
||||
* Dependencies and build output keep their exclusion on every surface: a
|
||||
* vendored `.git` is no more a project credential than a vendored `.env`.
|
||||
*/
|
||||
export function nestedGitRepos(root, { maxBuffer = 8 * 1024 * 1024 } = {}) {
|
||||
const git = (args) => {
|
||||
const result = spawnSync('git', ['-C', root, ...args], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`could not list nested repositories: ${(result.stderr || '').trim() || 'git failed'}`,
|
||||
);
|
||||
}
|
||||
return result.stdout;
|
||||
};
|
||||
const excluded = (path) =>
|
||||
path.split('/').some((segment) => EXCLUDED_SEGMENTS.has(segment));
|
||||
const found = new Set();
|
||||
const ignored = git([
|
||||
'ls-files',
|
||||
'--others',
|
||||
'--ignored',
|
||||
'--exclude-standard',
|
||||
'-z',
|
||||
'--',
|
||||
...NESTED_REPO_PATHSPECS,
|
||||
]);
|
||||
for (const path of ignored.split('\0')) {
|
||||
if (!path || excluded(path)) continue;
|
||||
found.add(path.replace(/\/$/, ''));
|
||||
}
|
||||
const untracked = git(['ls-files', '--others', '--exclude-standard', '-z']);
|
||||
for (const entry of untracked.split('\0')) {
|
||||
if (!entry.endsWith('/') || excluded(entry)) continue;
|
||||
if (existsSync(join(root, entry, '.git'))) {
|
||||
found.add(entry.replace(/\/$/, ''));
|
||||
}
|
||||
}
|
||||
const staged = git(['ls-files', '--stage', '-z']);
|
||||
for (const record of staged.split('\0')) {
|
||||
if (!record.startsWith('160000 ')) continue;
|
||||
const tab = record.indexOf('\t');
|
||||
const path = tab === -1 ? '' : record.slice(tab + 1);
|
||||
if (!path || excluded(path)) continue;
|
||||
// An unpopulated gitlink has no working tree to read; its URL is covered
|
||||
// by the config value scan instead.
|
||||
if (existsSync(join(root, path, '.git'))) found.add(path);
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns local config entries that carry a credential PR code could read
|
||||
* with plain `git config`. The scan inspects VALUES, not a list of keys: any
|
||||
* config value can hold a URL with a token (`http.proxy`, a novel mirror
|
||||
* key, tomorrow's sibling of `pushurl`), so a key allowlist never converges.
|
||||
* A value hits when it embeds http(s) userinfo or when it is an
|
||||
* `Authorization` / `Proxy-Authorization` header (the RFC 7235 pair GitHub
|
||||
* Actions writes into `http.*.extraheader`); other header names stay clean.
|
||||
* The two URL-bearing KEY families are judged too: `url.*.insteadOf` /
|
||||
* `url.*.pushInsteadOf` rewrites and `http.<url>.*` sections carry their URL
|
||||
* in the key, not the value. On an http(s) URL any userinfo counts — a token
|
||||
* used as the username (`https://<pat>@…`) is as readable as `user:pass@` —
|
||||
* except the bare conventional account name `git@` in any spelling, which
|
||||
* carries no secret, matching `ssh://git@…`. `--includes` follows
|
||||
* `include.path` the way the `git config --get` PR code uses does, and
|
||||
* `--list --null` frames each `key\nvalue` record so a multi-line value
|
||||
* cannot forge entries. When `extensions.worktreeConfig` is set, ordinary
|
||||
* `git config --get` also reads `.git/config.worktree`, so that scope (with
|
||||
* its includes) is enumerated too — `--local` alone would report a
|
||||
* credential there as clean. The file is optional: absent means an empty
|
||||
* scope, while an existing but unreadable or malformed one refuses. A
|
||||
* `credential.helper` COMMAND is out of this
|
||||
* URL/header contract by design (see the design record); prepare disables
|
||||
* helpers for its own reads.
|
||||
*/
|
||||
export function credentialBearingConfig(
|
||||
root,
|
||||
{ maxBuffer = 8 * 1024 * 1024 } = {},
|
||||
) {
|
||||
const readScope = (scope) => {
|
||||
const result = spawnSync(
|
||||
'git',
|
||||
['-C', root, 'config', scope, '--includes', '--list', '--null'],
|
||||
{ encoding: 'utf8', maxBuffer },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`could not read local git config: ${(result.stderr || '').trim() || 'git failed'}`,
|
||||
);
|
||||
}
|
||||
return result.stdout;
|
||||
};
|
||||
const worktreeConfig = spawnSync(
|
||||
'git',
|
||||
[
|
||||
'-C',
|
||||
root,
|
||||
'config',
|
||||
'--local',
|
||||
'--includes',
|
||||
'--bool',
|
||||
'extensions.worktreeConfig',
|
||||
],
|
||||
{ encoding: 'utf8', maxBuffer },
|
||||
);
|
||||
const scopes = ['--local'];
|
||||
if (worktreeConfig.status === 0 && worktreeConfig.stdout.trim() === 'true') {
|
||||
// Enabling the extension does not require the file to exist: an absent
|
||||
// optional config.worktree is an EMPTY scope (explicit `--worktree
|
||||
// --list` would exit 128 on it and false-refuse a clean repo). An
|
||||
// existing but unreadable or malformed file still refuses, because
|
||||
// readScope throws on any git error.
|
||||
const gitDir = spawnSync(
|
||||
'git',
|
||||
['-C', root, 'rev-parse', '--absolute-git-dir'],
|
||||
{ encoding: 'utf8', maxBuffer },
|
||||
);
|
||||
if (gitDir.status !== 0) {
|
||||
throw new Error(
|
||||
`could not read local git config: ${(gitDir.stderr || '').trim() || 'git failed'}`,
|
||||
);
|
||||
}
|
||||
if (existsSync(join(gitDir.stdout.trim(), 'config.worktree'))) {
|
||||
scopes.push('--worktree');
|
||||
}
|
||||
}
|
||||
const hasUserinfo = (value) => {
|
||||
const match = /^https?:\/\/([^/@\s]+)@/i.exec(value);
|
||||
// `git` is the conventional account name, not a secret, and account
|
||||
// names are case-insensitive; any other username-only userinfo may be
|
||||
// a token, so it stays refused.
|
||||
return match !== null && match[1].toLowerCase() !== 'git';
|
||||
};
|
||||
const isAuthHeader = (value) =>
|
||||
/^\s*(?:proxy-)?authorization\s*:/i.test(value);
|
||||
const hits = [];
|
||||
for (const scope of scopes) {
|
||||
for (const record of readScope(scope).split('\0')) {
|
||||
if (!record) continue;
|
||||
const newline = record.indexOf('\n');
|
||||
const key = newline === -1 ? record : record.slice(0, newline);
|
||||
const value = newline === -1 ? '' : record.slice(newline + 1);
|
||||
const rewrite = /^url\.(.*)\.(insteadof|pushinsteadof)$/.exec(key);
|
||||
const httpSection = /^http\.(.+)\.[^.]+$/.exec(key);
|
||||
if (
|
||||
hasUserinfo(value) ||
|
||||
isAuthHeader(value) ||
|
||||
(rewrite && hasUserinfo(rewrite[1])) ||
|
||||
(httpSection && hasUserinfo(httpSection[1]))
|
||||
) {
|
||||
if (!hits.includes(key)) hits.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ignored credential-shaped files under `root`, sorted. An empty
|
||||
* array is the clean state; a git failure is thrown so the caller can refuse
|
||||
* rather than treat it as clean, and a nested repository is refused because a
|
||||
* scan of the outer tree cannot see into it.
|
||||
*/
|
||||
export function scanIgnoredCredentials(
|
||||
root,
|
||||
{ maxBuffer = 8 * 1024 * 1024 } = {},
|
||||
) {
|
||||
const nested = nestedGitRepos(root, { maxBuffer });
|
||||
if (nested.length > 0) {
|
||||
throw new Error(
|
||||
`nested git repository under ${nested[0]} — scan that clone separately or remove it`,
|
||||
);
|
||||
}
|
||||
const configHits = credentialBearingConfig(root, { maxBuffer });
|
||||
if (configHits.length > 0) {
|
||||
throw new Error(
|
||||
`credential-bearing git config in ${configHits[0]} — remove the credential from .git/config`,
|
||||
);
|
||||
}
|
||||
const result = spawnSync('git', gitScanArgs(root), {
|
||||
encoding: 'utf8',
|
||||
maxBuffer,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`could not list ignored files: ${(result.stderr || '').trim() || 'git failed'}`,
|
||||
);
|
||||
}
|
||||
return result.stdout
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
.filter((path) => isCredentialShaped(path))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const root = argv[0];
|
||||
if (!root || argv.length > 1) {
|
||||
process.stderr.write(
|
||||
'Usage: node scripts/verify-pr-credential-scan.mjs <root>\n',
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
let found;
|
||||
try {
|
||||
found = scanIgnoredCredentials(root);
|
||||
} catch (error) {
|
||||
process.stderr.write(`verify-pr credential scan: ${error.message}\n`);
|
||||
return 1;
|
||||
}
|
||||
for (const path of found) {
|
||||
process.stdout.write(`credential-shaped ignored entry: ${path}\n`);
|
||||
}
|
||||
return found.length === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
if (isEntryPoint(import.meta.url, process.argv[1])) {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host-side model proxy for a local `/verify-pr` round.
|
||||
*
|
||||
* A local round needs the same isolation the CI lane provides: the container
|
||||
* executes untrusted PR code, so the real model credential must stay outside
|
||||
* it. This proxy holds the real key on the host and lets the container reach
|
||||
* the model with a per-run bearer token instead. It mirrors the CI lane's
|
||||
* inline proxy (qwen-triage.yml `start_openai_proxy`), with changes the local
|
||||
* shape needs: the listen address is a flag (a container cannot reach the
|
||||
* host's loopback, so bind the Docker bridge address instead), and the
|
||||
* coordinates are printed as one JSON line for the maintainer to consume.
|
||||
* Hop-by-hop request headers are stripped rather than forwarded.
|
||||
* The printed baseUrl uses the listen address, so bind the address clients
|
||||
* dial — 0.0.0.0 listens on every interface but is not a dialable URL.
|
||||
*
|
||||
* The CI lane deliberately keeps its own inline copy: the proxy handles the
|
||||
* real key, so it must not execute a script from the PR-head checkout.
|
||||
*
|
||||
* Usage (from a trusted checkout, never the PR-head tree — this process holds
|
||||
* the real key and a PR can rewrite a script in its own checkout):
|
||||
* if [ -t 0 ]; then
|
||||
* read -rsp 'REVIEW_OPENAI_API_KEY: ' REVIEW_OPENAI_API_KEY; echo
|
||||
* else
|
||||
* IFS= read -r REVIEW_OPENAI_API_KEY \
|
||||
* < ~/.config/qwen-verify/review-openai-key
|
||||
* fi
|
||||
* export REVIEW_OPENAI_API_KEY
|
||||
* REVIEW_OPENAI_BASE_URL=<url> \
|
||||
* node <trusted-clone>/scripts/verify-pr-model-proxy.mjs \
|
||||
* [--listen <host>]
|
||||
*
|
||||
* Reading the key from a terminal when one exists and from a file otherwise
|
||||
* keeps it out of shell history (an inline assignment is recorded) and keeps
|
||||
* an agent-driven run, whose shell has no TTY, from blocking on `read`.
|
||||
*
|
||||
* Options:
|
||||
* --listen <host> address to bind (default 127.0.0.1; use the Docker
|
||||
* bridge address, e.g. 172.17.0.1, for container clients).
|
||||
* The port is always ephemeral: a fixed port is a binding
|
||||
* race the CI lane avoids too.
|
||||
*
|
||||
* Environment:
|
||||
* REVIEW_OPENAI_BASE_URL upstream base URL, may carry a path prefix
|
||||
* REVIEW_OPENAI_API_KEY the real key; never leaves this process
|
||||
* QWEN_VERIFY_PROXY_TOKEN bearer clients must present (default: random)
|
||||
*
|
||||
* On readiness, prints one JSON line: {port, baseUrl, token, nonce}. Give the
|
||||
* container `baseUrl` as the provider base URL and `token` as its credential;
|
||||
* `GET /__health` echoes `nonce`. Only `POST <basePath>/chat/completions` is
|
||||
* relayed — the path is matched exactly, not as a suffix.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
import { Readable } from 'node:stream';
|
||||
import { isEntryPoint } from './lib/entry-point.js';
|
||||
|
||||
export const DEFAULT_IDLE_TIMEOUT_MS = 240_000;
|
||||
|
||||
// The qwen client tolerates 240 s of silence inside one stream
|
||||
// (DEFAULT_STREAM_IDLE_TIMEOUT_MS in the OpenAI content generator), so a
|
||||
// tighter proxy window would cut a completion its own client would have
|
||||
// waited out. Keep the default equal; a caller may still pass its own value.
|
||||
|
||||
// RFC 7230 hop-by-hop headers describe the client's connection to us, not the
|
||||
// upstream one. Forwarding them is never correct, and a copied
|
||||
// transfer-encoding or expect also fights the framing undici chooses for the
|
||||
// relayed stream. `host` and `content-length` ride along for the same reason.
|
||||
const STRIPPED_REQUEST_HEADERS = [
|
||||
'host',
|
||||
'content-length',
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authorization',
|
||||
'proxy-authenticate',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'expect',
|
||||
];
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const options = { listen: '127.0.0.1' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--listen') {
|
||||
const value = argv[++i];
|
||||
// An empty value would reach server.listen(port, ''), which binds
|
||||
// every interface — past the bridge this proxy is meant to stay on.
|
||||
if (value === undefined || value === '' || value.startsWith('--')) {
|
||||
throw new Error('--listen requires a value');
|
||||
}
|
||||
options.listen = value;
|
||||
} else {
|
||||
throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Strips the trailing slashes a base path may carry; `''` means none. */
|
||||
export function normalizeBasePath(pathname) {
|
||||
const trimmed = (pathname ?? '').replace(/\/+$/, '');
|
||||
return trimmed === '/' ? '' : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles the base URL the maintainer hands to the container. A host that
|
||||
* carries a colon is an IPv6 literal and needs brackets, or `new URL()`
|
||||
* rejects the string downstream.
|
||||
*/
|
||||
export function formatLocalBaseUrl(listen, port, basePath = '') {
|
||||
const host = listen.includes(':') ? `[${listen}]` : listen;
|
||||
return `http://${host}:${port}${basePath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an http.Server that relays only POST /chat/completions to the
|
||||
* upstream, replacing the client's bearer token with the real key. The idle
|
||||
* watchdog is per-chunk, not total: qwen tolerates minutes of silence inside
|
||||
* one stream, so a total cap would cut healthy long completions. It also
|
||||
* stays quiet while the downstream is paused — backpressure from a slow but
|
||||
* live client is not upstream idleness — and re-arms on 'drain'.
|
||||
*/
|
||||
export function createModelProxy({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
token,
|
||||
nonce,
|
||||
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
||||
}) {
|
||||
if (!baseUrl || !apiKey || !token || !nonce) {
|
||||
throw new Error('baseUrl, apiKey, token and nonce are all required');
|
||||
}
|
||||
const base = new URL(baseUrl);
|
||||
const basePath = normalizeBasePath(base.pathname);
|
||||
|
||||
return http.createServer(async (req, res) => {
|
||||
if (req.url === '/__health') {
|
||||
// Identity, not just liveness: a squatter cannot know the nonce.
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end(nonce);
|
||||
return;
|
||||
}
|
||||
|
||||
let timer;
|
||||
try {
|
||||
const incoming = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
const target = new URL(base.origin);
|
||||
|
||||
let path = incoming.pathname;
|
||||
if (
|
||||
basePath &&
|
||||
basePath !== '/' &&
|
||||
path !== basePath &&
|
||||
!path.startsWith(`${basePath}/`)
|
||||
) {
|
||||
path = `${basePath}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
}
|
||||
target.pathname = path;
|
||||
target.search = incoming.search;
|
||||
|
||||
// Exact path, not a suffix: `endsWith('/chat/completions')` would also
|
||||
// relay `/admin/chat/completions` to the upstream.
|
||||
const allowedPath = basePath
|
||||
? `${basePath}/chat/completions`
|
||||
: '/chat/completions';
|
||||
if (req.method !== 'POST' || target.pathname !== allowedPath) {
|
||||
res.writeHead(403, { 'content-type': 'text/plain' });
|
||||
res.end(`proxy: only POST ${allowedPath} is allowed\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only the agent knows this run's token; anything else that finds the
|
||||
// port cannot get the key used.
|
||||
if (req.headers.authorization !== `Bearer ${token}`) {
|
||||
res.writeHead(401, { 'content-type': 'text/plain' });
|
||||
res.end('proxy: unauthorized\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = new Headers(req.headers);
|
||||
for (const name of STRIPPED_REQUEST_HEADERS) headers.delete(name);
|
||||
// RFC 7230 §6.1: a header nominated by Connection is hop-by-hop too,
|
||||
// so a client cannot smuggle one through under an unlisted name.
|
||||
const nominated = String(req.headers.connection ?? '')
|
||||
.split(',')
|
||||
.map((name) => name.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
for (const name of nominated) headers.delete(name);
|
||||
headers.set('authorization', `Bearer ${apiKey}`);
|
||||
|
||||
const init = { method: req.method, headers };
|
||||
init.body = req;
|
||||
init.duplex = 'half';
|
||||
|
||||
const controller = new AbortController();
|
||||
let body = null;
|
||||
let finished = false;
|
||||
const arm = () => {
|
||||
if (finished) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
// A paused body is downstream backpressure, not upstream silence:
|
||||
// the client is alive but slow, and aborting here would cut a
|
||||
// healthy completion. The 'drain' listener re-arms this window.
|
||||
if (body?.isPaused()) {
|
||||
arm();
|
||||
return;
|
||||
}
|
||||
controller.abort();
|
||||
}, idleTimeoutMs);
|
||||
};
|
||||
// Registered before the fetch: a client that disconnects while the
|
||||
// upstream headers are still in flight has already emitted 'close', so
|
||||
// registering after the response would never abort that request.
|
||||
res.on('close', () => {
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
});
|
||||
res.on('drain', arm);
|
||||
// A slow request UPLOAD is activity too: fetch resolves only on the
|
||||
// upstream headers, so without this a large prompt on a slow link
|
||||
// would be torn down mid-upload by a window meant for stream silence.
|
||||
// 'readable', not 'data': a data listener switches the request to
|
||||
// flowing mode and steals the body chunks from the relayed stream.
|
||||
req.on('readable', arm);
|
||||
arm();
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await fetch(target, { ...init, signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
if (!res.destroyed && !res.writableEnded) {
|
||||
res.writeHead(504, { 'content-type': 'text/plain' });
|
||||
res.end('proxy error: upstream request timed out\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (res.destroyed) {
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
return;
|
||||
}
|
||||
// The timer is deliberately NOT cleared here: fetch() resolves on
|
||||
// HEADERS, and the armed timer doubles as the first idle window for
|
||||
// the body; the per-chunk handler below refreshes it.
|
||||
const responseHeaders = {};
|
||||
upstream.headers.forEach((value, key) => {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower !== 'content-encoding' && lower !== 'content-length') {
|
||||
responseHeaders[key] = value;
|
||||
}
|
||||
});
|
||||
res.writeHead(upstream.status, responseHeaders);
|
||||
if (upstream.body) {
|
||||
body = Readable.fromWeb(upstream.body);
|
||||
const done = () => {
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
body.on('end', done);
|
||||
// An idle watchdog firing mid-body aborts the upstream, which
|
||||
// surfaces here as an error: tear down the downstream response too.
|
||||
body.on('error', () => {
|
||||
done();
|
||||
res.destroy();
|
||||
});
|
||||
body.pipe(res);
|
||||
body.on('data', arm);
|
||||
} else {
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
console.error('proxy upstream failure:', error);
|
||||
res.writeHead(502, { 'content-type': 'text/plain' });
|
||||
res.end('proxy error: upstream request failed\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
let options;
|
||||
try {
|
||||
options = parseArgs(argv);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
return 2;
|
||||
}
|
||||
const baseUrl = process.env.REVIEW_OPENAI_BASE_URL;
|
||||
const apiKey = process.env.REVIEW_OPENAI_API_KEY;
|
||||
if (!baseUrl || !apiKey) {
|
||||
process.stderr.write(
|
||||
'REVIEW_OPENAI_BASE_URL and REVIEW_OPENAI_API_KEY must both be set\n',
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
const token =
|
||||
process.env.QWEN_VERIFY_PROXY_TOKEN || randomBytes(24).toString('hex');
|
||||
const nonce = randomBytes(24).toString('hex');
|
||||
const server = createModelProxy({ baseUrl, apiKey, token, nonce });
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, options.listen, resolve);
|
||||
});
|
||||
const { port } = server.address();
|
||||
const localBaseUrl = formatLocalBaseUrl(
|
||||
options.listen,
|
||||
port,
|
||||
normalizeBasePath(new URL(baseUrl).pathname),
|
||||
);
|
||||
// One JSON line, on stdout: the maintainer pastes baseUrl/token into the
|
||||
// container's provider configuration. The real key is never printed.
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ port, baseUrl: localBaseUrl, token, nonce })}\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (isEntryPoint(import.meta.url, process.argv[1])) {
|
||||
main(process.argv.slice(2)).then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
}
|
||||
+146
-12
@@ -46,7 +46,7 @@
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { isEntryPoint } from './lib/entry-point.js';
|
||||
@@ -272,6 +272,126 @@ function run(command, args) {
|
||||
return spawnSync(command, args, { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
@@ -296,7 +416,17 @@ function main(argv) {
|
||||
const { owner, name } = parseRepo(options.repo);
|
||||
const slug = `${owner}/${name}`;
|
||||
|
||||
const toplevel = run('git', ['rev-parse', '--show-toplevel']);
|
||||
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');
|
||||
}
|
||||
@@ -338,7 +468,7 @@ function main(argv) {
|
||||
];
|
||||
for (const [label, oid, ref] of probes) {
|
||||
if (
|
||||
run('git', ['-C', root, 'cat-file', '-e', `${oid}^{commit}`]).status !== 0
|
||||
runGit(['-C', root, 'cat-file', '-e', `${oid}^{commit}`]).status !== 0
|
||||
) {
|
||||
return fail(
|
||||
3,
|
||||
@@ -351,7 +481,7 @@ function main(argv) {
|
||||
// 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 = run('git', ['-C', root, 'rev-parse', 'HEAD']);
|
||||
const treeHead = runGit(['-C', root, 'rev-parse', 'HEAD']);
|
||||
if (treeHead.status !== 0) {
|
||||
return fail(3, 'could not read HEAD in this clone');
|
||||
}
|
||||
@@ -361,15 +491,19 @@ function main(argv) {
|
||||
`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', [
|
||||
'-C',
|
||||
root,
|
||||
'status',
|
||||
'--porcelain',
|
||||
'--untracked-files=normal',
|
||||
]);
|
||||
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}`);
|
||||
@@ -387,7 +521,7 @@ function main(argv) {
|
||||
// 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 = run('git', ['-C', root, 'ls-files', '-v']);
|
||||
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}`);
|
||||
|
||||
Reference in new issue
Block a user