mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-07-24 18:13:28 +00:00
878 lines
34 KiB
Python
Executable File
878 lines
34 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Render the suggested-fix products from a patch run directory.
|
|
|
|
Reads the run's `patches.json` and raw `F<n>.diff` files, and writes into the
|
|
report's `patches/` directory:
|
|
|
|
* `F<n>.patch` -- the raw diff behind an explanatory comment header;
|
|
* `F<n>.md` -- a short note per finding, whether or not a patch was written;
|
|
* `PATCHES.md` and `patches.jsonl` -- the index, prose and machine form;
|
|
* the report directory's `.gitignore` (the single line `*`) if it lacks one.
|
|
|
|
Each written patch is checked read-only against the repository with
|
|
`git apply --check`, and the whole patch run directory -- scratch workspaces,
|
|
raw diffs and the record -- is removed once the products are written, along
|
|
with the run directory above it when nothing else remains there.
|
|
|
|
Usage:
|
|
patch_artifacts.py <patch_dir> <patches_dir> <scan_root> --base <sha>
|
|
patch_artifacts.py --remove-scratch <workspace>
|
|
|
|
Exits 0 on success (declined findings included), 1 on a refusal naming what is
|
|
wrong, 2 on a usage error. Python 3.9-compatible, stdlib only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from typing import TYPE_CHECKING, TypedDict, cast
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from render_report import HEX_RE, RenderError, as_map, atomic_write
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from types import TracebackType
|
|
from typing import NoReturn
|
|
|
|
FINDING_ID_PATTERN = "F[0-9]{1,9}"
|
|
FINDING_ID_RE = re.compile(rf"^{FINDING_ID_PATTERN}\Z")
|
|
SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
|
|
REGULAR_FILE_MODE = "100644"
|
|
# \Z, not $: `$` also matches before a trailing newline, and this is a fence.
|
|
REPORT_DIR_RE = re.compile(r"^CLAUDE-SECURITY-[0-9][0-9-]*\Z")
|
|
PATCHES_DIR_NAME = "patches"
|
|
SCRATCH_NAME_RE = re.compile(rf"^scratch-{FINDING_ID_PATTERN}\Z")
|
|
PATCH_DIR_RE = re.compile(r"^patch-[0-9][0-9-]*\Z")
|
|
RUN_DIR_NAME = ".claude-security-run"
|
|
DIFF_HEADER = "diff --git "
|
|
CLAIM_KEYS = ("targeted", "no_new_vulnerability", "behaviour_unchanged")
|
|
CLAIM_LABELS = {
|
|
"targeted": "the change is highly targeted to this finding",
|
|
"no_new_vulnerability": "the change introduces no new security vulnerability",
|
|
"behaviour_unchanged": (
|
|
"beyond closing the finding, the change does not alter the code's "
|
|
"behaviour or the inputs it accepts"
|
|
),
|
|
}
|
|
CLAIM_STATES = ("CONFIDENT", "NOT_CONFIDENT", "UNSURE")
|
|
STATUSES = ("patch_written", "declined", "skipped_stale")
|
|
GIT_ENV = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
|
|
|
|
|
class Claim(TypedDict):
|
|
"""One of the verifier's three confidence claims."""
|
|
|
|
state: str
|
|
evidence: str
|
|
|
|
|
|
class DiffStat(TypedDict):
|
|
"""Per-file added/deleted line counts."""
|
|
|
|
path: str
|
|
added: object
|
|
deleted: object
|
|
|
|
|
|
class Unit(TypedDict):
|
|
"""A validated unit record, ready to be written out."""
|
|
|
|
id: str
|
|
title: str
|
|
status: str
|
|
summary: str
|
|
claims: dict[str, Claim]
|
|
untested: bool
|
|
tests_run: str
|
|
reviewed_paths: list[str]
|
|
decline_reason: str
|
|
recommendation: str
|
|
|
|
|
|
class PatchError(Exception):
|
|
"""The run record or a raw diff is malformed; the caller must correct it."""
|
|
|
|
|
|
def die(message: str) -> NoReturn:
|
|
"""A refusal: the inputs are well-formed arguments but bad data. Exits 1."""
|
|
sys.stderr.write(f"patch_artifacts.py: {message}\n")
|
|
sys.exit(1)
|
|
|
|
|
|
def die_usage(message: str) -> NoReturn:
|
|
"""A usage error: the arguments themselves are wrong. Exits 2."""
|
|
sys.stderr.write(f"patch_artifacts.py: {message}\n")
|
|
sys.exit(2)
|
|
|
|
|
|
def field(value: object, what: str) -> str:
|
|
"""A record field as text; None reads as empty."""
|
|
if value is None:
|
|
return ""
|
|
if not isinstance(value, str):
|
|
msg = f"{what} must be a string"
|
|
raise PatchError(msg)
|
|
lone = SURROGATE_RE.search(value)
|
|
if lone:
|
|
msg = f"{what} contains an unpaired surrogate ({lone.group(0)!r}); it is not valid text"
|
|
raise PatchError(msg)
|
|
return value
|
|
|
|
|
|
def line_field(value: object, what: str) -> str:
|
|
"""A record field for the patch's one-line "#" header; line breaks folded to spaces."""
|
|
return field(value, what).replace("\r", " ").replace("\n", " ")
|
|
|
|
|
|
def field_list(value: object, what: str) -> list[str]:
|
|
"""A list-of-strings record field."""
|
|
if value is None:
|
|
return []
|
|
if not isinstance(value, list):
|
|
msg = f"{what} must be a list of strings"
|
|
raise PatchError(msg)
|
|
items = cast("list[object]", value)
|
|
return [field(item, f"{what}[{index}]") for index, item in enumerate(items)]
|
|
|
|
|
|
def build_claims(raw: object, unit_id: str, status: str) -> dict[str, Claim]:
|
|
"""Validate the three named claims. A written patch needs all three CONFIDENT."""
|
|
claims_map = as_map(raw) or {}
|
|
out: dict[str, Claim] = {}
|
|
for key in CLAIM_KEYS:
|
|
claim = as_map(claims_map.get(key))
|
|
if claim is None:
|
|
if status == "patch_written":
|
|
msg = f"{unit_id}: status is patch_written but claim {key!r} is missing"
|
|
raise PatchError(msg)
|
|
continue
|
|
state = field(claim.get("state"), f"{unit_id} claim {key}.state").upper()
|
|
if state not in CLAIM_STATES:
|
|
msg = (
|
|
f"{unit_id}: claim {key!r} has state {state!r}; want one of "
|
|
f"{', '.join(CLAIM_STATES)}"
|
|
)
|
|
raise PatchError(msg)
|
|
evidence = line_field(claim.get("evidence"), f"{unit_id} claim {key}.evidence")
|
|
out[key] = Claim(state=state, evidence=evidence)
|
|
if status == "patch_written":
|
|
not_confident = [k for k in CLAIM_KEYS if out[k]["state"] != "CONFIDENT"]
|
|
if not_confident:
|
|
msg = (
|
|
f"{unit_id}: status is patch_written but {', '.join(not_confident)} "
|
|
"is not CONFIDENT -- a patch is written only when all three claims "
|
|
"are; record the unit as declined instead."
|
|
)
|
|
raise PatchError(msg)
|
|
return out
|
|
|
|
|
|
def build_unit(raw: object, index: int) -> Unit:
|
|
"""Validate one unit from patches.json into the shape the writers use."""
|
|
item = as_map(raw)
|
|
if item is None:
|
|
msg = f"patches.json unit {index} is not an object"
|
|
raise PatchError(msg)
|
|
unit_id = field(item.get("id"), f"unit {index} id")
|
|
if not FINDING_ID_RE.match(unit_id):
|
|
msg = f"unit {index} id {unit_id!r} is not a finding id (want F<number>, at most 9 digits)"
|
|
raise PatchError(msg)
|
|
status = field(item.get("status"), f"{unit_id} status")
|
|
if status not in STATUSES:
|
|
msg = f"{unit_id}: status {status!r} is not one of {', '.join(STATUSES)}"
|
|
raise PatchError(msg)
|
|
claims = build_claims(item.get("claims"), unit_id, status)
|
|
decline_reason = field(item.get("decline_reason"), f"{unit_id} decline_reason")
|
|
if status != "patch_written" and not decline_reason:
|
|
msg = f"{unit_id}: status {status} needs a decline_reason saying why no patch was written"
|
|
raise PatchError(msg)
|
|
untested = item.get("untested")
|
|
if untested is None and status == "patch_written":
|
|
msg = (
|
|
f'{unit_id}: status is patch_written but "untested" is missing -- it must '
|
|
"say (true/false) whether the project's own tests exercise the patched "
|
|
"code, because the patch header tells the reader exactly that."
|
|
)
|
|
raise PatchError(msg)
|
|
if untested is not None and not isinstance(untested, bool):
|
|
msg = f'{unit_id}: "untested" must be true or false'
|
|
raise PatchError(msg)
|
|
return Unit(
|
|
id=unit_id,
|
|
title=line_field(item.get("title"), f"{unit_id} title") or unit_id,
|
|
status=status,
|
|
summary=line_field(item.get("summary"), f"{unit_id} summary"),
|
|
claims=claims,
|
|
untested=untested is True,
|
|
tests_run=line_field(item.get("tests_run"), f"{unit_id} tests_run"),
|
|
reviewed_paths=field_list(item.get("reviewed_paths"), f"{unit_id} reviewed_paths"),
|
|
decline_reason=decline_reason,
|
|
recommendation=field(item.get("recommendation"), f"{unit_id} recommendation"),
|
|
)
|
|
|
|
|
|
def load_units(patch_dir: str) -> list[Unit]:
|
|
"""Read and validate patches.json (an object with a `units` array)."""
|
|
path = os.path.join(patch_dir, "patches.json")
|
|
try:
|
|
with open(path, encoding="utf-8") as handle:
|
|
raw = cast("object", json.load(handle))
|
|
except OSError as error:
|
|
msg = "patches.json is missing from the patch directory. Write it before running this."
|
|
raise PatchError(msg) from error
|
|
except ValueError as error:
|
|
msg = f"patches.json is not valid JSON: {error}"
|
|
raise PatchError(msg) from error
|
|
record = as_map(raw)
|
|
units_raw: object = record.get("units") if record is not None else raw
|
|
if not isinstance(units_raw, list):
|
|
msg = 'patches.json must be an object with a "units" array'
|
|
raise PatchError(msg)
|
|
units = [build_unit(item, i) for i, item in enumerate(cast("list[object]", units_raw))]
|
|
seen: set[str] = set()
|
|
for unit in units:
|
|
if unit["id"] in seen:
|
|
msg = f"{unit['id']} appears more than once in patches.json"
|
|
raise PatchError(msg)
|
|
seen.add(unit["id"])
|
|
return units
|
|
|
|
|
|
def read_diff(patch_dir: str, unit_id: str, required: bool) -> bytes | None:
|
|
"""The raw diff git wrote for this unit; None only if absent and optional.
|
|
|
|
A required one (a written patch) must exist and hold at least one
|
|
`diff --git` section, since the patch and its diffstat are built from it.
|
|
"""
|
|
path = os.path.join(patch_dir, f"{unit_id}.diff")
|
|
if not os.path.isfile(path):
|
|
if required:
|
|
msg = (
|
|
f"{unit_id}: status is patch_written but {unit_id}.diff is missing from the "
|
|
"patch directory. Write the staged diff with git diff --output before "
|
|
"running this script."
|
|
)
|
|
raise PatchError(msg)
|
|
return None
|
|
data = pathlib.Path(path).read_bytes()
|
|
if required and DIFF_HEADER.encode("ascii") not in data:
|
|
msg = f"{unit_id}.diff contains no '{DIFF_HEADER.strip()}' header; it is not a git diff"
|
|
raise PatchError(msg)
|
|
return data
|
|
|
|
|
|
def atomic_write_bytes(path: str, data: bytes) -> None:
|
|
"""Byte-faithful counterpart of render_report.atomic_write."""
|
|
handle, temp = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".render.")
|
|
try:
|
|
with os.fdopen(handle, "wb") as out:
|
|
out.write(data)
|
|
out.flush()
|
|
os.fsync(out.fileno())
|
|
os.replace(temp, path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
os.unlink(temp)
|
|
raise
|
|
|
|
|
|
def display_name(name: str | None) -> str | None:
|
|
"""A `--- `/`+++ ` line's file name for display: a/ or b/ dropped, None for /dev/null."""
|
|
if name is None:
|
|
return None
|
|
name = name.rstrip("\r")
|
|
if not name.startswith('"'):
|
|
name = name.split("\t", 1)[0]
|
|
if name == "/dev/null":
|
|
return None
|
|
if name.startswith(('"a/', '"b/')):
|
|
return '"' + name[3:]
|
|
return name[2:] if name[:2] in {"a/", "b/"} else name
|
|
|
|
|
|
def section_stat(lines: list[str]) -> DiffStat:
|
|
"""One `diff --git` section's file name and added/deleted line counts."""
|
|
names: dict[str, str] = {}
|
|
modes: dict[str, str] = {}
|
|
added = deleted = 0
|
|
binary = False
|
|
in_hunk = False
|
|
for line in lines[1:]:
|
|
if in_hunk:
|
|
if line.startswith("+"):
|
|
added += 1
|
|
elif line.startswith("-"):
|
|
deleted += 1
|
|
elif line.startswith(("GIT binary patch", "Binary files ")):
|
|
binary = True
|
|
elif line.startswith("@@ "):
|
|
in_hunk = True
|
|
else:
|
|
for key in ("--- ", "+++ "):
|
|
if line.startswith(key):
|
|
names[key.strip()] = line[4:]
|
|
for key in ("old mode", "new mode", "new file mode", "rename from", "rename to"):
|
|
if line.startswith(key + " "):
|
|
modes[key] = line[len(key) + 1 :].strip()
|
|
if modes.get("rename from") and modes.get("rename to"):
|
|
path = f"{modes['rename from']} => {modes['rename to']}"
|
|
else:
|
|
header = lines[0][len(DIFF_HEADER) :].rstrip("\r")
|
|
cut = header.rfind(" b/")
|
|
fallback = header[cut + 3 :] if cut >= 0 else header
|
|
path = display_name(names.get("+++")) or display_name(names.get("---")) or fallback
|
|
old_mode, new_mode = modes.get("old mode"), modes.get("new mode")
|
|
if old_mode and new_mode and old_mode != new_mode:
|
|
path += f" (mode {old_mode} -> {new_mode})"
|
|
elif modes.get("new file mode") not in {None, REGULAR_FILE_MODE}:
|
|
path += f" (new file, mode {modes['new file mode']})"
|
|
return DiffStat(path=path, added="-" if binary else added, deleted="-" if binary else deleted)
|
|
|
|
|
|
def numstat(diff: bytes) -> list[DiffStat]:
|
|
"""Per-file added/deleted line counts, parsed from the diff itself."""
|
|
stats: list[DiffStat] = []
|
|
section: list[str] = []
|
|
for line in diff.decode("utf-8", "replace").splitlines():
|
|
if line.startswith(DIFF_HEADER):
|
|
if section:
|
|
stats.append(section_stat(section))
|
|
section = [line]
|
|
elif section:
|
|
section.append(line)
|
|
if section:
|
|
stats.append(section_stat(section))
|
|
return stats
|
|
|
|
|
|
def git_toplevel(scan_root: str) -> str | None:
|
|
"""The repository root containing scan_root, or None when git can't say."""
|
|
try:
|
|
out = subprocess.run(
|
|
["git", "-C", scan_root, "rev-parse", "--show-toplevel"],
|
|
env=GIT_ENV,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if out.returncode != 0:
|
|
return None
|
|
top = out.stdout.decode("utf-8", "replace").rstrip("\r\n")
|
|
return top or None
|
|
|
|
|
|
def apply_check(top: str | None, patch_path: str) -> str:
|
|
"""`git apply --check` against the user's tree: 'clean', 'conflicts: ...', or 'not_run'."""
|
|
if top is None:
|
|
return "not_run"
|
|
try:
|
|
out = subprocess.run(
|
|
["git", "-C", top, "apply", "--check", os.path.abspath(patch_path)],
|
|
env=GIT_ENV,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
timeout=60,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return "not_run"
|
|
if out.returncode == 0:
|
|
return "clean"
|
|
first = out.stderr.decode("utf-8", "replace").strip().splitlines()
|
|
return "conflicts" + (f": {first[0]}" if first else "")
|
|
|
|
|
|
def diffstat_lines(stats: list[DiffStat] | None) -> list[str]:
|
|
"""Diffstat as markdown bullets, or a one-line fallback when git was unavailable."""
|
|
if stats is None:
|
|
return ["- _(no attempt diff was saved)_"]
|
|
if not stats:
|
|
return ["- _(no file changes recorded)_"]
|
|
return [f"- `{s['path']}` (+{s['added']} -{s['deleted']})" for s in stats]
|
|
|
|
|
|
def header_comment(unit: Unit, base: str, report_ref: str) -> str:
|
|
"""The comment block prepended above the first `diff --git`; git apply ignores it."""
|
|
lines = [
|
|
f"# Claude Security -- suggested patch for {unit['id']}: {unit['title']}",
|
|
f"# Applies to revision {base[:12]} (the revision the scan report describes).",
|
|
"#",
|
|
"# Verified by a panel of agents: an independent verifier reviewed this",
|
|
"# change against the finding, and a second, fresh reviewer re-challenged",
|
|
"# the bare diff for new vulnerabilities. The patch was written only",
|
|
"# because the panel stated all three of these with confidence:",
|
|
]
|
|
for key in CLAIM_KEYS:
|
|
claim = unit["claims"][key]
|
|
lines.append(f"# - {CLAIM_LABELS[key]}: {claim['evidence'] or claim['state']}")
|
|
if unit["untested"]:
|
|
lines += [
|
|
"#",
|
|
"# NOTE: no test exercises the patched code. The claim that behaviour is",
|
|
"# unchanged rests on review of the change and its callers, not on a test",
|
|
"# run -- weigh it accordingly before applying.",
|
|
]
|
|
if unit["summary"]:
|
|
lines += ["#", f"# {unit['summary']}"]
|
|
if unit["tests_run"]:
|
|
lines += [f"# Tests run: {unit['tests_run']}"]
|
|
lines += [
|
|
"#",
|
|
(f"# Apply, from the repository root: git apply {report_ref}/patches/{unit['id']}.patch"),
|
|
"#",
|
|
"",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def note_written(unit: Unit, stats: list[DiffStat] | None, check: str, report_ref: str) -> str:
|
|
"""The F<n>.md note for a finding that earned a patch."""
|
|
lines = [
|
|
f"# {unit['id']}: {unit['title']}",
|
|
"",
|
|
f"**Status:** patch written -> `{unit['id']}.patch`",
|
|
"",
|
|
(
|
|
"**Verified by a panel of agents.** An independent verifier reviewed the "
|
|
"change against the finding and stated the three claims below with "
|
|
"confidence, and a second, fresh reviewer re-challenged the bare diff "
|
|
"for new vulnerabilities. The patch was written only because the "
|
|
"panel could vouch for it; nothing here was applied for you."
|
|
),
|
|
"",
|
|
]
|
|
if unit["summary"]:
|
|
lines += [unit["summary"], ""]
|
|
lines += ["## Confidence", ""]
|
|
for key in CLAIM_KEYS:
|
|
claim = unit["claims"][key]
|
|
lines.append(f"- **{CLAIM_LABELS[key]}** -- {claim['state']}: {claim['evidence']}")
|
|
if unit["untested"]:
|
|
lines += [
|
|
"",
|
|
(
|
|
"**No test exercises the patched code.** The behaviour claim rests on "
|
|
"review of the change and its callers, not on a test run."
|
|
),
|
|
]
|
|
lines += ["", f"**Tests run:** {unit['tests_run'] or 'none recorded'}", ""]
|
|
lines += ["## Change", ""]
|
|
lines += diffstat_lines(stats)
|
|
lines += ["", "## Applying it", ""]
|
|
if check == "clean":
|
|
lines.append("Applies cleanly to the working tree (checked with `git apply --check`).")
|
|
elif check == "not_run":
|
|
lines.append("The clean-apply check could not run here (git unavailable); try it yourself.")
|
|
else:
|
|
detail = check.split(": ", 1)[-1]
|
|
lines.append(
|
|
f"`git apply --check` reported a conflict ({detail}). The patch was built against the "
|
|
"recorded revision, so this usually means the working tree has uncommitted or newer "
|
|
"changes in these files -- apply it to a checkout of that revision, or merge by "
|
|
"hand."
|
|
)
|
|
lines += [
|
|
"",
|
|
"```",
|
|
f"git apply {report_ref}/patches/{unit['id']}.patch",
|
|
"```",
|
|
"",
|
|
"Or ask Claude Security to apply it, or to open a pull request for it.",
|
|
"",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def note_declined(unit: Unit, stats: list[DiffStat] | None) -> str:
|
|
"""The F<n>.md note for a finding with no patch."""
|
|
lines = [
|
|
f"# {unit['id']}: {unit['title']}",
|
|
"",
|
|
"**Status:** no patch produced",
|
|
"",
|
|
unit["decline_reason"],
|
|
"",
|
|
]
|
|
blocking = [(k, c) for k, c in unit["claims"].items() if c["state"] != "CONFIDENT"]
|
|
if blocking:
|
|
lines += ["## The claim that could not be made with confidence", ""]
|
|
for key, claim in blocking:
|
|
lines.append(f"- **{CLAIM_LABELS[key]}** -- {claim['state']}: {claim['evidence']}")
|
|
lines.append("")
|
|
if stats is not None:
|
|
lines += ["## What the rejected attempt changed", ""]
|
|
lines += diffstat_lines(stats)
|
|
lines.append("")
|
|
if unit["recommendation"]:
|
|
lines += ["## The report's original recommendation", "", unit["recommendation"], ""]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def index_markdown(units: list[Unit], base: str, report_dir_name: str, report_ref: str) -> str:
|
|
"""PATCHES.md: the one-page index of every unit's outcome."""
|
|
patched = [u for u in units if u["status"] == "patch_written"]
|
|
declined = [u for u in units if u["status"] != "patch_written"]
|
|
lines = [
|
|
"# Suggested patches",
|
|
"",
|
|
(
|
|
f"Targeted patches for findings in `{report_dir_name}`, each written against "
|
|
f"revision `{base[:12]}` and verified by a panel of agents before it was "
|
|
"written. Nothing here is applied, committed, or opened as a pull request "
|
|
"until you choose to do so."
|
|
),
|
|
"",
|
|
]
|
|
if patched:
|
|
lines += ["## Patches written", ""]
|
|
for unit in patched:
|
|
caveat = " _(no tests cover the patched code)_" if unit["untested"] else ""
|
|
lines.append(f"- **{unit['id']}** -- {unit['title']}: `{unit['id']}.patch`{caveat}")
|
|
lines.append("")
|
|
if declined:
|
|
lines += ["## No patch produced", ""]
|
|
for unit in declined:
|
|
lines.append(f"- **{unit['id']}** -- {unit['title']}: {unit['decline_reason']}")
|
|
lines.append("")
|
|
lines += [
|
|
"## Applying a patch",
|
|
"",
|
|
"From the repository root:",
|
|
"",
|
|
"```",
|
|
f"git apply {report_ref}/patches/F<n>.patch",
|
|
"```",
|
|
"",
|
|
(
|
|
"Each `F<n>.md` beside the patch explains the change and what was verified. "
|
|
"The job that wrote these applied, committed, pushed, and opened nothing; "
|
|
"if you want one applied, or turned into a pull request, ask Claude "
|
|
"Security and it handles that as a separate request."
|
|
),
|
|
"",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def jsonl(
|
|
units: list[Unit],
|
|
base: str,
|
|
stats_by_id: dict[str, list[DiffStat] | None],
|
|
checks: dict[str, str],
|
|
) -> str:
|
|
"""patches.jsonl: one record per unit, machine-readable for tooling."""
|
|
rows: list[str] = []
|
|
for unit in units:
|
|
record: dict[str, object] = {
|
|
"id": unit["id"],
|
|
"status": unit["status"],
|
|
"base": base,
|
|
"patch": f"{unit['id']}.patch" if unit["status"] == "patch_written" else None,
|
|
"note": f"{unit['id']}.md",
|
|
"claims": unit["claims"],
|
|
"untested": unit["untested"],
|
|
"tests_run": unit["tests_run"] or None,
|
|
"reviewed_paths": unit["reviewed_paths"],
|
|
"diffstat": stats_by_id.get(unit["id"]),
|
|
"apply_check": checks.get(unit["id"]),
|
|
"decline_reason": unit["decline_reason"] or None,
|
|
}
|
|
rows.append(json.dumps(record, ensure_ascii=False, sort_keys=False))
|
|
return "\n".join(rows) + ("\n" if rows else "")
|
|
|
|
|
|
def clear_stale_products(patches_dir: str, produced: set[str]) -> list[str]:
|
|
"""Remove F<n>.patch / F<n>.md files an earlier run left that this run did not write.
|
|
|
|
Only the script's own product names (F<n>.patch, F<n>.md) are removed;
|
|
every other file in the folder is left alone.
|
|
"""
|
|
removed: list[str] = []
|
|
for name in sorted(os.listdir(patches_dir)):
|
|
stem, dot, ext = name.rpartition(".")
|
|
if not dot or ext not in {"patch", "md"} or not FINDING_ID_RE.match(stem):
|
|
continue
|
|
if name in produced:
|
|
continue
|
|
path = os.path.join(patches_dir, name)
|
|
if os.path.isdir(path):
|
|
continue
|
|
os.unlink(path)
|
|
removed.append(name)
|
|
return removed
|
|
|
|
|
|
def ensure_gitignore(report_dir: str) -> str:
|
|
"""Fence the report directory with a `*` .gitignore if it has none.
|
|
|
|
Returns "written" when the fence was just added, "present" when an
|
|
existing .gitignore already ignores everything, and "open" when one exists
|
|
but has no bare `*` line; an existing file is never rewritten.
|
|
"""
|
|
path = os.path.join(report_dir, ".gitignore")
|
|
if os.path.lexists(path):
|
|
try:
|
|
existing = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return "open"
|
|
return "present" if "*" in (line.strip() for line in existing.splitlines()) else "open"
|
|
atomic_write(path, "*\n")
|
|
return "written"
|
|
|
|
|
|
def contained_relpath(target: str, root: str) -> str | None:
|
|
"""`target` as a path from `root`, or None when it does not sit inside root."""
|
|
rel = os.path.relpath(os.path.realpath(target), os.path.realpath(root))
|
|
if rel == ".." or rel.startswith(".." + os.sep) or os.path.isabs(rel):
|
|
return None
|
|
return rel
|
|
|
|
|
|
def report_path_from_root(report_dir: str, top: str | None, fallback: str) -> str:
|
|
"""The report directory as a path from the repository root, for the apply command.
|
|
|
|
Falls back to the bare folder name when git cannot name a root or the
|
|
folder sits outside it.
|
|
"""
|
|
if top is None:
|
|
return fallback
|
|
return contained_relpath(report_dir, top) or fallback
|
|
|
|
|
|
def resolve_report_dir(patches_dir: str) -> tuple[str, str]:
|
|
"""The report directory holding `patches_dir`, validated by name."""
|
|
patches_abs = os.path.abspath(patches_dir)
|
|
report_dir = os.path.dirname(patches_abs)
|
|
report_dir_name = os.path.basename(report_dir)
|
|
if os.path.basename(patches_abs) != PATCHES_DIR_NAME:
|
|
msg = (
|
|
f"patches dir must be a directory named {PATCHES_DIR_NAME!r} inside the "
|
|
f"report directory; got {patches_abs}"
|
|
)
|
|
raise PatchError(msg)
|
|
if not REPORT_DIR_RE.match(report_dir_name):
|
|
msg = (
|
|
"patches dir must live inside a CLAUDE-SECURITY-<timestamp> report "
|
|
f"directory; its parent is {report_dir_name!r}. Refusing rather than "
|
|
"fence the wrong directory with a .gitignore."
|
|
)
|
|
raise PatchError(msg)
|
|
return report_dir, report_dir_name
|
|
|
|
|
|
def run(patch_dir: str, patches_dir: str, scan_root: str, base: str) -> int:
|
|
units = load_units(patch_dir)
|
|
report_dir, report_dir_name = resolve_report_dir(patches_dir)
|
|
top = git_toplevel(scan_root)
|
|
report_ref = shlex.quote(report_path_from_root(report_dir, top, report_dir_name))
|
|
stats_by_id: dict[str, list[DiffStat] | None] = {}
|
|
checks: dict[str, str] = {}
|
|
produced: set[str] = set()
|
|
for unit in units:
|
|
written = unit["status"] == "patch_written"
|
|
diff = read_diff(patch_dir, unit["id"], required=written)
|
|
stats = numstat(diff) if diff is not None else None
|
|
stats_by_id[unit["id"]] = stats
|
|
if written and diff is not None:
|
|
patch_path = os.path.join(patches_dir, f"{unit['id']}.patch")
|
|
header = header_comment(unit, base, report_ref)
|
|
atomic_write_bytes(patch_path, header.encode("utf-8") + diff)
|
|
check = apply_check(top, patch_path)
|
|
checks[unit["id"]] = check
|
|
note = note_written(unit, stats, check, report_ref)
|
|
produced.add(f"{unit['id']}.patch")
|
|
print(f"{unit['id']}: patch written -> {patch_path} (apply check: {check})")
|
|
else:
|
|
note = note_declined(unit, stats)
|
|
print(f"{unit['id']}: no patch ({unit['status']}) -> {unit['id']}.md")
|
|
atomic_write(os.path.join(patches_dir, f"{unit['id']}.md"), note)
|
|
produced.add(f"{unit['id']}.md")
|
|
index_text = index_markdown(units, base, report_dir_name, report_ref)
|
|
atomic_write(os.path.join(patches_dir, "PATCHES.md"), index_text)
|
|
atomic_write(
|
|
os.path.join(patches_dir, "patches.jsonl"), jsonl(units, base, stats_by_id, checks)
|
|
)
|
|
for name in clear_stale_products(patches_dir, produced):
|
|
print(f"removed stale {name} (not produced by this run)")
|
|
swept, warnings = remove_workspaces_in(patch_dir)
|
|
for name in swept:
|
|
print(f"removed workspace {name}")
|
|
removed, more_warnings = remove_patch_run(patch_dir)
|
|
for path in removed:
|
|
print(f"removed {path}")
|
|
for warning in warnings + more_warnings:
|
|
print(f"WARNING: {warning}")
|
|
fence = ensure_gitignore(report_dir)
|
|
if fence == "written":
|
|
print(f"fenced {report_dir} with .gitignore")
|
|
elif fence == "open":
|
|
print(
|
|
f"WARNING: {report_dir}/.gitignore exists but does not ignore everything "
|
|
"('*'); the report and these patches are NOT fenced off from git add. "
|
|
"Left untouched -- edit it yourself if you want them ignored."
|
|
)
|
|
patched = sum(1 for u in units if u["status"] == "patch_written")
|
|
print(
|
|
f"wrote PATCHES.md and patches.jsonl into {patches_dir} "
|
|
f"({patched} patched, {len(units) - patched} declined)"
|
|
)
|
|
return 0
|
|
|
|
|
|
def refuse_reason(path: str) -> str | None:
|
|
"""Why `path` may NOT be deleted as a scratch workspace, or None when it may.
|
|
|
|
Only `<report>/.claude-security-run/patch-<ts>/scratch-F<n>` holding its
|
|
own `.git` may be deleted; every other shape is refused.
|
|
"""
|
|
leaf = os.path.normpath(os.path.abspath(path))
|
|
if not os.path.isdir(leaf):
|
|
return "it is not a directory"
|
|
if not SCRATCH_NAME_RE.match(os.path.basename(leaf)):
|
|
return "its name is not scratch-F<n>"
|
|
run = os.path.dirname(leaf)
|
|
top = os.path.dirname(run)
|
|
if not PATCH_DIR_RE.match(os.path.basename(run)):
|
|
return "it is not inside a patch-<timestamp> run directory"
|
|
if os.path.basename(top) != RUN_DIR_NAME:
|
|
return f"its run directory is not inside {RUN_DIR_NAME}/"
|
|
if not os.path.isdir(os.path.join(leaf, ".git")):
|
|
return "it holds no .git directory of its own"
|
|
return None
|
|
|
|
|
|
def clear_readonly(
|
|
func: Callable[..., object],
|
|
path: str,
|
|
exc_info: tuple[type[BaseException], BaseException, TracebackType],
|
|
) -> None:
|
|
"""Make `path` writable and retry the removal rmtree could not do."""
|
|
# Git writes read-only objects, which Windows will not delete.
|
|
if func not in {os.unlink, os.rmdir}:
|
|
raise exc_info[1]
|
|
os.chmod(path, stat.S_IWRITE)
|
|
func(path)
|
|
|
|
|
|
def remove_workspace(path: str) -> None:
|
|
"""Delete one scratch workspace, refusing anything off the fenced layout."""
|
|
reason = refuse_reason(path)
|
|
if reason is not None:
|
|
msg = f"refusing to remove {path!r}: {reason}"
|
|
raise PatchError(msg)
|
|
target = os.path.normpath(os.path.abspath(path))
|
|
try:
|
|
shutil.rmtree(target, onerror=clear_readonly)
|
|
except OSError as error:
|
|
detail = error.args[0] if error.args else error
|
|
msg = f"could not remove {path!r}: {detail}"
|
|
raise PatchError(msg) from error
|
|
|
|
|
|
def remove_workspaces_in(patch_dir: str) -> tuple[list[str], list[str]]:
|
|
"""Remove every scratch workspace in a patch run directory.
|
|
|
|
Returns (removed names, warnings). Never raises: a workspace that cannot
|
|
be removed is reported as a warning.
|
|
"""
|
|
removed: list[str] = []
|
|
warnings: list[str] = []
|
|
try:
|
|
names = sorted(os.listdir(patch_dir))
|
|
except OSError as error:
|
|
return removed, [f"could not list {patch_dir!r}: {error}"]
|
|
for name in names:
|
|
if not name.startswith("scratch-"):
|
|
continue
|
|
path = os.path.join(patch_dir, name)
|
|
try:
|
|
remove_workspace(path)
|
|
except PatchError as error:
|
|
warnings.append(str(error))
|
|
else:
|
|
removed.append(name)
|
|
return removed, warnings
|
|
|
|
|
|
def remove_patch_run(patch_dir: str) -> tuple[list[str], list[str]]:
|
|
"""Remove a finished patch run directory, and its run directory if now empty.
|
|
|
|
Returns (removed paths, warnings). Never raises; only the recipe's own
|
|
`<report>/.claude-security-run/patch-<ts>` layout is deleted.
|
|
"""
|
|
removed: list[str] = []
|
|
target = os.path.normpath(os.path.abspath(patch_dir))
|
|
run_dir = os.path.dirname(target)
|
|
if not PATCH_DIR_RE.match(os.path.basename(target)):
|
|
return removed, [f"left {patch_dir!r} in place: its name is not patch-<timestamp>"]
|
|
if os.path.basename(run_dir) != RUN_DIR_NAME:
|
|
return removed, [f"left {patch_dir!r} in place: it is not inside {RUN_DIR_NAME}/"]
|
|
try:
|
|
shutil.rmtree(target, onerror=clear_readonly)
|
|
except OSError as error:
|
|
detail = error.args[0] if error.args else error
|
|
return removed, [f"could not remove {patch_dir!r}: {detail}"]
|
|
removed.append(target)
|
|
try:
|
|
os.rmdir(run_dir)
|
|
except OSError:
|
|
return removed, []
|
|
removed.append(run_dir)
|
|
return removed, []
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
if argv and argv[0] == "--remove-scratch":
|
|
if len(argv) != 2:
|
|
die_usage("--remove-scratch takes exactly one workspace path")
|
|
try:
|
|
remove_workspace(argv[1])
|
|
except PatchError as error:
|
|
die(str(error))
|
|
print(f"removed workspace {argv[1]!r}")
|
|
return 0
|
|
parser = argparse.ArgumentParser(
|
|
prog="patch_artifacts.py",
|
|
description="Render suggested-fix patch files and notes from a patch run directory.",
|
|
epilog="Also: --remove-scratch <workspace> deletes one fenced scratch workspace.",
|
|
)
|
|
parser.add_argument("patch_dir", help="the patch run dir holding patches.json and F<n>.diff")
|
|
parser.add_argument("patches_dir", help="the report's patches/ directory to write into")
|
|
parser.add_argument("scan_root", help="the user's repository root (for git apply --check)")
|
|
parser.add_argument("--base", required=True, help="the revision every patch applies to")
|
|
args = parser.parse_args(argv)
|
|
patch_dir = str(cast("object", args.patch_dir))
|
|
patches_dir = str(cast("object", args.patches_dir))
|
|
scan_root = str(cast("object", args.scan_root))
|
|
base = str(cast("object", args.base))
|
|
for label, path in (("patch dir", patch_dir), ("patches dir", patches_dir)):
|
|
if not os.path.isdir(path):
|
|
die_usage(f"{label} is not a directory: {path}")
|
|
if not HEX_RE.match(base):
|
|
die_usage(f"--base {base!r} is not a hex revision id")
|
|
try:
|
|
return run(patch_dir, patches_dir, scan_root, base)
|
|
except (PatchError, RenderError) as error:
|
|
die(str(error))
|
|
except OSError as error:
|
|
die(f"could not read or write the report's files: {error}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|