mirror of
https://github.com/trailofbits/skills.git
synced 2026-09-14 14:28:48 +08:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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("<", "<").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("<", "<")
|
||||
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("<", "<", 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('<') == '<'"]
|
||||
```
|
||||
|
||||
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."
|
||||
+182
@@ -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.
|
||||
Executable
+1957
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
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user