| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877 |
- #!/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:]))
|