Files
CASAN/packages/casan-harness/scripts/python/harness_hash.py
T

106 lines
3.7 KiB
Python
Executable File

#!/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 <harness_root> # prints "<algo>:<hex>"
harness_hash.py manifest <harness_root> # prints JSON {file: sha256}
harness_hash.py verify <harness_root> <expected> # 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 <compute|manifest|verify> <harness_root> [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())