save_result.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. #!/usr/bin/env python3
  2. """Record a scan workflow's result in its run directory and say what to do next.
  3. Reads the JSON file the Claude Code runtime writes when a workflow task
  4. completes, folds the result's findings, votes and coverage into
  5. findings.json, votes.json and coverage.json in the run directory (appending
  6. to an earlier run's when this result continues one), writes the candidate
  7. files a further verification run loads, and prints a summary line with the
  8. count at each severity, a `votes:` line with each finding's panel tally, and
  9. a `next:` line: the Workflow call that continues the verification, or the
  10. instruction to write the report. Each finding is recorded at the line of its
  11. file that its quoted code is on, with the line the researcher declared kept
  12. beside it.
  13. Usage:
  14. save_result.py <output_file> <run_dir>
  15. Exits 0 when the result is recorded or already was, 1 when it could not be
  16. (the `next:` line still says what to do), 2 on a usage error.
  17. Python 3.9-compatible, stdlib only.
  18. """
  19. from __future__ import annotations
  20. import argparse
  21. import os
  22. import re
  23. import sys
  24. from collections import Counter
  25. from pathlib import Path
  26. from typing import NamedTuple
  27. # The lib/ package lives next to this script. Python normally adds a script's own
  28. # directory to the import path, but not under -P or PYTHONSAFEPATH, so we add it here.
  29. sys.path.insert(0, str(Path(__file__).resolve().parent))
  30. from lib import console, plugin, source, strictjson
  31. from lib.chain import Chain, chain_of, pending_ranks
  32. from lib.finding import (
  33. Confidence,
  34. FindingError,
  35. Panel,
  36. Severity,
  37. file_field,
  38. finding_panels,
  39. line_number,
  40. link_field,
  41. scan_prefix_fits,
  42. scan_prefix_shaped,
  43. vote_rounds,
  44. )
  45. from lib.revision import change_range
  46. from lib.strictjson import JsonMap, is_int, is_list, is_map, is_str
  47. # Rows per candidate file; workflows/scan.js derives each file's ranks from the same number.
  48. CHUNK = 25
  49. UNACCOUNTED_ECHO_CAP = 40
  50. CID_RE = re.compile(r"^C([1-9][0-9]*)\Z")
  51. WRITE_REPORT = (
  52. "next: write CLAUDE-SECURITY-RESULTS.md from findings.json and coverage.json per the "
  53. "report spec, then run render_report.py"
  54. )
  55. class Records(NamedTuple):
  56. """A scan's three records and the chain its vote record carries."""
  57. findings: list[JsonMap]
  58. votes: JsonMap
  59. coverage: JsonMap
  60. chain: Chain
  61. @property
  62. def rounds(self) -> JsonMap:
  63. return vote_rounds(self.votes)
  64. def severity_counts(self) -> Counter[Severity]:
  65. """How many findings carry each severity; a word that names no Severity is not counted."""
  66. return Counter(s for s in map(Severity.of, self.findings) if s is not None)
  67. def panels(self) -> list[tuple[str, Panel | None]]:
  68. return finding_panels(self.rounds, [str(f["id"]) for f in self.findings])
  69. class Args(argparse.Namespace):
  70. """The parsed command line."""
  71. output_file: str = ""
  72. run_dir: str = ""
  73. class NotAResultError(Exception):
  74. """The output file holds no scan result this script can fold; the message names why."""
  75. fallback: str = (
  76. "Write the result's findings, votes and coverage from the completion notice to "
  77. "findings.json, votes.json and coverage.json in the run directory yourself, then "
  78. + WRITE_REPORT[len("next: ") :]
  79. )
  80. class CannotContinueError(Exception):
  81. """The run directory cannot take this result; the message names why."""
  82. fallback: str = "stop; nothing was recorded, so say so and name the report directory"
  83. def count(record: JsonMap, key: str) -> int:
  84. """A count the workflow wrote; anything but a non-negative integer cannot be folded."""
  85. value = record.get(key)
  86. if not is_int(value) or value < 0:
  87. msg = f"{key!r} is {value!r}, not a count"
  88. raise CannotContinueError(msg)
  89. return value
  90. def texts(record: JsonMap, key: str) -> list[object]:
  91. """A list the workflow wrote, empty when absent."""
  92. value = record.get(key)
  93. return list(value) if is_list(value) else []
  94. def rank_of(row: JsonMap) -> int:
  95. """A candidate row's rank, from its `cid`."""
  96. cid = row.get("cid")
  97. matched = CID_RE.match(cid) if is_str(cid) else None
  98. if not matched:
  99. msg = f"a pending row's cid is {cid!r}, not C<rank>"
  100. raise NotAResultError(msg)
  101. return int(matched[1])
  102. def scan_settings(run_dir: Path) -> JsonMap:
  103. """scan-meta.json from the run directory: the settings every run of this scan shares."""
  104. if run_dir.name != plugin.RUN_DIR_NAME:
  105. msg = f"{run_dir} is not a {plugin.RUN_DIR_NAME} run directory"
  106. raise CannotContinueError(msg)
  107. try:
  108. meta = strictjson.load(run_dir / "scan-meta.json")
  109. except (OSError, ValueError) as error:
  110. msg = f"scan-meta.json cannot be read from {run_dir}: {error}"
  111. raise CannotContinueError(msg) from error
  112. if not is_map(meta) or not is_str(meta.get("scan_root")):
  113. msg = "scan-meta.json names no scan_root"
  114. raise CannotContinueError(msg)
  115. return meta
  116. def recorded(run_dir: Path) -> Records | None:
  117. """The scan's records as an earlier save left them; None before the first."""
  118. if not os.path.isfile(run_dir / "votes.json"):
  119. return None
  120. findings, votes, coverage = (
  121. strictjson.load(run_dir / name) for name in ("findings.json", "votes.json", "coverage.json")
  122. )
  123. if not is_list(findings) or not is_map(votes) or not is_map(coverage):
  124. msg = "the run directory's records are not the shapes this script wrote"
  125. raise CannotContinueError(msg)
  126. kept = [f for f in findings if is_map(f) and is_str(f.get("id"))]
  127. if len(kept) != len(findings):
  128. msg = "findings.json holds a finding without an id"
  129. raise CannotContinueError(msg)
  130. try:
  131. chain = chain_of(votes.get("chain"))
  132. except ValueError as error:
  133. msg = f"votes.json chain {error}"
  134. raise CannotContinueError(msg) from error
  135. return Records(kept, votes, coverage, chain)
  136. def result_in(output_file: Path) -> JsonMap:
  137. """The `result` object the runtime wrote to the workflow's output file."""
  138. try:
  139. output = strictjson.load(output_file)
  140. except (OSError, ValueError) as error:
  141. msg = f"the output file cannot be read: {error}"
  142. raise NotAResultError(msg) from error
  143. result = output.get("result") if is_map(output) else None
  144. if not is_map(result):
  145. msg = "the output file holds no result object"
  146. raise NotAResultError(msg)
  147. return result
  148. def records_in(result: JsonMap) -> tuple[Records, dict[int, JsonMap]]:
  149. """A run's records and its pending rows by rank, narrowed from its result."""
  150. raw, votes, coverage, pending = (
  151. result.get(key) for key in ("findings", "votes", "coverage", "pending")
  152. )
  153. findings = [f for f in raw if is_map(f) and is_str(f.get("id"))] if is_list(raw) else []
  154. if not is_list(raw) or len(findings) != len(raw):
  155. msg = "'findings' is not a list of findings with ids"
  156. raise NotAResultError(msg)
  157. if not is_map(votes) or votes.get("provenance") != plugin.VOTES_PROVENANCE:
  158. msg = "'votes' is not the scan workflow's vote record"
  159. raise NotAResultError(msg)
  160. if not is_map(coverage) or not is_str(coverage.get("effort")):
  161. msg = "'coverage' does not name the effort"
  162. raise NotAResultError(msg)
  163. if not is_list(pending):
  164. msg = "'pending' is not a list of candidate rows"
  165. raise NotAResultError(msg)
  166. try:
  167. chain = chain_of(votes.get("chain"))
  168. except ValueError as error:
  169. msg = f"'votes' chain {error}"
  170. raise NotAResultError(msg) from error
  171. rows = {rank_of(row): row for row in pending if is_map(row)}
  172. if len(rows) != len(pending) or sorted(rows) != pending_ranks(chain):
  173. msg = "'pending' rows are not the ranks the chain hands on"
  174. raise NotAResultError(msg)
  175. return Records(findings, votes, coverage, chain), rows
  176. def ancestry(path: str) -> list[str]:
  177. """`path` and each directory above it, nearest last: a/b/c gives a, a/b, a/b/c."""
  178. parts = path.split("/")
  179. return ["/".join(parts[:depth]) for depth in range(1, len(parts) + 1)]
  180. def checked(coverage: JsonMap, meta: JsonMap, run_dir: Path) -> JsonMap:
  181. """`coverage` carrying, under `research.tree`, its coverage accounts counted against the
  182. scan target's tracked files; `coverage` as given when the accounts are not checkable."""
  183. research = coverage.get("research")
  184. if not is_map(research) or research.get("checkable") is not True:
  185. return coverage
  186. listing = run_dir / plugin.TARGET_FILES_NAME
  187. try:
  188. listed = strictjson.load(listing)
  189. except (OSError, ValueError) as error:
  190. sys.stderr.write(f"save_result.py: coverage account not checked: {error}\n")
  191. return coverage
  192. if not is_list(listed):
  193. sys.stderr.write(f"save_result.py: coverage account not checked: {listing} is not a list\n")
  194. return coverage
  195. files = [path for path in listed if is_str(path)]
  196. names = {name for path in files for name in ancestry(path)}
  197. scan_prefix = str(meta.get("scan_prefix") or "")
  198. scan_root = str(meta.get("scan_root") or "").replace("\\", "/").strip("/") + "/"
  199. names.add("")
  200. def named(path: str) -> str:
  201. slashed = path.replace("\\", "/")
  202. folded = (
  203. (slashed + "/").removeprefix(root).strip("/") for root in (scan_prefix, scan_root)
  204. )
  205. return next((s for s in (path, slashed, *folded) if s in names), path)
  206. accounts = [account for account in texts(research, "components") if is_map(account)]
  207. inside = {named(p) for account in accounts for p in texts(account, "paths") if is_str(p)}
  208. read = {named(p) for account in accounts for p in texts(account, "filesRead") if is_str(p)}
  209. declared = (e.get("path") for a in accounts for e in texts(a, "notReached") if is_map(e))
  210. missed = {named(p) for p in declared if is_str(p)}
  211. assigned = [f for f in files if not inside.isdisjoint(["", *ancestry(f)])]
  212. unread = [f for f in assigned if f not in read]
  213. unaccounted = [f for f in unread if missed.isdisjoint(["", *ancestry(f)])]
  214. tree = {
  215. "files": len(assigned),
  216. "read": len(assigned) - len(unread),
  217. "notReached": len(unread) - len(unaccounted),
  218. "unaccounted": len(unaccounted),
  219. "unaccountedPaths": unaccounted[:UNACCOUNTED_ECHO_CAP],
  220. "outsideComponents": len(files) - len(assigned),
  221. }
  222. return {**coverage, "research": {**research, "tree": tree}}
  223. def placed(finding: JsonMap, meta: JsonMap) -> JsonMap:
  224. """`finding` with its file, change link and line as the render will carry them.
  225. `line` becomes the line of the file its quoted snippet is on, and the line as it arrived
  226. moves to `declared_line`. Fields the render would refuse are left as they are.
  227. """
  228. finding_id, line, snippet = (
  229. finding["id"],
  230. line_number(finding.get("line")),
  231. finding.get("snippet"),
  232. )
  233. scan_root, prefix = meta.get("scan_root"), meta.get("scan_prefix") or ""
  234. if (
  235. line is None
  236. or not (snippet is None or is_str(snippet))
  237. or not is_str(finding_id)
  238. or not is_str(scan_root)
  239. or not is_str(prefix)
  240. or not scan_prefix_shaped(prefix)
  241. or not scan_prefix_fits(prefix, scan_root=scan_root)
  242. ):
  243. return finding
  244. try:
  245. file = file_field(finding, finding_id, scan_root, prefix, meta.get("mode") == "scan")
  246. via_change = link_field(finding, finding_id, scan_root, prefix)
  247. except FindingError:
  248. return {**finding, "line": line, "declared_line": line}
  249. return {
  250. **finding,
  251. **({"file": file} if file == file.strip() else {}),
  252. **({"via_change": via_change} if via_change else {}),
  253. "line": source.placed_line(source.read(scan_root, file), line, snippet or ""),
  254. "declared_line": line,
  255. }
  256. def report_order(finding: JsonMap) -> tuple[int, int]:
  257. """Sort key: severity, then confidence, strongest first; unknown values last."""
  258. severity = Severity.of(finding)
  259. confidence = str(finding.get("confidence", "")).lower()
  260. return -(severity.value if severity else 0), -Confidence.__members__.get(confidence, 0)
  261. def merged(scan: Records, run: Records) -> Records:
  262. """The scan's records with one further verification run folded in."""
  263. handed = pending_ranks(scan.chain)
  264. if run.chain["shard"] != scan.chain["shard"] + 1:
  265. msg = (
  266. f"this is run {run.chain['shard']} but run {scan.chain['shard']} was the last recorded"
  267. )
  268. raise CannotContinueError(msg)
  269. if run.coverage.get("received") != len(handed):
  270. received = run.coverage.get("received")
  271. msg = f"the run was handed {received!r} candidates, not the {len(handed)} pending"
  272. raise CannotContinueError(msg)
  273. if run.coverage.get("effort") != scan.coverage.get("effort"):
  274. msg = "the run's effort is not the scan's"
  275. raise CannotContinueError(msg)
  276. findings = scan.findings + [f for f in run.findings if f not in scan.findings]
  277. ids = [f["id"] for f in findings]
  278. if len(ids) != len(set(ids)):
  279. msg = "the run reuses a finding id the scan already has"
  280. raise CannotContinueError(msg)
  281. scan_rounds, run_rounds = scan.votes.get("rounds"), run.votes.get("rounds")
  282. if not is_map(scan_rounds) or not is_map(run_rounds):
  283. msg = "'rounds' is not an object"
  284. raise CannotContinueError(msg)
  285. repanelled = {r.get("candidate") for r in run_rounds.values() if is_map(r)}
  286. rounds = {
  287. rid: r
  288. for rid, r in scan_rounds.items()
  289. if not (is_map(r) and r.get("continued") is True and r.get("candidate") in repanelled)
  290. }
  291. if rounds.keys() & run_rounds.keys():
  292. msg = "the run reuses a round id the scan already has"
  293. raise CannotContinueError(msg)
  294. votes = {
  295. **scan.votes,
  296. "panel_votes": count(scan.votes, "panel_votes") + count(run.votes, "panel_votes"),
  297. "unreviewed_candidate_sites": count(scan.votes, "unreviewed_candidate_sites")
  298. - len(handed)
  299. + count(run.votes, "unreviewed_candidate_sites"),
  300. "rounds": {**rounds, **run_rounds},
  301. "chain": run.chain,
  302. }
  303. coverage = (
  304. scan.coverage
  305. if scan.coverage.get("verificationRun") == run.chain["shard"]
  306. else {
  307. **scan.coverage,
  308. "adversarialCasualties": texts(scan.coverage, "adversarialCasualties")
  309. + texts(run.coverage, "adversarialCasualties"),
  310. "lostCandidates": texts(scan.coverage, "lostCandidates")
  311. + texts(run.coverage, "lostCandidates"),
  312. "severityLowered": texts(scan.coverage, "severityLowered")
  313. + texts(run.coverage, "severityLowered"),
  314. "preExisting": texts(scan.coverage, "preExisting") + texts(run.coverage, "preExisting"),
  315. "outsideScope": texts(scan.coverage, "outsideScope")
  316. + texts(run.coverage, "outsideScope"),
  317. "dispatchRefusals": count(scan.coverage, "dispatchRefusals")
  318. + count(run.coverage, "dispatchRefusals"),
  319. "continued": run.coverage.get("continued"),
  320. "verificationRun": run.chain["shard"],
  321. }
  322. )
  323. return Records(sorted(findings, key=report_order), votes, coverage, run.chain)
  324. def write_json(run_dir: Path, name: str, value: object, indent: int = 2) -> None:
  325. """Write one record through a temporary name, so it is never seen half-written."""
  326. partial = run_dir / f"{name}.partial"
  327. partial.write_bytes((strictjson.text(value, indent=indent) + "\n").encode())
  328. partial.replace(run_dir / name)
  329. def write_records(run_dir: Path, records: Records, rows: dict[int, JsonMap]) -> None:
  330. """The three records and the next run's candidate files; votes.json last."""
  331. write_json(run_dir, "findings.json", records.findings)
  332. write_json(run_dir, "coverage.json", records.coverage)
  333. shard = records.chain["shard"] + 1
  334. ranks = sorted(rows)
  335. for number, start in enumerate(range(0, len(ranks), CHUNK), start=1):
  336. chunk = {
  337. "runDir": str(run_dir),
  338. "shard": shard,
  339. "chunk": number,
  340. "candidates": [rows[rank] for rank in ranks[start : start + CHUNK]],
  341. }
  342. write_json(run_dir, f"candidates.{shard}.{number}.json", chunk, indent=1)
  343. write_json(run_dir, "votes.json", records.votes)
  344. def summary(records: Records) -> str:
  345. """Two lines: where the scan stands, with the count at each severity, then each
  346. finding's tally, or "?" when its panel is not complete, in findings.json's order.
  347. """
  348. counts = records.severity_counts()
  349. by_severity = ", ".join(f"{counts[severity]} {severity.name}" for severity in Severity)
  350. tallies = [f"{i} {p['true']}/{p['voters']}" if p else f"{i} ?" for i, p in records.panels()]
  351. return (
  352. f"recorded verification run {records.chain['shard']}: {len(records.findings)} findings "
  353. f"({by_severity}), "
  354. f"{len(records.rounds)} rounds, "
  355. f"{len(pending_ranks(records.chain))} pending, "
  356. f"{len(texts(records.coverage, 'lostCandidates'))} lost\n"
  357. f"votes: {', '.join(tallies) or 'no findings'}"
  358. )
  359. def next_step(run_dir: Path, meta: JsonMap, records: Records) -> str:
  360. """The `next:` line: the Workflow call that panels what is pending, or the report."""
  361. chain = records.chain
  362. if not pending_ranks(chain):
  363. return WRITE_REPORT
  364. scope, reviewed_range = meta.get("scope"), records.coverage.get("range")
  365. args = {
  366. "scanRoot": meta.get("scan_root"),
  367. "runDir": str(run_dir),
  368. "mode": meta.get("mode"),
  369. "effort": meta.get("effort"),
  370. "scope": scope if is_list(scope) and scope else None,
  371. "range": reviewed_range if is_str(reviewed_range) else None,
  372. "verify": {
  373. "shard": chain["shard"] + 1,
  374. "idBase": chain["next_id"],
  375. "pending": chain["pending"],
  376. "retry": chain["retry"],
  377. },
  378. }
  379. return (
  380. "next: make this Workflow call exactly as printed, wait for it with keep-waiting.sh "
  381. "as before, then run save_result.py on its output file\n"
  382. f'Workflow({{ name: "claude-security:scan", args: {strictjson.text(args)} }})'
  383. )
  384. def otherwise(run_dir: Path, fallback: str) -> str:
  385. """The `next:` line when this result could not be recorded."""
  386. if os.path.isfile(run_dir / "votes.json"):
  387. return WRITE_REPORT + "; its stamp will name what was not verified"
  388. return f"next: {fallback}"
  389. def standing(run_dir: Path, meta: JsonMap, scan: Records | None, result: JsonMap) -> Records:
  390. """The scan's records once `result` is taken into account, written if that changed them.
  391. `meta` is the scan's settings (scan_settings), which placing a finding reads.
  392. """
  393. if result.get("started") is False:
  394. if scan is None:
  395. msg = "the workflow did not accept its settings, so no scan ran"
  396. raise CannotContinueError(msg)
  397. return scan
  398. run, rows = records_in(result)
  399. made_for = result.get("runDir")
  400. if not is_str(made_for) or os.path.abspath(made_for) != str(run_dir):
  401. msg = f"the result names {made_for!r} as its run directory, not this one"
  402. raise CannotContinueError(msg)
  403. revision = meta.get("revision")
  404. reviewed_range = run.coverage.get("range")
  405. recorded_range = change_range(revision) if is_map(revision) else None
  406. if is_str(reviewed_range) and reviewed_range != recorded_range:
  407. msg = (
  408. f"the result reviewed {reviewed_range} but this scan recorded "
  409. f"{recorded_range or 'no change'}, so it is not a review of the change that was "
  410. "listed: run the scan again with the range: line write_scan_meta.py prints"
  411. )
  412. raise CannotContinueError(msg)
  413. if scan is None and run.chain["shard"] != 1:
  414. msg = f"this is run {run.chain['shard']} but nothing is recorded yet"
  415. raise CannotContinueError(msg)
  416. if scan is not None and run.chain["shard"] <= scan.chain["shard"]:
  417. return scan
  418. run = run._replace(
  419. findings=[placed(item, meta) for item in run.findings],
  420. coverage=checked(run.coverage, meta, run_dir),
  421. )
  422. records = run if scan is None else merged(scan, run)
  423. write_records(run_dir, records, rows)
  424. return records
  425. def save(output_file: Path, run_dir: Path) -> None:
  426. """Fold the result into the run directory and print where the scan stands."""
  427. meta = scan_settings(run_dir)
  428. records = standing(run_dir, meta, recorded(run_dir), result_in(output_file))
  429. print(summary(records))
  430. print(next_step(run_dir, meta, records))
  431. def argument_parser() -> argparse.ArgumentParser:
  432. """The command line: the workflow's output file and the scan's run directory."""
  433. parser = argparse.ArgumentParser(
  434. prog="save_result.py",
  435. description="Record a scan workflow's result in its run directory and say what to do next.",
  436. allow_abbrev=False,
  437. )
  438. parser.add_argument("output_file", help="the file the workflow's completion notice names")
  439. parser.add_argument("run_dir", help="the scan's run directory")
  440. return parser
  441. def main(argv: list[str]) -> int:
  442. args = argument_parser().parse_args(argv, namespace=Args())
  443. output_file = Path(os.path.abspath(args.output_file))
  444. # abspath folds ".." without following symlinks, so later checks see this path's own name.
  445. run_dir = Path(os.path.abspath(args.run_dir))
  446. try:
  447. save(output_file, run_dir)
  448. except (NotAResultError, CannotContinueError) as error:
  449. sys.stderr.write(f"save_result.py: {error}\n")
  450. print(otherwise(run_dir, error.fallback))
  451. return 1
  452. except (OSError, ValueError) as error:
  453. sys.stderr.write(f"save_result.py: could not read or write the run's files: {error}\n")
  454. print(otherwise(run_dir, CannotContinueError.fallback))
  455. return 1
  456. return 0
  457. if __name__ == "__main__":
  458. console.tolerate_undecodable_names()
  459. sys.exit(main(sys.argv[1:]))