107 lines
3.7 KiB
Python
Executable File
107 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 level5 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", "level5", "adapters", "schemas")
|
|
SKIP_DIR_NAMES = {"__pycache__", ".git", "node_modules"}
|
|
SKIP_SUFFIXES = (".pyc", ".pyo", ".log", ".tmp", ".DS_Store")
|
|
# Within level5, only policy/config, not regenerated runtime artifacts.
|
|
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())
|