#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Deterministic harness integrity hash (Plan-21 hybrid install pin+verify). Computes a stable content hash over the CASAN gate code so a globally-installed harness can be PINNED by a project (`.casan/version.lock`) and VERIFIED at runtime. If the resolved global harness drifts or is tampered relative to the pin, the project can fail closed — preserving the "gates are trusted, not arbitrary code" guarantee from Plan-16 even when the harness lives outside the repo. Only GATE-relevant trees are hashed (the code that makes security/governance decisions), never runtime state/logs: scripts/bash scripts/python security config adapters schemas stdlib-only, deterministic (sorted paths), text-mode agnostic (hashes raw bytes). Usage: harness_hash.py compute # prints ":" harness_hash.py manifest # prints JSON {file: sha256} harness_hash.py verify # exit 0 match / 3 drift """ from __future__ import annotations import hashlib import json import os import sys ALGO = "sha256" HASHED_SUBTREES = ( "scripts/bash", "scripts/python", "security", "config", "adapters", "schemas") SKIP_DIR_NAMES = {"__pycache__", ".git", "node_modules"} SKIP_SUFFIXES = (".pyc", ".pyo", ".log", ".tmp", ".DS_Store") SKIP_BASENAMES = {"policy-manifest.json", "policy-manifest.sig", "project-registry.json"} def _iter_files(root): for sub in HASHED_SUBTREES: base = os.path.join(root, sub) if not os.path.isdir(base): continue for dirpath, dirnames, filenames in os.walk(base): dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIR_NAMES) for name in sorted(filenames): if name in SKIP_BASENAMES: continue if any(name.endswith(s) for s in SKIP_SUFFIXES): continue full = os.path.join(dirpath, name) rel = os.path.relpath(full, root).replace(os.sep, "/") yield rel, full def manifest(root): out = {} for rel, full in _iter_files(root): try: with open(full, "rb") as fh: out[rel] = hashlib.sha256(fh.read()).hexdigest() except (OSError, IOError): out[rel] = "UNREADABLE" return out def compute(root): m = manifest(root) joiner = "\n".join("%s:%s" % (rel, m[rel]) for rel in sorted(m)) digest = hashlib.sha256(joiner.encode("utf-8")).hexdigest() return "%s:%s" % (ALGO, digest) def main(argv=None): argv = argv if argv is not None else sys.argv[1:] if len(argv) < 2: sys.stderr.write("usage: harness_hash.py [expected]\n") return 64 cmd, root = argv[0], argv[1] if not os.path.isdir(root): sys.stderr.write("harness_hash: not a directory: %s\n" % root) return 66 if cmd == "compute": print(compute(root)) return 0 if cmd == "manifest": print(json.dumps(manifest(root), ensure_ascii=False, indent=2, sort_keys=True)) return 0 if cmd == "verify": if len(argv) < 3: sys.stderr.write("harness_hash: verify needs an expected hash\n") return 64 actual = compute(root) expected = argv[2].strip() if actual == expected: print("HARNESS_INTEGRITY_OK %s" % actual) return 0 sys.stderr.write("HARNESS_INTEGRITY_DRIFT expected=%s actual=%s\n" % (expected, actual)) return 3 sys.stderr.write("harness_hash: unknown command %s\n" % cmd) return 64 if __name__ == "__main__": sys.exit(main())