write_scan_meta.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. #!/usr/bin/env python3
  2. """Write scan-meta.json for a run: the record of what was scanned.
  3. Captures the revision from git itself and, for a whole-repository scan, the
  4. tree's top-level directories, printed as a JSON array on a `top_level_dirs:`
  5. line and recorded in the meta file.
  6. Usage:
  7. write_scan_meta.py <run_dir> <scan_root> --mode scan|changes|commit
  8. --effort low|medium|high|max [--scope a,b] [--base <ref>]
  9. [--merge-base <sha>] [--commit <sha>]
  10. Exits 0 on success. A caller error prints a one-line diagnostic to stderr and
  11. exits non-zero without writing the file.
  12. """
  13. from __future__ import annotations
  14. import argparse
  15. import json
  16. import os
  17. import subprocess
  18. import sys
  19. from typing import TypedDict, cast
  20. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  21. from render_report import RenderError, atomic_write
  22. PLUGIN_NAME = "claude-security"
  23. REPORT_DIR_PREFIX = "CLAUDE-SECURITY-"
  24. GIT_ENV = dict(os.environ, GIT_TERMINAL_PROMPT="0")
  25. class Revision(TypedDict, total=False):
  26. """What was scanned. `versioned` is always present; the rest when in git."""
  27. versioned: bool
  28. commit: str | None
  29. parent: str | None
  30. branch: str | None
  31. dirty: bool | None
  32. base: str | None
  33. merge_base: str | None
  34. class Options(TypedDict):
  35. """The parsed, typed command line -- argparse hands back untyped attributes."""
  36. run_dir: str
  37. scan_root: str
  38. mode: str
  39. effort: str
  40. scope: str
  41. base: str | None
  42. merge_base: str | None
  43. commit: str | None
  44. class MetaError(Exception):
  45. """An input error the caller must correct."""
  46. def _opt_str(value: object) -> str | None:
  47. """An argparse optional as str-or-None, typed."""
  48. return None if value is None else str(value)
  49. def git(cwd: str, *args: str) -> str | None:
  50. """One read-only git call, prompts suppressed. None on any failure."""
  51. try:
  52. out = subprocess.run(
  53. ["git", "-C", cwd, *args],
  54. env=GIT_ENV,
  55. stdout=subprocess.PIPE,
  56. stderr=subprocess.DEVNULL,
  57. timeout=30,
  58. check=False,
  59. )
  60. except (OSError, subprocess.SubprocessError):
  61. return None
  62. if out.returncode != 0:
  63. return None
  64. return out.stdout.decode("utf-8", "replace").rstrip("\r\n")
  65. def top_level_dirs(scan_root: str) -> list[str] | None:
  66. """The scan target's top-level directories, computed from the tree itself.
  67. Inside a git work tree the tracked files decide; where nothing is tracked
  68. the immediate subdirectories do. `.git` and `CLAUDE-SECURITY-*` report
  69. directories are excluded. None when the tree could not be listed.
  70. """
  71. names: set[str] = set()
  72. listing = git(scan_root, "ls-files", "-z")
  73. if listing:
  74. for path in listing.split("\0"):
  75. top, sep, _rest = path.partition("/")
  76. if sep and top:
  77. names.add(top)
  78. elif path and os.path.isdir(os.path.join(scan_root, path)):
  79. names.add(path)
  80. else:
  81. try:
  82. with os.scandir(scan_root) as entries:
  83. names.update(entry.name for entry in entries if entry.is_dir(follow_symlinks=False))
  84. except OSError:
  85. return None
  86. names.discard(".git")
  87. return sorted(n for n in names if not n.startswith(REPORT_DIR_PREFIX))
  88. def worktree_dirty(scan_root: str) -> bool | None:
  89. """True/False/None (unknown) for the working tree, ignoring report dirs."""
  90. status = git(scan_root, "status", "--porcelain", "--untracked-files=all")
  91. if status is None:
  92. return None
  93. for line in status.splitlines():
  94. if len(line) < len("XY P"):
  95. continue
  96. path = line[3:].split(" -> ")[-1]
  97. top = path.split("/", 1)[0]
  98. if top.startswith(REPORT_DIR_PREFIX):
  99. continue
  100. return True
  101. return False
  102. def capture_revision(scan_root: str, opts: Options) -> Revision:
  103. versioned = git(scan_root, "rev-parse", "--is-inside-work-tree") == "true"
  104. if opts["mode"] == "commit":
  105. if not versioned:
  106. msg = f"--mode commit needs a git repository; {scan_root!r} is not one"
  107. raise MetaError(msg)
  108. commit_arg = opts["commit"] or ""
  109. sha = git(scan_root, "rev-parse", "--verify", "--quiet", commit_arg + "^{commit}")
  110. if not sha:
  111. msg = f"--commit {commit_arg!r} does not resolve to a commit"
  112. raise MetaError(msg)
  113. return {
  114. "versioned": True,
  115. "commit": sha,
  116. "parent": git(scan_root, "rev-parse", "--verify", "--quiet", sha + "^") or None,
  117. "branch": git(scan_root, "rev-parse", "--abbrev-ref", "HEAD"),
  118. "dirty": False,
  119. }
  120. if not versioned:
  121. return {"versioned": False}
  122. revision: Revision = {
  123. "versioned": True,
  124. "commit": git(scan_root, "rev-parse", "HEAD"),
  125. "branch": git(scan_root, "rev-parse", "--abbrev-ref", "HEAD"),
  126. "dirty": worktree_dirty(scan_root),
  127. }
  128. if opts["mode"] == "changes":
  129. revision["base"] = opts["base"]
  130. revision["merge_base"] = opts["merge_base"]
  131. return revision
  132. def parse_options(argv: list[str]) -> Options:
  133. ap = argparse.ArgumentParser(prog="write_scan_meta")
  134. ap.add_argument("run_dir")
  135. ap.add_argument("scan_root")
  136. ap.add_argument("--mode", required=True, choices=["scan", "changes", "commit"])
  137. ap.add_argument("--effort", required=True, choices=["low", "medium", "high", "max"])
  138. ap.add_argument("--scope", default="")
  139. ap.add_argument("--base", default=None)
  140. ap.add_argument("--merge-base", dest="merge_base", default=None)
  141. ap.add_argument("--commit", default=None)
  142. ns = ap.parse_args(argv)
  143. return {
  144. "run_dir": str(cast("object", ns.run_dir)),
  145. "scan_root": str(cast("object", ns.scan_root)),
  146. "mode": str(cast("object", ns.mode)),
  147. "effort": str(cast("object", ns.effort)),
  148. "scope": str(cast("object", ns.scope)),
  149. "base": _opt_str(cast("object", ns.base)),
  150. "merge_base": _opt_str(cast("object", ns.merge_base)),
  151. "commit": _opt_str(cast("object", ns.commit)),
  152. }
  153. def main(argv: list[str]) -> int:
  154. opts = parse_options(argv)
  155. if opts["mode"] == "commit" and not opts["commit"]:
  156. msg = "--mode commit requires --commit <sha>"
  157. raise MetaError(msg)
  158. run_dir = os.path.realpath(os.path.abspath(opts["run_dir"]))
  159. if not os.path.isdir(run_dir):
  160. msg = f"run directory does not exist: {run_dir}"
  161. raise MetaError(msg)
  162. scan_root = os.path.realpath(os.path.abspath(opts["scan_root"]))
  163. revision = capture_revision(scan_root, opts)
  164. scope = [s.strip() for s in opts["scope"].split(",") if s.strip()]
  165. if scope and all(s in {".", "./"} for s in scope):
  166. scope = []
  167. whole_repo = opts["mode"] == "scan" and not scope
  168. top_level = top_level_dirs(scan_root) if whole_repo else None
  169. if whole_repo and top_level is None:
  170. sys.stderr.write(f"write_scan_meta: could not list {scan_root}; top_level_dirs unknown\n")
  171. meta: dict[str, object] = {
  172. "scan_root": scan_root,
  173. "run_dir": run_dir,
  174. "flow": "scan" if opts["mode"] == "scan" else "changes",
  175. "agent": f"{PLUGIN_NAME}:{PLUGIN_NAME}",
  176. "mode": opts["mode"],
  177. "scope": scope,
  178. "effort": opts["effort"],
  179. "model": None,
  180. "revision": revision,
  181. "revision_source": "self-reported",
  182. "top_level_dirs": top_level,
  183. }
  184. path = os.path.join(run_dir, "scan-meta.json")
  185. atomic_write(path, json.dumps(meta, indent=2) + "\n")
  186. sys.stdout.write(f"scan-meta.json written: {path}\n")
  187. sys.stdout.write(f"revision: {revision.get('commit') or 'UNVERSIONED'}\n")
  188. sys.stdout.write(f"top_level_dirs: {json.dumps(top_level)}\n")
  189. return 0
  190. if __name__ == "__main__":
  191. try:
  192. sys.exit(main(sys.argv[1:]))
  193. except (MetaError, RenderError) as error:
  194. sys.stderr.write(f"write_scan_meta: {error}\n")
  195. sys.exit(2)
  196. except OSError as error:
  197. sys.stderr.write(f"write_scan_meta: could not write the run's output: {error}\n")
  198. sys.exit(2)