save_result.py 20 KB

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