#!/usr/bin/env python3 """Build a private, self-contained reconciliation bundle from a billing run.""" from __future__ import annotations import argparse import datetime as dt import hashlib import json import os import re import shutil import tempfile from pathlib import Path from typing import Iterator from runtime_config import PLUGIN_ROOT, load_runtime_config REQUIRED_ROOT_FILES = ("result.json", "validation.json", "lineage.json") OPTIONAL_ROOT_FILES = ("result.csv",) REQUIRED_DIRECTORIES = ("evidence", "normalized", "calculation") OPTIONAL_DIRECTORIES = ("input",) SECRET_CONTENT_PATTERNS = ( ("GitHub token assignment", re.compile(rb"(?im)^\s*GITHUB_(?:PAT|TOKEN)\s*=")), ("Clinic API key assignment", re.compile(rb"(?im)^\s*CLINIC_API_KEY\s*=")), ("O11Y Auth0 token assignment", re.compile(rb"(?im)^\s*O11Y_AUTH0_TOKEN\s*=")), ("AWS secret key assignment", re.compile(rb"(?im)^\s*AWS_SECRET_ACCESS_KEY\s*=")), ("private key field", re.compile(rb"(?i)[\"']private_key[\"']\s*:")), ("bearer authorization header", re.compile(rb"(?i)Authorization\s*:\s*Bearer\s+")), ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Build a reconciliation directory containing report, data, code, and hashes." ) parser.add_argument("--run-dir", type=Path, required=True) parser.add_argument( "--report-source", type=Path, help="Markdown report to copy as report.md; defaults to report.md, manual-invoice.md, or summary.md in the run.", ) parser.add_argument( "--output-dir", type=Path, help=( "Exact destination directory. Defaults to " "$MANUAL_INVOICE_OUTPUT_DIR/ or /manual-invoice-output/." ), ) parser.add_argument("--force", action="store_true", help="Replace an existing destination.") return parser.parse_args() def ensure_plain_file(path: Path, label: str) -> None: if path.is_symlink(): raise ValueError(f"Refusing symlink for {label}: {path}") if not path.is_file(): raise ValueError(f"Missing {label}: {path}") def ensure_plain_directory(path: Path, label: str) -> None: if path.is_symlink(): raise ValueError(f"Refusing symlink for {label}: {path}") if not path.is_dir(): raise ValueError(f"Missing {label}: {path}") def iter_tree_files(root: Path) -> Iterator[Path]: ensure_plain_directory(root, "source directory") for current, directories, files in os.walk(root, followlinks=False): current_path = Path(current) for name in directories: child = current_path / name if child.is_symlink(): raise ValueError(f"Refusing symlink in run artifacts: {child}") for name in files: child = current_path / name ensure_plain_file(child, "run artifact") yield child def suspicious_filename(path: Path) -> str | None: lower_parts = [part.lower() for part in path.parts] if any(part == ".env" or part.startswith(".env.") for part in lower_parts): return "environment file" name = path.name.lower() if name.endswith(".json") and ( "service-account" in name or "service_account" in name or "credential" in name or name == "application_default_credentials.json" ): return "credential JSON filename" return None def scan_file_for_secrets(path: Path) -> None: filename_reason = suspicious_filename(path) if filename_reason: raise ValueError(f"Refusing possible secret-bearing file ({filename_reason}): {path}") overlap = b"" with path.open("rb") as handle: while True: chunk = handle.read(1024 * 1024) if not chunk: break candidate = overlap + chunk for label, pattern in SECRET_CONTENT_PATTERNS: if pattern.search(candidate): raise ValueError(f"Refusing possible secret content ({label}) in: {path}") overlap = candidate[-512:] def copy_file(source: Path, destination: Path) -> None: ensure_plain_file(source, "source file") scan_file_for_secrets(source) destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) shutil.copyfile(source, destination) os.chmod(destination, 0o600) def copy_directory(source: Path, destination: Path) -> None: destination.mkdir(parents=True, exist_ok=True, mode=0o700) for source_file in iter_tree_files(source): relative = source_file.relative_to(source) copy_file(source_file, destination / relative) def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def write_manifest(bundle: Path, run_id: str, source_run: Path) -> dict[str, object]: files = [] total_size = 0 for path in sorted(bundle.rglob("*")): if not path.is_file() or path.name == "manifest.json": continue relative = path.relative_to(bundle).as_posix() size = path.stat().st_size total_size += size files.append( { "path": relative, "size_bytes": size, "sha256": sha256_file(path), } ) manifest: dict[str, object] = { "format": "manual-invoice-reconciliation-bundle/v1", "run_id": run_id, "source_run_dir": str(source_run), "generated_at_utc": dt.datetime.now(dt.timezone.utc).isoformat(), "hash_algorithm": "sha256", "manifest_excludes": ["manifest.json"], "file_count": len(files), "total_size_bytes": total_size, "files": files, } manifest_path = bundle / "manifest.json" manifest_path.write_text( json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" ) os.chmod(manifest_path, 0o600) return manifest def set_private_permissions(root: Path) -> None: for path in root.rglob("*"): os.chmod(path, 0o700 if path.is_dir() else 0o600) os.chmod(root, 0o700) def resolve_report(run_dir: Path, requested: Path | None) -> Path: if requested: report = requested.expanduser().resolve() ensure_plain_file(report, "report source") return report for name in ("report.md", "manual-invoice.md", "summary.md"): candidate = run_dir / name if candidate.is_file() and not candidate.is_symlink(): return candidate raise ValueError( "No report source found. Create report.md, manual-invoice.md, or summary.md, " "or pass --report-source." ) def main() -> int: os.umask(0o077) load_runtime_config() args = parse_args() run_dir = args.run_dir.expanduser().resolve() ensure_plain_directory(run_dir, "run directory") run_id = run_dir.name report_source = resolve_report(run_dir, args.report_source) for name in REQUIRED_ROOT_FILES: ensure_plain_file(run_dir / name, name) for name in REQUIRED_DIRECTORIES: ensure_plain_directory(run_dir / name, name) if args.output_dir: output_dir = args.output_dir.expanduser().resolve() else: configured_root = os.environ.get("MANUAL_INVOICE_OUTPUT_DIR") output_root = ( Path(configured_root).expanduser().resolve() if configured_root else ( PLUGIN_ROOT.parent / "manual-invoice-output" if Path.cwd().resolve() == PLUGIN_ROOT or PLUGIN_ROOT in Path.cwd().resolve().parents else (Path.cwd() / "manual-invoice-output").resolve() ) ) output_dir = output_root / run_id if ( output_dir == Path(output_dir.anchor) or output_dir == run_dir or run_dir in output_dir.parents or output_dir in run_dir.parents ): raise ValueError( "The reconciliation output must be a dedicated directory outside the source run." ) if output_dir.exists() and not args.force: raise FileExistsError(f"Refusing to overwrite existing reconciliation bundle: {output_dir}") output_dir.parent.mkdir(parents=True, exist_ok=True, mode=0o700) staging = Path( tempfile.mkdtemp(prefix=f".{output_dir.name}.tmp-", dir=str(output_dir.parent)) ) try: copy_file(report_source, staging / "report.md") for name in REQUIRED_ROOT_FILES + OPTIONAL_ROOT_FILES: source = run_dir / name if source.exists(): copy_file(source, staging / name) for name in OPTIONAL_DIRECTORIES: source = run_dir / name if source.exists(): copy_directory(source, staging / name) copy_directory(run_dir / "evidence", staging / "raw") copy_directory(run_dir / "normalized", staging / "normalized") copy_directory(run_dir / "calculation", staging / "calculation") manifest = write_manifest(staging, run_id, run_dir) set_private_permissions(staging) if output_dir.exists(): shutil.rmtree(output_dir) os.replace(staging, output_dir) except Exception: shutil.rmtree(staging, ignore_errors=True) raise result = { "status": "created", "run_id": run_id, "output_dir": str(output_dir), "report": str(output_dir / "report.md"), "file_count": manifest["file_count"], "total_size_bytes": manifest["total_size_bytes"], "manifest": str(output_dir / "manifest.json"), } print(json.dumps(result, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())