feat(install): Plan-21 hybrid global install + casan init (pin+verify)
Adopt CASAN like a normal tool (codegraph-style): install the harness ONCE
per machine, then `casan init` per project writes CONFIG ONLY — the harness
is no longer copied into every repo.
- install.sh / install.ps1: global bootstrap (curl|sh / irm|iex or local
source). Installs harness to $CASAN_HOME/versions/<ver>, writes a `casan`
launcher that resolves the shared harness + the current project's .specify,
and records a gate-code integrity hash. CASAN_NO_PATH_LINK for tests.
- harness_hash.py: deterministic content hash over gate code (scripts/bash,
scripts/python, security, level5) — the pin+verify anchor.
- casan-init.py: `casan init` writes .casan/{config,version.lock,agentic.env},
.specify/ marker, and the Plan-20 client hooks — no harness copy. `verify`
recomputes the harness hash LIVE and compares to the project pin (drift/
tamper -> rc 3), preserving the Plan-16 trusted-gates guarantee off-repo.
- bin/casan: new `init` and `verify-harness` commands.
- hybrid-install-tests.sh: 21/21 (install, config-only init, no-copy, pin,
verify ok, tamper drift, bridge runs against project state via global harness).
- docs: CASAN_INSTALL_HYBRID.md + Plan-21.
The path model (casan-paths.sh) already separated harness/state/domain roots,
so this is installer + init, not a core rewrite. Remote dist tarball, real
Windows run, and signed .harness-hash are the documented next steps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f6d28a3163
commit
8450f8ca1a
+105
@@ -0,0 +1,105 @@
|
||||
#!/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
|
||||
|
||||
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")
|
||||
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())
|
||||
Reference in New Issue
Block a user