Add post-patch-validation plugin (#302)

* Add post-patch-validation plugin

* Use public contact email for post-patch-validation

* Fix post-patch validation CLI and artifact edge cases
This commit is contained in:
Dan Guido
2026-09-14 01:49:16 -04:00
committed by GitHub
parent 321ccfe628
commit ce9ae2e2dc
27 changed files with 4687 additions and 3 deletions
@@ -0,0 +1,10 @@
{
"name": "post-patch-validation",
"version": "0.1.1",
"description": "Validates security patches against baseline exploits, root-cause variants, behavior contracts, regressions, and new-vulnerability checks using isolated worktrees and deterministic S1-S5 evidence classification. Bundles a validate-patch dynamic workflow for Claude Code.",
"author": {
"name": "Scott Arciszewski",
"email": "opensource@trailofbits.com",
"url": "https://github.com/tob-scott-a"
}
}
+164
View File
@@ -0,0 +1,164 @@
# Post-Patch Validation
Validates an existing security patch as an untrusted hypothesis. The plugin proves the original
failure on a pinned baseline, exercises at least one root-cause variant, checks behavior and
adjacent security properties, runs the project suite, and produces an evidence-backed S1-S5 or
INCONCLUSIVE verdict.
The portable `post-patch-validation` skill works with agents that support Agent Skills. Claude
Code users also get a bundled dynamic workflow.
## Installation
```text
/plugin marketplace add trailofbits/skills
/plugin install post-patch-validation@trailofbits
```
## Skill usage
Ask an agent to validate a patch and provide:
- the vulnerable base commit or tag;
- the patched commit, tag, or patch file;
- the finding or a local path containing it;
- authorization to execute the local project and its tests.
The skill scaffolds a JSON plan, requires executable evidence for seven categories, runs each
check in isolated Git worktrees, and writes:
```text
post-patch-validation/
├── plan.json
└── results/
├── artifact-manifest.json
├── patch.diff
├── plan.snapshot.json
├── report.md
├── result.json
├── helpers/
│ └── <sha256> # invoked file bytes, content-addressed
├── scratch/
│ └── <check-id-and-side>/ # archived per-invocation writable state
└── 001-<check-id>-<side>.stdout / .stderr
```
The Python runner uses argv arrays rather than shell command strings. It pins commit and patch
hashes, disables Git hooks while materializing worktrees, fixes locale/timezone/hash-seed inputs,
executes checks in lexical order, and preserves raw output. Each check invocation receives a fresh
opaque scratch directory and a private copy of its plan artifacts; base and patched worktrees also
use separate validator-owned roots. Scratch is archived only after exit, while the private plan
copy is discarded; the clean source snapshot remains only in runner memory. Exploit and variant
sides execute in random order with stable evidence filenames; stdout/stderr are copied from
anonymous or random capture descriptors into those named files only after exit. Every argv element
that resolves to a readable file is hashed. Files inside the isolated plan or checkout roots are
also stored under `helpers/<sha256>` for review, up to a 16 MiB limit; `argv_files` says why an
external, unreadable, or oversized file was not archived. Git worktree metadata changes are
serialized and active worktrees are locked, while independent validations may execute their checks
concurrently. The runner exits 0 only for S1; S2-S5 use their score as the exit code and
INCONCLUSIVE uses 10. Invalid or moved inputs use 64.
These controls isolate runner-managed evidence state; they are not a host sandbox. Checks retain
the caller's privileges, so execute untrusted helper code inside an appropriate OS or container
sandbox.
Two rules exist because an exit code carries less information than it appears to:
- `exploit` and `variant` checks must print `PPV_REACHED` before their assertion. A build error
and a failed assertion both exit nonzero, so an unmarked run is INCONCLUSIVE rather than proof
that the vulnerability reproduced.
- Checks run under a fixed minimal environment. Real toolchains get what they need through
`--allow-env NAME`, which records the forwarded name and value in `result.json`.
Every plan declares an evidence level: `source`, `build`, or `runtime`. Reports display it beside
verdict-specific interpretation text so an S1 from source inspection cannot be mistaken for runtime
proof. Exploit and variant checks assert only the security invariant; liveness, exact error behavior,
timing, and compatibility belong in behavior or regression checks.
Scaffolding also records affected Git submodules as sorted relative paths. Their pinned commits are
initialized from module objects already present in the source repository, without contacting the
URLs in `.gitmodules`, and both base and patched pins are recorded in `result.json`.
Active validation worktrees are locked against concurrent pruning with random owner tokens backed
by kernel file locks, so PID reuse cannot confuse stale-owner detection. After a forced
termination, the next run unlocks stale validator-owned registrations; `git worktree unlock`,
followed by `git worktree remove --force` or `git worktree prune`, provides manual recovery.
## Claude Dynamic Workflow
Claude Code exposes `/post-patch-validation:validate-patch`. For structured inputs, ask Claude
to invoke the `Workflow` tool with the following arguments:
```javascript
Workflow({
name: 'post-patch-validation:validate-patch',
args: {
finding: 'UAF when a callback releases the final request reference',
baseRef: 'vulnerable-tag',
patchRef: 'HEAD',
workdir: 'post-patch-validation',
},
})
```
The `Workflow` tool's `name` omits the leading slash. See the
[workflow documentation](https://code.claude.com/docs/en/workflows#distribute-a-workflow-in-a-plugin)
for plugin command naming.
Use `patchFile` instead of `patchRef` for a patch artifact; supplying both is rejected. `patchRef`
defaults to `HEAD`, but `baseRef` is always required because a validator that guesses the vulnerable
baseline cannot prove reproduction.
The workflow has five fixed phases and at most nine agents:
| Phase | Work |
|---|---|
| Inventory | Pin the inputs and scaffold the plan with the Python runner |
| Coverage | Four read-only lenses map exploit variants, behavior, adjacent security, and test infrastructure |
| Plan | One agent turns proposals into executable artifacts and passes `validate-plan` |
| Execute | One agent invokes the deterministic runner and returns its exact result |
| Review | Two read-only reviewers flag omitted paths or evidence that did not exercise real code |
The review agents cannot change the S-score. They can only keep an S1 result in
`REVIEW_REQUIRED` rather than advancing it to `READY_FOR_HUMAN_REVIEW`.
Dynamic workflows must be enabled in Claude Code. The workflow cannot ask questions after
launch, so pass the base and patch inputs up front.
## Verdicts
| Verdict | Meaning |
|---|---|
| S1 | Fix and variants pass without observed behavior/security regression |
| S2 | Fix passes but behavior, regression, or suite evidence fails |
| S3 | Original exploit or a root-cause variant remains unfixed |
| S4 | Fix passes but a base-clean security property fails on the patch |
| S5 | Patch is both incomplete and introduces a new security failure |
| INCONCLUSIVE | Baseline, marker, control, execution, pinning, coverage, or cleanup evidence is invalid |
Every verdict requires human review. S1 means the supplied checks passed; it cannot prove that a
human or agent supplied every relevant path.
## Requirements
- Git 2.36 or later (for lock reasons and NUL-delimited porcelain worktree metadata)
- Python 3.11 or later
- `uv`
- The target project's local build and test dependencies
No remote-target mode is provided. The validator executes repository code, so run it only for a
local target the user has authorized.
## Development
Run the Python runner tests, eval grader tests, and workflow tests from this repository's root:
```bash
bash plugins/post-patch-validation/tests/run-all.sh
node plugins/post-patch-validation/tests/workflow_logic.test.mjs
```
The Python suite uses `pytest` and `PyYAML` through `uv`; the runtime runner uses only the Python
standard library. `make check` discovers both suites. The three cases under `evals/` exercise a
complete fix, a missed variant, and a behavior regression. Their grader and scaffold tests run
locally without model calls; live Claude plugin evals require a separate invocation.
@@ -0,0 +1,36 @@
schema_version: "1.1"
name: behavior-regression
description: A patch fixes markup escaping but strips legitimate whitespace from benign input.
tags: [post-patch-validation, behavior-regression]
runs: 3
context:
scaffold_script: scaffold.sh
execution:
max_turns: 60
timeout_seconds: 1800
allowed_tools: [Read, Write, Edit, Bash, Glob, Grep, Skill, Workflow]
prompt: |-
Run the post-patch-validation:validate-patch dynamic workflow with the Workflow tool, passing
args finding PPV-EVAL-3, baseRef vulnerable, patchRef HEAD, and workdir post-patch-validation.
Finding PPV-EVAL-3: render(value) returns unescaped attacker-controlled markup. The intended
fix must escape every "<" while preserving legitimate text exactly, including surrounding
spaces. The target is this local repository and you are authorized to execute its code.
graders:
- name: result-exists
type: file_exists
path: post-patch-validation/results/result.json
exists: true
weight: 1
- name: behavior-change-verdict
type: regex
target: {source: file, path: post-patch-validation/results/result.json}
pattern: '"code":\s*"S2"'
match: contains
weight: 2
- name: behavior-row-shows-regression
type: regex
target: {source: file, path: post-patch-validation/results/report.md}
pattern: '\|\s*`[^`]+`\s*\|\s*behavior\s*\|\s*pass\s*\|\s*(?:fail\s*\|\s*(?:same|changed)|pass\s*\|\s*changed)\s*\|'
match: contains
weight: 1
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
git -c init.defaultBranch=main init -q
git config user.name "PPV Eval"
git config user.email "ppv-eval@example.invalid"
cat >renderer.py <<'PY'
def render(value: str) -> str:
return value
PY
git add renderer.py
GIT_AUTHOR_DATE="2026-01-01T00:00:00Z" GIT_COMMITTER_DATE="2026-01-01T00:00:00Z" \
git -c commit.gpgsign=false commit -q -m "vulnerable renderer"
git tag vulnerable
cat >renderer.py <<'PY'
def render(value: str) -> str:
return value.replace("<", "&lt;").strip()
PY
git add renderer.py
GIT_AUTHOR_DATE="2026-01-02T00:00:00Z" GIT_COMMITTER_DATE="2026-01-02T00:00:00Z" \
git -c commit.gpgsign=false commit -q -m "escape markup"
@@ -0,0 +1,39 @@
schema_version: "1.1"
name: complete-fix
description: A patch fixes the original unsafe character and the repeated-character variant without changing benign behavior.
tags: [post-patch-validation, clean-fix]
runs: 3
context:
scaffold_script: scaffold.sh
execution:
max_turns: 60
timeout_seconds: 1800
allowed_tools: [Read, Write, Edit, Bash, Glob, Grep, Skill, Workflow]
prompt: |-
Use the post-patch-validation skill to validate HEAD against the vulnerable tag.
Finding PPV-EVAL-1: render(value) returns attacker-controlled "<" characters without
escaping them, enabling markup injection. The demonstrated payload is a single "<".
Validate the root cause, not only that payload. The target is this local repository and
you are authorized to execute its code. Write the plan under post-patch-validation/plan.json
and results under post-patch-validation/results. Do not stop to ask questions.
graders:
- name: result-exists
type: file_exists
path: post-patch-validation/results/result.json
exists: true
weight: 1
- name: clean-fix-verdict
type: regex
target: {source: file, path: post-patch-validation/results/result.json}
pattern: '"code":\s*"S1"'
match: contains
weight: 2
# `"kind": "variant"` in result.json only proves a variant check was declared. The report row
# proves it did the job: failed as expected on the vulnerable base, then passed on the patch.
- name: variant-reproduced-then-fixed
type: regex
target: {source: file, path: post-patch-validation/results/report.md}
pattern: '\|\s*variant\s*\|\s*pass\s*\|\s*pass\s*\|'
match: contains
weight: 1
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
git -c init.defaultBranch=main init -q
git config user.name "PPV Eval"
git config user.email "ppv-eval@example.invalid"
cat >renderer.py <<'PY'
def render(value: str) -> str:
return value
PY
git add renderer.py
GIT_AUTHOR_DATE="2026-01-01T00:00:00Z" GIT_COMMITTER_DATE="2026-01-01T00:00:00Z" \
git -c commit.gpgsign=false commit -q -m "vulnerable renderer"
git tag vulnerable
cat >renderer.py <<'PY'
def render(value: str) -> str:
return value.replace("<", "&lt;")
PY
git add renderer.py
GIT_AUTHOR_DATE="2026-01-02T00:00:00Z" GIT_COMMITTER_DATE="2026-01-02T00:00:00Z" \
git -c commit.gpgsign=false commit -q -m "escape markup"
@@ -0,0 +1,40 @@
schema_version: "1.1"
name: missed-variant
description: A patch handles the demonstrated single character but misses repeated instances of the same root cause.
tags: [post-patch-validation, missed-variant]
runs: 3
context:
scaffold_script: scaffold.sh
execution:
max_turns: 60
timeout_seconds: 1800
allowed_tools: [Read, Write, Edit, Bash, Glob, Grep, Skill, Workflow]
prompt: |-
Use the post-patch-validation skill to validate HEAD against the vulnerable tag.
Finding PPV-EVAL-2: render(value) must escape every attacker-controlled "<" character.
The report demonstrated one character, but the root cause is unescaped markup at any
position and multiplicity. The target is this local repository and you are authorized to
execute its code. Write the plan under post-patch-validation/plan.json and results under
post-patch-validation/results. Do not stop to ask questions.
graders:
- name: result-exists
type: file_exists
path: post-patch-validation/results/result.json
exists: true
weight: 1
- name: not-fixed-verdict
type: regex
target: {source: file, path: post-patch-validation/results/result.json}
pattern: '"code":\s*"S3"'
match: contains
weight: 2
# Graded on the report's one-row-per-check table, not result.json. The obvious JSON regex
# (`"kind": "variant"` ... `"matched": false`) backtracks across check boundaries, so a
# PASSING variant plus any later failing check satisfies it. A table row cannot span checks.
- name: variant-failed-on-patch
type: regex
target: {source: file, path: post-patch-validation/results/report.md}
pattern: '\|\s*variant\s*\|\s*pass\s*\|\s*fail\s*\|'
match: contains
weight: 1
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
git -c init.defaultBranch=main init -q
git config user.name "PPV Eval"
git config user.email "ppv-eval@example.invalid"
cat >renderer.py <<'PY'
def render(value: str) -> str:
return value
PY
git add renderer.py
GIT_AUTHOR_DATE="2026-01-01T00:00:00Z" GIT_COMMITTER_DATE="2026-01-01T00:00:00Z" \
git -c commit.gpgsign=false commit -q -m "vulnerable renderer"
git tag vulnerable
cat >renderer.py <<'PY'
def render(value: str) -> str:
return value.replace("<", "&lt;", 1)
PY
git add renderer.py
GIT_AUTHOR_DATE="2026-01-02T00:00:00Z" GIT_COMMITTER_DATE="2026-01-02T00:00:00Z" \
git -c commit.gpgsign=false commit -q -m "escape demonstrated character"
@@ -0,0 +1,236 @@
---
name: post-patch-validation
description: >
Validates security patches with reproducible baseline-versus-patched evidence, including
original exploits, root-cause variants, behavior preservation, regressions, and newly
introduced security failures. Use after a patch exists and before accepting, merging, or
reporting it as fixed; also use when an AI-generated patch, remediation commit, pull request,
or proposed upstream fix needs adversarial post-patch validation across any language.
allowed-tools: Read Write Edit Grep Glob Bash Workflow
---
# Post-Patch Validation
Treat the patch as an untrusted hypothesis. Produce executable evidence in isolated Git
worktrees, then let the bundled runner assign the verdict. Never infer success from the diff,
the patch author, an upstream implementation, or the original proof of concept alone.
## When to Use
- A security fix, remediation commit, patch file, or pull request already exists.
- An AI-generated patch needs validation before human review or merge.
- A fix may cover one exploit path while missing variants of the same root cause.
- A security fix may alter legitimate behavior or introduce a new vulnerability.
- Another pipeline needs a deterministic final patch-validation gate.
## When NOT to Use
- No patch exists yet; use vulnerability discovery or fix implementation first.
- The task is to review an audit finding against a report without executing patch evidence.
- The task is only to convert a finding into a permanent project test.
- The target is remote or production. This skill executes local code and tests only.
- The user has not authorized execution of the repository's code or test suite.
## Quick Start
1. Pin the vulnerable base and patched input. Prefer immutable commits. For uncommitted work,
create a binary patch file first; do not validate in the user's working tree.
2. Scaffold a pinned plan:
```bash
uv run {baseDir}/scripts/post_patch_validation.py scaffold \
--repo . \
--base-ref <vulnerable-ref> \
--patched-ref <patched-ref> \
--finding-id <stable-id> \
--finding-summary "<root cause and impact>" \
--evidence-level runtime \
--output post-patch-validation/plan.json
```
Use `--patch-file <path>` instead of `--patched-ref` for a patch artifact. Choose the highest
honest evidence level: `source` for source/patch invariants only, `build` when target code is
compiled or analyzed but the reported behavior is not executed, or `runtime` when the checks
execute the reported behavior and its safety assertions.
3. Inspect the finding, diff, callers, sibling paths, cleanup/error paths, and existing tests.
Populate `checks` in the generated plan. Run `print-schema` for the structural schema:
```bash
uv run {baseDir}/scripts/post_patch_validation.py print-schema
```
4. Run `validate-plan` for the complete validation, including coverage, command restrictions,
and pinned inputs, before executing code:
```bash
uv run {baseDir}/scripts/post_patch_validation.py validate-plan \
--plan post-patch-validation/plan.json
```
5. Execute the evidence plan:
```bash
uv run {baseDir}/scripts/post_patch_validation.py run \
--plan post-patch-validation/plan.json \
--output post-patch-validation/results
```
6. Report `result.json`, `report.md`, the exact verdict, and every failing or inconclusive
check. An S1 result is ready for human review; it is not permission to merge.
## Evidence Contract
The runner rejects incomplete plans. Supply at least one check of every kind:
| Kind | Required observation |
|---|---|
| `control` | Benign harness succeeds on both base and patch |
| `exploit` | Original safety assertion fails on base and succeeds on patch |
| `variant` | A distinct root-cause variant fails on base and succeeds on patch |
| `behavior` | Unaffected behavior succeeds with byte-identical selected output |
| `regression` | Targeted non-security regression check succeeds on both revisions |
| `security` | Adjacent/new-vulnerability check succeeds on base and patch |
| `suite` | Existing project suite, sanitizer, or deterministic fuzz campaign succeeds on patch |
Commands are argv arrays, never shell strings. Put complex setup in a checked-in or plan artifact
script and invoke it with `{plan_dir}`. The runner fixes locale/timezone/hash-seed inputs, executes
checks in lexical ID order, records raw stdout/stderr, and never edits the original worktree.
Each check's `timeout_seconds` defaults to 300 and accepts integers from 1 through 3600.
Exceeding the timeout makes the run INCONCLUSIVE.
Every plan also contains a sorted `submodules` array (`[]` when none). Scaffolding infers affected
Gitlinks from the changed-file inventory. The runner initializes those pinned commits from the
source repository's existing Git module objects, never from `.gitmodules` network URLs; initialize
or fetch them in the source repository before validation.
### Exploit and variant checks must prove they ran
A nonzero exit does not mean the vulnerability reproduced. An import error, a failed build, a
missing dependency, and a failed safety assertion all exit nonzero and are indistinguishable to the
runner. Every `exploit` and `variant` check must print and flush `PPV_REACHED` immediately
before it evaluates its assertion, on both revisions:
```json
"argv": ["python3", "-c", "import app; print('PPV_REACHED', flush=True); assert app.render('<') == '&lt;'"]
```
The token is also in the environment as `PPV_REACHED_MARKER`. It must land on **stdout, as a line
of its own**. Stderr is not scanned, because a Python `SyntaxError` traceback echoes the offending
source and would otherwise satisfy the check for a harness that executed nothing. A run without it
is recorded as `marker_missing` and the whole result is INCONCLUSIVE. Flush explicitly: a harness
whose payload segfaults or calls `_exit` loses buffered output and forfeits its own evidence.
These checks also run **side-blind**. `{side}` is not expanded for them, `PPV_SIDE` is absent from
their environment, the checkout directory is randomly named, and the plan validator rejects any
`exploit` or `variant` check whose `argv` or `env` mentions either. An assertion that can see which
revision it is on can assert on *that* instead of on the code, which is the cheapest possible way
to fake a reproduction followed by a fix.
### Environment
Checks run under a fixed minimal environment: `PATH`, `HOME`, and a handful of temp/user keys,
plus `LANG`/`LC_ALL=C`, `TZ=UTC`, `PYTHONHASHSEED=0`, `NO_COLOR`, `TERM=dumb`. Everything else in
the caller's environment is dropped. Toolchains that need more get it explicitly:
```bash
uv run {baseDir}/scripts/post_patch_validation.py run \
--plan post-patch-validation/plan.json \
--output post-patch-validation/results \
--allow-env JAVA_HOME --allow-env CARGO_HOME
```
Forwarded names and values are recorded in `result.json`. A requested variable that is unset is an
error, not an empty string. Two classes are refused outright: names that read as credentials
(`*SECRET*`, `*TOKEN*`, `*API_KEY*`, …), because the value would be written into the result; and
names that change what executes (`LD_PRELOAD`, `BASH_ENV`, `NODE_OPTIONS`, `GIT_SSH_COMMAND`, …),
because forwarding those would quietly dismantle the isolation the verdict rests on.
The runner's fixed variables and every `PPV_*` name are also reserved and cannot be forwarded.
Placeholders expanded in `argv` and per-check `env` values: `{checkout}` (the revision under test),
`{plan_dir}` (an isolated copy of the plan artifacts for that one invocation), `{scratch}` (a
fresh opaque directory for that one check invocation), and
`{side}` (`base` or `patched`, and not available to `exploit`/`variant` checks). The same values
arrive as `PPV_CHECKOUT`, `PPV_PLAN_DIR`, `PPV_SCRATCH`, `PPV_SIDE`, and `PPV_CASE_ID`. Write only
under `{scratch}`; the evidence directory path is not passed to checks. Base and patched invocations
do not share runner-managed scratch, plan, or worktree roots. After each invocation exits, its
scratch tree is archived under the deterministic `results/scratch/<check-id-and-side>` path, its
private plan copy is discarded, and every readable argv element that resolves to a file is hashed
in `argv_files`. Files inside the isolated plan or checkout roots are additionally retained under
`results/helpers/<sha256>` up to 16 MiB; the record explains why any other file was not archived.
Use a dedicated directory for `plan.json`: its sibling files and directories are copied into each
invocation's `{plan_dir}`. Keep helper code under that directory's `checks/` directory or checked
into the target repository so its bytes are reviewable. The machine plan containing commit pins,
the current output directory, and detected prior result trees are excluded; symlinks are rejected.
The clean snapshot remains only in runner memory, and
exploit/variant sides execute in random order while evidence filenames remain deterministic.
Stdout/stderr use anonymous or randomly named capture descriptors and are copied to the named
evidence files only after the child exits, so fd inspection cannot disclose the side label.
This isolation is not a host sandbox: checks run with the caller's privileges and a malicious
helper could use arbitrary external state or deliberately infer the revision from source or Git
metadata. Inspect the content-addressed helper artifacts, and use an OS/container sandbox when the
check code itself is untrusted.
Active validation worktrees are Git-locked with random owner tokens backed by kernel file locks, so
another concurrent validator cannot prune them and PID reuse cannot impersonate an owner. If the
runner is forcibly killed, the next run unlocks stale validator-owned registrations. For manual
recovery, inspect `git worktree list`, then use `git worktree unlock <path>` and
`git worktree remove --force <path>` (or `git worktree prune` after the path is gone).
Read [evidence-model.md](references/evidence-model.md) when designing coverage, selecting
variants, or interpreting S1-S5 and INCONCLUSIVE. Do not read it for routine CLI execution.
## Coverage Rules
- Derive variants from the root cause, not cosmetic mutations of the original payload.
- Enumerate sibling call sites, alternate callbacks/outputs, error paths, teardown, ownership,
serialization, and boundary values touched by the fix.
- Make each exploit or variant test assert the safe behavior. It must fail on the vulnerable
base; a test that passes on both revisions proves nothing about remediation. Read the base-side
stderr and confirm the failure is the assertion you wrote, not a harness that never got there.
- Keep exploit and variant assertions limited to the security invariant. Test liveness, exact
error types/messages, timing, and compatibility separately as `behavior` or `regression` checks;
otherwise an unrelated contract change can masquerade as proof that the vulnerability remains.
- Keep the `control` harness benign and make it exercise the changed component. It
is the only check that says the worktree can run at all, and its failure is INCONCLUSIVE.
- Use `behavior` only for behavior that should remain unchanged. Exact output comparison is
deliberate; move unstable values behind a deterministic test harness instead of normalizing
them away in prose.
- Make `security` checks pass on the vulnerable base before treating a patched failure as newly
introduced. Otherwise the runner returns INCONCLUSIVE rather than inventing causality.
- Do not edit the patch during validation. Return failures to the patch author and start a new,
freshly pinned run.
## Claude Dynamic Workflow
Claude Code exposes the bundled workflow as `/post-patch-validation:validate-patch`.
To pass structured inputs through the `Workflow` tool, use the name without a leading slash:
```javascript
Workflow({
name: 'post-patch-validation:validate-patch',
args: {
finding: '<finding text or local path>',
baseRef: '<vulnerable-ref>',
patchRef: '<patched-ref>',
workdir: 'post-patch-validation',
},
})
```
Use `patchFile` instead of `patchRef` when appropriate. The workflow uses fixed coverage
lenses to propose checks and a fixed executor to run this skill. Agents may author test
artifacts, but they do not vote on the S-score: only the Python runner classifies evidence.
It cannot ask questions after launch, so pass every input up front.
## Rationalizations to Reject
| Rationalization | Required response |
|---|---|
| "The original PoC no longer works" | Test at least one independent root-cause variant |
| "The exploit failed on base, so it reproduced" | Confirm the marker and that the failure is the assertion, not a broken harness |
| "The full suite passes" | Prove baseline reproduction and targeted behavior explicitly |
| "This matches the upstream/canonical patch" | Treat provenance as context, not evidence |
| "The diff is tiny" | Exercise callers, failure paths, and teardown affected by the change |
| "The validator says S1" | Preserve artifacts and require human review |
| "A flaky rerun passed" | Keep the first pinned result; fix nondeterminism before retrying |
| "There is no obvious variant" | Inspect sibling sites and boundaries; otherwise stop INCONCLUSIVE |
@@ -0,0 +1,4 @@
interface:
display_name: "Post-Patch Validation"
short_description: "Validate security patches with reproducible evidence"
default_prompt: "Use $post-patch-validation to validate this security patch against the finding and its variants."
@@ -0,0 +1,182 @@
# Evidence Model
Use this reference while authoring the validation plan or interpreting its result.
## Contents
- Design basis
- Building independent evidence
- Classification
- Human handoff
## Design basis
Patch correctness is not binary. Empirical reviews of model-generated vulnerability patches
show recurring failures: fixing only the demonstrated path, changing legitimate behavior,
introducing a second vulnerability, accepting incorrect guidance despite contrary evidence,
and copying a flawed upstream pattern. Validators also disagree often enough that a prose-only
second opinion is not a reliable gate.
This skill therefore separates two jobs:
1. Agents or humans identify the root cause and author candidate checks.
2. Code pins the inputs, executes the checks, preserves the evidence, and assigns the verdict.
The runner is deliberately strict. Missing evidence is INCONCLUSIVE, not an optimistic pass.
Every result also states its evidence level. `source` means only source or patch invariants ran;
`build` means target code was compiled or analyzed without executing the reported behavior; and
`runtime` means the reported behavior and its safety assertions executed. This is a declared scope,
not a verdict multiplier: even S1 establishes no more than its stated evidence level and supplied
coverage.
## Building independent evidence
### Baseline first
An exploit test expresses the safe postcondition. It must fail against the vulnerable base and
pass against the patch. A test that never reproduced the vulnerability cannot establish a fix.
The exit code alone cannot carry that claim. `ImportError`, a failed build, a missing shared
library, a typo'd module name, and a failed assertion all exit nonzero, and the runner sees only
the number. Treating any nonzero base exit as reproduction is how a harness that never executed
becomes an S1: it "fails" on base for an unrelated reason, then "passes" on the patch where the
same unrelated reason happens not to bite.
So `exploit` and `variant` checks must print `PPV_REACHED`, flushed, immediately before evaluating
the assertion. A nonzero run without the marker is `marker_missing` and the result is INCONCLUSIVE.
Place the marker after setup and after reaching the vulnerable call, immediately before
the comparison, because everything between the marker and the assertion is still unproven. Flush
explicitly; a payload that segfaults or calls `_exit` discards buffered output and its own evidence
along with it.
The marker must land on stdout as a line of its own. Scanning stderr matched the token inside a
Python `SyntaxError` traceback, which echoes the offending source line, so a harness that never
executed a statement passed the check that exists to catch precisely that.
`exploit` and `variant` checks additionally run side-blind: no `{side}`, no `PPV_SIDE`, a randomly
named checkout, and plan-time rejection of any check that mentions either. Their assertion must be
identical on both revisions by construction, so a check that can identify its revision can only be
using that fact to fake the result.
The marker proves the harness ran. It does not prove the harness was *correct*: a check asserting
the wrong postcondition still fails on base and passes on patch. That remains a human-review job,
which is why `plan.snapshot.json`, the raw stderr, and content-addressed helper copies ship with
every result. `argv_files` records each file's argv index, original argument, SHA-256 digest,
optional archived path, and any reason it could not be archived. Readable files under the isolated
plan and checkout roots are archived up to 16 MiB; external paths are hashed but never copied, so a
data-file argument cannot silently vendor a host credential into the evidence. `argv0_sha256` also
records the resolved executable digest for compatibility with earlier result readers.
Keep that postcondition to the security invariant. Whether the operation remains live, returns an
exact error type or message, meets a timing property, or stays compatible with existing callers is
important, but it is separate behavior/regression evidence. Combining those contracts into an
exploit assertion lets a benign compatibility change manufacture S3 even when the security
invariant is fixed.
Each base or patched invocation receives a fresh opaque scratch directory and an isolated copy of
a clean plan-artifact snapshot. The snapshot excludes the machine plan containing revision pins,
symlinks, the live evidence tree, and detected prior result trees. The runner archives scratch under
a deterministic evidence path only after the process exits and discards the private plan copy.
Base and patched worktrees also use separate, randomly named validator-owned roots, and temp
environment variables point to per-invocation scratch. These measures remove runner-provided shared
writable channels that could otherwise reveal execution order or manufacture the expected base/fix
sequence. Exploit and variant sides execute in random order while retaining deterministic evidence
filenames, and the clean plan-artifact snapshot lives only as immutable runner memory between
invocations. Child stdout/stderr first go to anonymous or randomly named capture descriptors; the
runner copies them to side-labelled evidence paths only after the child exits, so inspecting fd 1
or fd 2 does not reveal the logical side or retained evidence directory.
This is evidence isolation, not an operating-system sandbox. Checks execute with the caller's
privileges and could deliberately communicate through arbitrary host paths, services, or process
inspection. A deliberately adversarial check can also inspect source differences or Git metadata
in the revision it must execute. Review the archived helper bytes before trusting a verdict, and
run untrusted helpers inside a sandbox chosen for the target's threat model.
### Root-cause variants
Choose a variant that reaches the same invariant violation through a meaningfully different
path. Useful dimensions include:
- sibling call sites or alternate public entry points;
- callback, direct-output, streaming, and buffered modes;
- success, error, cancellation, and teardown paths;
- minimum, maximum, empty, repeated, and nested inputs;
- ownership transfer, reference counts, aliases, and object lifetime;
- parser/serializer asymmetry and encode/decode direction;
- concurrency ordering or state-machine transition.
Changing only a literal, filename, or payload size is not independent unless that boundary is
the root cause.
### Behavior preservation
Use a stable benign input whose behavior should not change. The runner compares the selected
stdout, stderr, or combined stream byte-for-byte across base and patch. Put timestamps, random
IDs, absolute paths, addresses, and ordering behind a deterministic harness; do not hide them with
ad hoc output scrubbing. Checkout, plan, and scratch paths differ between invocations.
### New-vulnerability evidence
A `security` check must pass on the base and patch. If it passes on base but fails on patch, the
runner can attribute the new failure to the patch. High-value checks exercise cleanup, ownership,
error handling, authorization, bounds, and state transitions adjacent to changed code.
### Existing suite
Run the narrow deterministic project suite first, then broader tests, sanitizers, or a bounded
fuzz corpus when available. Pin seeds, corpus, worker count, and iteration/time budgets. A suite
supplements targeted evidence; it does not replace it.
Suite checks run only on the patched revision. A suite failure can produce S2 even when the
failure predates the patch; that result alone does not establish a regression caused by the patch.
## Classification
| Code | Meaning | Deterministic condition |
|---|---|---|
| S1 | Clean fix | Exploit and variants fixed; behavior and security checks pass |
| S2 | Fixed with behavior change | Exploit and variants fixed; behavior, regression, or suite fails |
| S3 | Not fixed | Exploit or variant still fails on the patch; no new security failure |
| S4 | Fixed with new vulnerability | Exploit and variants fixed; a base-clean security check fails on patch |
| S5 | Not fixed and new vulnerability | S3 and S4 conditions both hold |
| INCONCLUSIVE | Evidence cannot support a class | Missing category, baseline failure, missing marker, patched-side control failure, timeout, execution error, pin mismatch, or cleanup failure |
Classification precedence is S5, S3/S4 as applicable, S2, then S1. A behavior regression does
not conceal an unfixed vulnerability. Every result sets `human_review_required` to true.
`control` is deliberately not an S2 signal. Its job is to show that both worktrees can execute
the component at all, so a control failure on the patched side invalidates every other patched
observation rather than describing a behavior change. Reporting that as "fixed with behavior
change" would assert a fix on the strength of a harness that demonstrably stopped working.
### Environment determinism
Checks inherit a fixed minimal environment. Anything a real toolchain needs beyond `PATH` and
`HOME` is forwarded by name with `--allow-env`, and the forwarded names and values land in
`result.json` so a reviewer can see exactly what the run depended on. Never forward credentials;
a requested variable that is unset fails the run rather than arriving empty. Fixed deterministic
variables and all `PPV_*` names are reserved so forwarded input cannot replace runner-controlled
state.
Affected submodules are explicit, sorted paths in the plan. Their pinned objects must already exist
in the source repository: validation redirects initialization to those local module stores and does
not contact `.gitmodules` URLs. The result records base and patched submodule commits alongside the
top-level pins.
## Human handoff
Give the reviewer:
- `plan.snapshot.json` and the finding/root-cause statement;
- `patch.diff` and its SHA-256 pin;
- `result.json` and `report.md`;
- raw per-check stdout/stderr;
- content-addressed helper files under `helpers/`, cross-referenced by each run's `argv_files`;
- archived per-invocation scratch trees;
- `artifact-manifest.json` for integrity;
- any coverage concern that was not converted into an executable check.
Human review should inspect whether the declared root-cause surface is complete. The runner can
prove that the supplied checks behaved as claimed; it cannot prove that an omitted path does not
exist.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,22 @@
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = PLUGIN_ROOT / "skills" / "post-patch-validation" / "scripts" / "post_patch_validation.py"
@pytest.fixture(scope="session")
def ppv():
spec = importlib.util.spec_from_file_location("post_patch_validation", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
# Register before exec: with `from __future__ import annotations`, @dataclass resolves its
# field types through sys.modules[cls.__module__], which is None for an unregistered module.
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
@@ -0,0 +1,35 @@
import fs from 'node:fs'
import vm from 'node:vm'
export function loadFunction(path, name) {
const source = fs.readFileSync(path, 'utf8')
const marker = `function ${name}(`
const start = source.indexOf(marker)
if (start < 0) throw new Error(`function not found: ${name}`)
const brace = source.indexOf('{', start)
let depth = 0
let quote = null
let escaped = false
for (let index = brace; index < source.length; index += 1) {
const char = source[index]
if (quote) {
if (escaped) escaped = false
else if (char === '\\') escaped = true
else if (char === quote) quote = null
continue
}
if (char === "'" || char === '"' || char === '`') {
quote = char
continue
}
if (char === '{') depth += 1
if (char === '}') {
depth -= 1
if (depth === 0) {
const body = source.slice(start, index + 1)
return vm.runInNewContext(`(${body})`)
}
}
}
throw new Error(`unterminated function: ${name}`)
}
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
plugin_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
uv run --with pytest --with pyyaml --no-project \
python3 -m pytest -q --rootdir "${plugin_root}" -p no:cacheprovider "${plugin_root}/tests"
@@ -0,0 +1,69 @@
from __future__ import annotations
import hashlib
import subprocess
import sys
from pathlib import Path
import pytest
from conftest import SCRIPT
@pytest.mark.parametrize(
"argv",
[[], ["unknown-command"], ["print-schema", "--typo"], ["run"], ["validate-plan"]],
)
def test_usage_errors_return_64(argv: list[str]) -> None:
result = subprocess.run([sys.executable, str(SCRIPT), *argv], capture_output=True)
assert result.returncode == 64
assert b"usage:" in result.stderr
assert b"error:" in result.stderr
assert b"Traceback" not in result.stderr
@pytest.mark.parametrize("argv", [["--help"], ["run", "--help"]])
def test_help_returns_zero(argv: list[str]) -> None:
result = subprocess.run([sys.executable, str(SCRIPT), *argv], capture_output=True)
assert result.returncode == 0
assert b"usage:" in result.stdout
assert result.stderr == b""
def test_invalid_utf8_plan_returns_64(tmp_path: Path) -> None:
plan = tmp_path / "plan.json"
plan.write_bytes(b'{"summary": "\xff"}')
result = subprocess.run(
[sys.executable, str(SCRIPT), "validate-plan", "--plan", str(plan)],
capture_output=True,
)
assert result.returncode == 64
assert b"cannot read JSON" in result.stderr
assert b"plan.json" in result.stderr
assert b"Traceback" not in result.stderr
def test_manifest_includes_nested_manifest_files(tmp_path: Path, ppv) -> None:
(tmp_path / "artifact-manifest.json").write_bytes(b"runner manifest")
nested = tmp_path / "scratch" / "check" / "artifact-manifest.json"
nested.parent.mkdir(parents=True)
content = b"check artifact\n"
nested.write_bytes(content)
manifest = ppv.artifact_manifest(tmp_path)
assert manifest["files"] == [
{
"path": "scratch/check/artifact-manifest.json",
"sha256": hashlib.sha256(content).hexdigest(),
"bytes": len(content),
}
]
@pytest.mark.parametrize("own_manifest_exists", [False, True])
def test_manifest_rejects_zero_evidence_files(
tmp_path: Path, ppv, own_manifest_exists: bool
) -> None:
if own_manifest_exists:
(tmp_path / "artifact-manifest.json").write_bytes(b"runner manifest")
with pytest.raises(ppv.PlanError, match="zero files"):
ppv.artifact_manifest(tmp_path)
@@ -0,0 +1,130 @@
from __future__ import annotations
import re
import subprocess
from pathlib import Path
import yaml
EVALS = Path(__file__).resolve().parents[1] / "evals"
EXPECTED = {"complete-fix", "missed-variant", "behavior-regression"}
GOLDENS = {
"complete-fix": (
'{"code": "S1"} | `03-variant` | variant | pass | pass |',
'{"code": "S3"} | `03-variant` | variant | pass | fail |',
),
"missed-variant": (
'{"code": "S3"} | `03-variant` | variant | pass | fail |',
'{"code": "S1"} | `03-variant` | variant | pass | pass |',
),
"behavior-regression": (
'{"code": "S2"}\n| `04-behavior` | behavior | pass | pass | changed |',
'{"code": "S1"}\n| `04-behavior` | behavior | pass | pass | same |',
),
}
# The shape that broke the original result.json grader: a variant that PASSED on the patch,
# followed by an unrelated check that failed. `[\s\S]*?` backtracks across the check boundary,
# so the old pattern read this as "the variant failed". Report rows cannot span checks.
SPANNING_DEFECT = """\
| `02-exploit-original` | exploit | pass | pass | — |
| `03-variant-repeated` | variant | pass | pass | — |
| `05-regression-empty` | regression | pass | fail | — |
"""
def test_three_outcome_evals_have_deterministic_graders() -> None:
cases = sorted(EVALS.glob("*/case.yaml"))
assert {path.parent.name for path in cases} == EXPECTED
for path in cases:
case = yaml.safe_load(path.read_text())
assert case["name"] == path.parent.name
assert case["runs"] == 3
assert case["execution"]["timeout_seconds"] == 1800
graders = case["graders"]
assert any(grader["type"] == "file_exists" for grader in graders)
assert any(grader["type"] == "regex" for grader in graders)
assert all(grader["type"] != "llm" for grader in graders)
def test_eval_regexes_accept_goldens_and_reject_defects() -> None:
asserted = 0
for path in sorted(EVALS.glob("*/case.yaml")):
case = yaml.safe_load(path.read_text())
golden, defective = GOLDENS[case["name"]]
for grader in case["graders"]:
if grader["type"] != "regex":
continue
pattern = re.compile(grader["pattern"])
assert pattern.search(golden), grader["name"]
assert not pattern.search(defective), grader["name"]
asserted += 1
assert asserted == 6
def test_variant_graders_cannot_match_across_check_boundaries() -> None:
"""A passing variant plus any later failing check must not read as an unfixed variant."""
old_pattern = r'"kind":\s*"variant"[\s\S]*?"patched":\s*\{[\s\S]*?"matched":\s*false'
spanning_json = (
'"kind": "variant", "runs": {"patched": {"matched": true}}, '
'"kind": "regression", "runs": {"patched": {"matched": false}}'
)
assert re.search(old_pattern, spanning_json), "fixture no longer reproduces the old defect"
case = yaml.safe_load((EVALS / "missed-variant" / "case.yaml").read_text())
patterns = [g["pattern"] for g in case["graders"] if g["type"] == "regex"]
variant = [p for p in patterns if "variant" in p]
assert variant, "missed-variant lost its variant grader"
genuine = SPANNING_DEFECT.replace("variant | pass | pass", "variant | pass | fail")
for pattern in variant:
assert not re.search(pattern, SPANNING_DEFECT)
assert re.search(pattern, genuine)
def test_behavior_grader_accepts_either_s2_signal() -> None:
case = yaml.safe_load((EVALS / "behavior-regression" / "case.yaml").read_text())
pattern = next(
grader["pattern"]
for grader in case["graders"]
if grader["name"] == "behavior-row-shows-regression"
)
for row in (
"| `04-behavior` | behavior | pass | pass | changed |",
"| `04-behavior` | behavior | pass | fail | same |",
"| `04-behavior` | behavior | pass | fail | changed |",
):
assert re.search(pattern, row)
assert not re.search(pattern, "| `04-behavior` | behavior | pass | pass | same |")
def test_eval_scaffolds_create_clean_two_commit_repositories(tmp_path: Path) -> None:
ran = 0
for directory in sorted(path.parent for path in EVALS.glob("*/case.yaml")):
workdir = tmp_path / directory.name
workdir.mkdir()
subprocess.run(["bash", str(directory / "scaffold.sh")], cwd=workdir, check=True)
base = subprocess.run(
["git", "rev-parse", "vulnerable"],
cwd=workdir,
text=True,
stdout=subprocess.PIPE,
check=True,
).stdout.strip()
head = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=workdir,
text=True,
stdout=subprocess.PIPE,
check=True,
).stdout.strip()
status = subprocess.run(
["git", "status", "--short"],
cwd=workdir,
text=True,
stdout=subprocess.PIPE,
check=True,
).stdout
assert base != head
assert status == ""
ran += 1
assert ran == 3
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
WORKFLOW = Path(__file__).resolve().parents[1] / "workflows" / "validate-patch.js"
# The meta object is the only top-level brace closed at column 0; everything after it is body.
META_END_RE = re.compile(r"^\}$", flags=re.MULTILINE)
def source() -> str:
assert WORKFLOW.is_file(), f"workflow missing: {WORKFLOW}"
return WORKFLOW.read_text()
def test_node_is_available() -> None:
assert shutil.which("node"), "node is required for the syntax check and must not be skipped"
def test_workflow_is_valid_javascript(tmp_path: Path) -> None:
"""`node --check` on the script body, wrapped the way the runtime wraps it.
The raw file parses under neither module system: as CommonJS it trips on `export`, and as
an ES module it trips on the top-level `return` guards. Both are legal in the Workflow
runtime, which strips `meta` and runs the body in an async function. Checking the file
as-shipped therefore fails on every Node version, which is how this test shipped red.
"""
text = source()
end = META_END_RE.search(text)
assert end, "could not locate the end of the meta block"
body = text[end.end() :]
wrapped = tmp_path / "validate-patch.check.mjs"
wrapped.write_text(f"async function __wf(args, log, phase, agent, parallel) {{\n{body}\n}}\n")
result = subprocess.run(
["node", "--check", str(wrapped)],
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_meta_and_phase_calls_agree() -> None:
text = source()
meta_block = text[text.index("phases: [") : text.index(" ],", text.index("phases: ["))]
declared = re.findall(r"title: '([^']+)'", meta_block)
called = re.findall(r"^phase\('([^']+)'\)$", text, flags=re.MULTILINE)
assert declared == ["Inventory", "Coverage", "Plan", "Execute", "Review"]
assert called == declared
def test_every_agent_call_has_a_schema_and_phase() -> None:
text = source()
fragments = text.split("agent(")[1:]
assert len(fragments) == 5, "expected five fixed agent call sites"
for fragment in fragments:
before_next = fragment.split("agent(", 1)[0]
assert "{ schema:" in before_next
assert "phase:" in before_next
assert text.count("additionalProperties: false") >= 5
def test_workflow_has_no_nondeterministic_or_self_verification_scaffolding() -> None:
text = source()
for banned in ["Math.random", "Date.now", "new Date", "Promise.race", "double-check"]:
assert banned not in text
def test_machine_verdict_is_not_changed_by_reviewers() -> None:
text = source()
assert "const status = finalStatus(execution.verdict, reviews)" in text
assert "deterministicVerdict: execution.verdict" in text
assert "if (verdict !== 'S1') return 'REJECTED'" in text
def test_workflow_carries_evidence_scope_and_check_contracts() -> None:
text = source()
assert "--evidence-level source" in text
assert "evidenceLevel: execution.evidenceLevel" in text
assert "Exploit and variant assertions must" in text
assert "liveness, exact error type, timing, and compatibility" in text
assert "Preserve the scaffolded submodules list" in text
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadFunction } from './extract.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const workflow = path.join(here, '..', 'workflows', 'validate-patch.js')
const normalizeArgs = loadFunction(workflow, 'normalizeArgs')
const argProblems = loadFunction(workflow, 'argProblems')
const finalStatus = loadFunction(workflow, 'finalStatus')
const plain = value => JSON.parse(JSON.stringify(value))
test('structured input is normalized without widening patch scope', () => {
assert.deepEqual(
plain(
normalizeArgs({
finding: ' finding.md ',
baseRef: ' vulnerable ',
patchRef: ' fix ',
workdir: ' evidence ',
}),
),
{
finding: 'finding.md',
baseRef: 'vulnerable',
patchRef: 'fix',
patchFile: '',
workdir: 'evidence',
},
)
})
test('patch files suppress the default HEAD ref', () => {
const input = normalizeArgs({ finding: 'bug', baseRef: 'base', patchFile: 'fix.patch' })
assert.equal(input.patchFile, 'fix.patch')
assert.equal(input.patchRef, '')
assert.deepEqual(plain(argProblems(input)), [])
})
test('explicit patch refs and patch files are mutually exclusive', () => {
const input = normalizeArgs({
finding: 'bug',
baseRef: 'base',
patchRef: 'fix-commit',
patchFile: 'fix.patch',
})
assert.equal(input.patchRef, 'fix-commit')
assert.equal(input.patchFile, 'fix.patch')
assert.ok(argProblems(input).includes('only one of patchRef or patchFile'))
})
test('missing baseline and unsafe workdirs fail before dispatch', () => {
const input = normalizeArgs({ finding: 'bug', workdir: '../escape' })
const problems = argProblems(input)
assert.ok(problems.includes('baseRef'))
assert.ok(problems.some(value => value.startsWith('workdir')))
})
test('only unanimous review can advance S1 to human review', () => {
const ok = approved => ({ approved, evidenceRead: ['results/result.json'] })
const yes = [ok(true), ok(true)]
assert.equal(finalStatus('S1', yes), 'READY_FOR_HUMAN_REVIEW')
assert.equal(finalStatus('S1', [ok(true), ok(false)]), 'REVIEW_REQUIRED')
assert.equal(finalStatus('S1', [yes[0]]), 'REVIEW_REQUIRED')
for (const verdict of ['S2', 'S3', 'S4', 'S5', 'INCONCLUSIVE']) {
assert.equal(finalStatus(verdict, yes), 'REJECTED')
}
})
test('a reviewer that read nothing cannot approve', () => {
const read = { approved: true, evidenceRead: ['results/result.json'] }
for (const empty of [{ approved: true, evidenceRead: [] }, { approved: true }]) {
assert.equal(finalStatus('S1', [read, empty]), 'REVIEW_REQUIRED')
}
})
@@ -0,0 +1,377 @@
export const meta = {
name: 'validate-patch',
description:
'Build and execute an evidence plan for an existing security patch, then return the deterministic S1-S5 verdict plus independent coverage concerns',
whenToUse:
'Use after a security patch exists. Pass finding, baseRef, and patchRef or patchFile. The workflow runs local project code and cannot ask for missing input after launch.',
phases: [
{ title: 'Inventory', detail: 'Pin the finding, baseline, patch, diff hash, and changed files' },
{ title: 'Coverage', detail: 'Map variants, behavior, adjacent security, and test infrastructure' },
{ title: 'Plan', detail: 'Create executable checks and pass the machine plan validator' },
{ title: 'Execute', detail: 'Run checks in isolated worktrees and assign the S-score in Python' },
{ title: 'Review', detail: 'Flag omitted paths or evidence that did not exercise real code' },
],
}
const INVENTORY_SCHEMA = {
type: 'object',
additionalProperties: false,
required: [
'planPath',
'baseCommit',
'patchSha256',
'changedFiles',
'evidenceLevel',
'submodules',
'findingSummary',
],
properties: {
planPath: { type: 'string' },
baseCommit: { type: 'string' },
patchedCommit: { type: ['string', 'null'] },
patchSha256: { type: 'string' },
changedFiles: { type: 'array', items: { type: 'string' } },
evidenceLevel: { enum: ['source', 'build', 'runtime'] },
submodules: { type: 'array', items: { type: 'string' } },
findingSummary: { type: 'string' },
},
}
const PROPOSAL_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['lens', 'observations', 'proposals'],
properties: {
lens: { type: 'string' },
observations: { type: 'array', items: { type: 'string' } },
proposals: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['id', 'kind', 'rationale', 'covers', 'strategy'],
properties: {
id: { type: 'string' },
kind: {
enum: ['control', 'exploit', 'variant', 'behavior', 'regression', 'security', 'suite'],
},
rationale: { type: 'string' },
covers: { type: 'array', items: { type: 'string' } },
strategy: { type: 'string' },
},
},
},
},
}
const PLAN_SCHEMA = {
type: 'object',
additionalProperties: false,
required: [
'complete',
'planPath',
'checkCount',
'coverage',
'evidenceLevel',
'validationOutput',
],
properties: {
complete: { type: 'boolean' },
planPath: { type: 'string' },
checkCount: { type: 'integer' },
coverage: {
type: 'array',
items: { enum: ['control', 'exploit', 'variant', 'behavior', 'regression', 'security', 'suite'] },
},
evidenceLevel: { enum: ['source', 'build', 'runtime'] },
validationOutput: { type: 'string' },
blocker: { type: 'string' },
allowEnv: { type: 'array', items: { type: 'string' } },
},
}
const EXECUTION_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['resultPath', 'reportPath', 'verdict', 'label', 'evidenceLevel', 'reasons'],
properties: {
resultPath: { type: 'string' },
reportPath: { type: 'string' },
verdict: { enum: ['S1', 'S2', 'S3', 'S4', 'S5', 'INCONCLUSIVE'] },
label: { type: 'string' },
evidenceLevel: { enum: ['source', 'build', 'runtime'] },
reasons: { type: 'array', items: { type: 'string' } },
},
}
const REVIEW_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['lens', 'approved', 'concerns', 'evidenceRead'],
properties: {
lens: { type: 'string' },
approved: { type: 'boolean' },
concerns: { type: 'array', items: { type: 'string' } },
// A reviewer that read nothing cannot approve anything. Without minItems an agent could
// return approved:true with empty arrays and advance an S1 having inspected no evidence.
evidenceRead: { type: 'array', items: { type: 'string' }, minItems: 1 },
},
}
function normalizeArgs(raw) {
const trim = value => (typeof value === 'string' ? value.trim() : '')
const source = typeof raw === 'string' ? { finding: raw } : raw && typeof raw === 'object' ? raw : {}
const patchFile = trim(source.patchFile)
const explicitPatchRef = trim(source.patchRef)
return {
finding: trim(source.finding),
baseRef: trim(source.baseRef),
patchRef: explicitPatchRef || (patchFile ? '' : 'HEAD'),
patchFile,
workdir: trim(source.workdir) || 'post-patch-validation',
}
}
function argProblems(value) {
const problems = []
if (!value.finding) problems.push('finding')
if (!value.baseRef) problems.push('baseRef')
if (!value.patchRef && !value.patchFile) problems.push('patchRef or patchFile')
if (value.patchRef && value.patchFile) problems.push('only one of patchRef or patchFile')
if (
value.workdir.startsWith('/') ||
value.workdir.split('/').some(part => part === '..' || part === '')
) {
problems.push('workdir (must be a non-empty relative path without ..)')
}
return problems
}
function finalStatus(verdict, reviews) {
if (verdict !== 'S1') return 'REJECTED'
if (!Array.isArray(reviews) || reviews.length !== 2) return 'REVIEW_REQUIRED'
if (reviews.some(review => !review || !review.approved)) return 'REVIEW_REQUIRED'
// Approval only counts when the reviewer names what it read. An empty evidenceRead is a
// reviewer that rubber-stamped, which is indistinguishable from no review at all.
if (reviews.some(review => !Array.isArray(review.evidenceRead) || !review.evidenceRead.length)) {
return 'REVIEW_REQUIRED'
}
return 'READY_FOR_HUMAN_REVIEW'
}
const input = normalizeArgs(args)
const problems = argProblems(input)
if (problems.length) {
log(`BLOCKED: missing or unsafe workflow input: ${problems.join(', ')}`)
return {
status: 'BLOCKED',
deterministicVerdict: 'INCONCLUSIVE',
humanReviewRequired: true,
reason: `Supply structured args before launch: ${problems.join(', ')}`,
}
}
phase('Inventory')
const patchOption = input.patchFile
? `--patch-file ${JSON.stringify(input.patchFile)}`
: `--patched-ref ${JSON.stringify(input.patchRef)}`
const inventory = await agent(
`Load the post-patch-validation skill and perform only its scaffold step in the current Git
repository. This is a local validation; do not use the network and do not modify tracked project
files. Read the finding if it is a path, reduce it to one root-cause-and-impact sentence, and run
the bundled Python script with these inputs:
finding: ${input.finding}
base ref: ${input.baseRef}
patch option: ${patchOption}
scaffold flag: --evidence-level source
plan path: ${input.workdir}/plan.json
Use the finding's stable ID when one exists; otherwise use "patch-finding". Run one command at a
time with no pipes or chained shell operators. Return the values printed by scaffold.`,
{ schema: INVENTORY_SCHEMA, label: 'inventory', phase: 'Inventory' },
)
if (!inventory) {
return {
status: 'BLOCKED',
deterministicVerdict: 'INCONCLUSIVE',
humanReviewRequired: true,
reason: 'inventory agent returned no pinned plan',
}
}
phase('Coverage')
const LENSES = [
{
key: 'root-cause',
brief:
'Trace the vulnerable invariant through every sibling caller and alternate entry/output path. Propose the original exploit assertion plus at least one meaningfully distinct root-cause variant. Include error and teardown paths when ownership or lifetime is involved.',
},
{
key: 'behavior',
brief:
'Identify stable benign behavior that must remain byte-identical, plus targeted non-security regressions at the boundaries changed by the patch. Avoid outputs containing time, randomness, addresses, or unordered collections.',
},
{
key: 'adjacent-security',
brief:
'Look specifically for a new vulnerability introduced by the patch: ownership, cleanup, authorization, bounds, concurrency, error handling, and state-transition regressions. Propose checks that should pass on both base and patch.',
},
{
key: 'harness',
brief:
'Find the smallest deterministic project-native build/test command, sanitizer, or bounded fixed-seed fuzz corpus. Also propose a benign harness control that proves both worktrees can execute the relevant component.',
},
]
const proposals = await parallel(
LENSES.map(lens => () =>
agent(
`Read the finding, the pinned plan at ${inventory.planPath}, the changed files, and both Git
revisions without checking either revision out over the user's working tree. Work read-only.
Your coverage lens is ${lens.key}: ${lens.brief}
Return concrete check proposals for the post-patch-validation plan. Each proposal needs a stable
ID, one supported kind, a falsifiable rationale, the exact code path/invariant it covers, and an
implementation strategy. Do not claim a path is covered merely because the diff mentions it.
Do not write tests yet; the Plan phase owns all artifacts.`,
{ schema: PROPOSAL_SCHEMA, label: `coverage:${lens.key}`, phase: 'Coverage' },
),
),
)
if (proposals.length !== LENSES.length || proposals.some(value => !value)) {
return {
status: 'BLOCKED',
deterministicVerdict: 'INCONCLUSIVE',
humanReviewRequired: true,
reason: 'one or more fixed coverage lenses returned no result',
}
}
phase('Plan')
const planned = await agent(
`Load the post-patch-validation skill. Turn the fixed-lens proposals below into a complete,
executable validation plan at ${inventory.planPath}. You may write helper test artifacts only
under ${input.workdir}/checks; do not edit tracked project files or the patch.
PROPOSALS
${JSON.stringify(proposals, null, 2)}
Run the bundled script's print-schema command before editing the plan. Use argv arrays and the
{checkout}, {plan_dir}, {scratch}, and {side} placeholders; never use shell command strings, pipes,
redirections, or chained commands, and write only under {scratch}. The {side} placeholder and
PPV_SIDE are unavailable to exploit and variant checks. Every helper must invoke real project code.
Sort checks by ID and supply every required kind. Exploit and variant safety
assertions must fail on the vulnerable base and pass on the patch, and each must print PPV_REACHED
flushed immediately before its assertion or the runner will return INCONCLUSIVE. Security checks
must pass on both revisions. Behavior output must be stable enough for exact comparison.
Replace the scaffolded source evidence_level with the highest level the completed checks honestly
support: keep source when only source or patch invariants run, use build when target code is
compiled or analyzed without executing the reported behavior, and use runtime only when the
exploit and variant checks execute the affected component. Return that value as evidenceLevel.
Exploit and variant assertions must
test only the security invariant; put liveness, exact error type, timing, and compatibility in
behavior or regression checks. Preserve the scaffolded submodules list unless another pinned
submodule is demonstrably required.
Checks run under a fixed minimal environment. If the project's toolchain needs host variables
beyond PATH and HOME, list their names in allowEnv rather than hardcoding host paths into the
plan. Never list a credential; the values are recorded in result.json.
Run validate-plan when done. If the evidence cannot honestly cover every required kind, do not
invent a check: return complete=false with the blocker and preserve the incomplete plan.`,
{ schema: PLAN_SCHEMA, label: 'plan', phase: 'Plan' },
)
if (!planned || !planned.complete) {
return {
status: 'BLOCKED',
deterministicVerdict: 'INCONCLUSIVE',
humanReviewRequired: true,
reason: planned ? planned.blocker || planned.validationOutput : 'plan agent returned nothing',
planPath: planned ? planned.planPath : inventory.planPath,
}
}
phase('Execute')
const allowEnvFlags = (Array.isArray(planned.allowEnv) ? planned.allowEnv : [])
.filter(name => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
.map(name => `--allow-env ${name}`)
.join(' ')
const execution = await agent(
`Load the post-patch-validation skill and execute its Python runner exactly once:
plan: ${planned.planPath}
output: ${input.workdir}/results
extra flags: ${allowEnvFlags || '(none)'}
The runner intentionally exits nonzero for S2-S5 and INCONCLUSIVE. A nonzero Bash result is not a
reason to rerun it. Read result.json after the command, return its exact verdict, label, evidence
level, reasons, and artifact paths, and do not edit the plan, patch, checks, or result.`,
{ schema: EXECUTION_SCHEMA, label: 'execute', phase: 'Execute' },
)
if (!execution) {
return {
status: 'BLOCKED',
deterministicVerdict: 'INCONCLUSIVE',
humanReviewRequired: true,
reason: 'execution agent returned no machine result',
planPath: planned.planPath,
}
}
phase('Review')
const REVIEW_LENSES = [
{
key: 'surface-completeness',
brief:
'Try to find a root-cause sibling, alternate entry/output path, error path, boundary, or teardown path omitted from the declared covers fields. Read the relevant source rather than trusting the plan summary.',
},
{
key: 'evidence-integrity',
brief:
'Read result.json, patch.diff, plan.snapshot.json, the raw logs, and every content-addressed helper artifact referenced by each run\'s argv_files (including scripts passed to interpreters, not only argv[0]). Verify the recorded hashes, and reject a relevant helper whose argv_files record has no artifact. Reject mocks, reimplemented vulnerable logic, a test that never invokes real project code, empty output, a harness that prints the marker without exercising the vulnerable path, or commands whose observations do not support their declared kind.',
},
]
const reviews = await parallel(
REVIEW_LENSES.map(lens => () =>
agent(
`Review the completed post-patch evidence read-only under ${input.workdir}/results.
Your lens is ${lens.key}: ${lens.brief}
The Python verdict is ${execution.verdict}. You cannot change or vote on that S-score. Set
approved=false when the supplied evidence is incomplete or invalid under your lens, list concrete
concerns with file/function/check IDs, and list every artifact you actually read. Missing evidence
is a concern, not consent. Do not run the validation again and do not edit artifacts.`,
{ schema: REVIEW_SCHEMA, label: `review:${lens.key}`, phase: 'Review' },
),
),
)
const status = finalStatus(execution.verdict, reviews)
return {
status,
deterministicVerdict: execution.verdict,
verdictLabel: execution.label,
evidenceLevel: execution.evidenceLevel,
reasons: execution.reasons,
humanReviewRequired: true,
planPath: planned.planPath,
resultPath: execution.resultPath,
reportPath: execution.reportPath,
reviews: reviews.filter(Boolean),
}