K2: scan a pull request's diff and comment the band-aid - #16
Merged
Conversation
`.github/actions/vpcopilot-scan/` (composite action), `.github/workflows/pr-review.yml`, `src/vpcopilot/ci.py`, `vpcopilot ci-review`, and `docs/CI.md`. Scans a PR's diff against the merge base and leaves one comment carrying, per finding above a severity threshold, the F5 XC control triage routed it to and the generated policy name. A PR with nothing above the threshold posts nothing. Measured live on the Nimbus fixture: a deliberately vulnerable /api/refund route produced three findings (critical negative-amount, critical SQLi, high missing authorization) and four policies across service_policy, waf, malicious_user and rate_limit, in 76 s against a three-minute budget. The acceptance criterion contradicted itself and this resolves it honestly. It asked for one comment carrying the policy AND the simulation result while also requiring never writes to XC from CI. G2's blast-radius measurement creates a throwaway policy, attaches it to a load balancer, replays traffic through it and deletes it — three tenant writes — and G1, an offline evaluator, was deliberately deferred. So a would-block count is not computed in CI. The comment reports blast radius only from a real tenant run's simulation.json, and otherwise says that no measurement was made and what it would take. Neither silence nor 0% would do: both read as measured-and-safe. Never writes to XC by construction: ci.py imports no xc, apply, refiner or simulate, pinned by a test that reads the module's own source. The action declares no XC inputs. The defect that would have shipped a silent all-clear: `git diff` answers relative to the repository root while the scanner matches relative to `repo-path`, and this project's app fixture lives eight levels down — so nothing matched, nothing was scanned, and the PR was told it was clean. `rebase_onto` translates the paths and the comment discloses what fell outside. Additive plumbing only: `collect_files(..., only=)` and `run_pipeline(..., only_files=)` filter the existing walk, so the existing call path is unchanged and a changed file that is vendored, unsupported or oversized is still excluded and reported exactly as in a full scan. 735 tests pass offline, ruff clean, coverage 79%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fix the action manifest Two defects the PR's own CI found that a local run could not. 1. The moved blast-radius gate needed XC credentials to refuse. Both apply paths constructed XC() before reaching it, and XC.__init__ raises when XC_API_URL / XC_API_TOKEN are unset — so on a runner without credentials "this policy is too broad" came back as "XC_API_URL not set". The test passed locally only because the developer's .env happened to have them, which is a test depending on developer-local state. Every refusal that needs no tenant now precedes XC(), which is also better behaviour: an over-broad policy should be refused whether or not a tenant is reachable. Pinned by tests that unset the variables. 2. A comment inside the action broke the action. The runner parses Actions expression syntax anywhere in a run block, comments included, so a comment spelling out an empty expression while explaining the script-injection hazard failed the manifest to load with "An expression was expected". pyyaml cannot see it and the guard test skipped comment lines — exactly where the text was. The test no longer skips them. Also, found while fixing those: the repo has no secrets configured, so secrets.ANTHROPIC_API_KEY is empty, and `required: true` on an action input is not enforced against an empty value. The action would have scanned nothing and read as clean — an open review finding. It now fails loudly and names the cause, the workflow skips with an explicit "not a clean bill of health" summary rather than going permanently red, and the paths filter is scoped to the directory the action actually scans instead of every code file in the repo. 738 tests pass with XC_API_URL/XC_API_TOKEN/XC_NAMESPACE unset and no .env. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The shift left: a developer sees the F5 XC virtual patch that would hold their new hole closed in the review where they introduced it.
Acceptance, as met
One comment carrying the policy ✅ · a PR with nothing above the threshold posts nothing ✅ · 76 s on the Nimbus repo against a three-minute budget ✅ · never writes to XC ✅, structurally.
Live: a deliberately vulnerable
/api/refundroute → three findings (critical negative-amount, critical SQLi, high missing-authorization) and four policies acrossservice_policy,waf,malicious_user,rate_limit.The acceptance criterion contradicted itself
It asked for one comment carrying the policy and the simulation result while also requiring never writes to XC from CI. Both cannot hold: G2's blast-radius measurement creates a throwaway policy, attaches it to a load balancer, replays traffic through it and deletes it — three tenant writes — and there is no offline evaluator, because G1 was deliberately deferred.
So a would-block count is not computed in CI. The comment reports blast radius only from a real tenant run's
simulation.json(passed via thesimulation-jsoninput), and otherwise says so plainly and names what it would take. Neither silence nor0%would do: both read as measured and safe. Same precedent as G2's own rewritten criterion, G4's reproducibility, and I2's conflict criterion.Never writes to XC, by construction
ci.pyimports noxc, noapply, norefiner, nosimulate— there is no code path from CI to a load balancer, pinned by a test that reads the module's own source, which is the only version of that guarantee that survives someone adding a convenient import later. The action declares no XC inputs, andci-reviewsays so out loud if it finds an XC credential in its environment.The defect that would have shipped a silent all-clear
git diff --name-onlyanswers relative to the repository root;collect_filesmatches relative torepo-path; this project's app fixture lives eight levels down. Compared directly they never match — zero files scanned, no findings, PR told it is clean. Catching it means noticing the absence of an error.rebase_onto()translates the paths and returns a count of changed files outside the scanned directory, which the comment discloses.Adversarial review before shipping
21 raised across four dimensions; 12 verified — 2 confirmed, 10 refuted, again mostly because they had already been fixed while the review ran. Almost every real finding was one shape: a failure rendering as an all-clear.
findings.jsonis the discover contract — every candidate, including onesverifyrefuted — and the filter compared that set against itself, so it was a no-op. Now built from findings that have a triage decision. Showing developers refuted false positives with a band-aid attached is the fastest way to get a bot ignored.**/*.pychange while scanning one fixture directory).body_matcherpolicy matches nothing and scores a perfect 0%."caf\303\251.py"), so the suffix read as.py"and the file vanished — neither scanned nor counted as outside (-zfixes it);base: origin/mainbecameorigin/origin/main; action inputs reached bash by${{ }}text substitution rather than the environment;--min-severitywas unvalidated and silently fell through tohigh; the header's denominator used the post-filter count, so a fourteen-file diff read as "1 of 1".Notes for review
bench/fixtures/ci/, deliberately not in the Nimbus app.benchscansbench/fixtures/nimbus-vuln-lab/app/src/app/apiagainstanswer_key.yaml, and a vulnerable route there produces findings in neitherexpectednorbonus— whichbench.pyscores as noise, silently degrading G4's committed scorecard andBASELINE.md. A benchmark regression caused by a test fixture. Pinned by a test, with the reason in the fixture README.pull_request, neverpull_request_target— the latter runs trusted workflow code with secrets against untrusted head code. The consequence, that fork PRs get no review, is documented rather than discovered.fail-on-findingsdefaults to false. The comment is the deliverable; a red check on a finding the team has accepted is how a useful bot gets switched off.Verification
ci.pyat 87%.Roadmap now 12 done / 5 open.
🤖 Generated with Claude Code