Files
CASAN/AINative_OKR_CASAN5/.specify/scripts/bash/control-plane-settings.py
T
2026-07-06 21:47:38 +09:00

289 lines
11 KiB
Python

#!/usr/bin/env python3
"""CASAN Control Plane — governed settings store (Plan-13 core, harness-owned).
This is the REUSABLE governance asset: it lives in the core harness, not in any
generated app (e.g. OKR). Every write is (1) deny-by-default (only whitelisted
keys), (2) approval-gated for security-sensitive keys, (3) versioned, and (4)
recorded in a hash-linked audit chain so tampering is detectable.
The standalone Control Plane web app (control-plane/) calls this CLI for all
writes so the governance logic exists exactly once — in the harness.
Store file: $CASAN_CP_STORE_FILE (default .specify/level5/control-plane-settings.json)
"""
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime, timezone
SETTINGS_POLICY = {
"compression.enabled": {"securitySensitive": False, "description": "Toggle context/token compression"},
"compression.mode": {"securitySensitive": False, "description": "extractive | structural | semantic-dedup | abstractive"},
"cost.absolute_cap_usd": {"securitySensitive": False, "description": "Absolute per-call cost cap"},
"model.primary": {"securitySensitive": False, "description": "Primary model spec (e.g. ollama:ornith:9b)"},
"security.strict": {"securitySensitive": True, "description": "H4 fail-closed strict mode"},
"kill_switch.global": {"securitySensitive": True, "description": "Global kill-switch engage/disengage"},
}
GENESIS_HASH = "0" * 64
def project_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
def store_path() -> str:
return os.environ.get(
"CASAN_CP_STORE_FILE",
os.path.join(project_root(), ".specify/level5/control-plane-settings.json"),
)
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def empty_store():
return {"settings": {}, "history": {}, "audit": []}
def load_store():
path = store_path()
if not os.path.isfile(path):
return empty_store()
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
return {"settings": data.get("settings", {}), "history": data.get("history", {}), "audit": data.get("audit", [])}
def save_store(store) -> None:
path = store_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(store, fh, indent=2, ensure_ascii=False)
fh.write("\n")
def hash_entry(entry) -> str:
return hashlib.sha256(json.dumps(entry, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
def append_audit(store, base) -> None:
prev = store["audit"][-1] if store["audit"] else None
prev_hash = prev["hash"] if prev else GENESIS_HASH
seq = len(store["audit"]) + 1
without_hash = {"seq": seq, **base, "prevHash": prev_hash}
store["audit"].append({**without_hash, "hash": hash_entry(without_hash)})
# --- SEC-06 (H-06): sign the audit-chain HEAD -------------------------------
# The hash chain alone is recomputable: a file-writer who edits the store can
# recompute every hash and `verify-audit` (chain-only) would still PASS, so
# governance-report would falsely report CERTIFIED. Signing the head with an
# OFF-REPO private key (pubkey provisioned out-of-band; KMS in prod = SEC-02/16)
# means a recompute-attacker without the key cannot forge a matching signature.
def _enforced() -> bool:
return os.environ.get("CASAN_PROFILE") == "prod" or os.environ.get("CASAN_VERIFY_STRICT") == "1"
def _cp_priv() -> str:
key_dir = os.environ.get("CASAN_CP_KEY_DIR") or os.path.join(os.path.expanduser("~"), ".casan", "audit-keys")
return os.path.join(key_dir, "cp-private.pem")
def _cp_pub() -> str:
# Prod provisions this out-of-band (or via KMS). Default is adjacent to the
# store for self-contained dev; an attacker who can also rewrite the pubkey is
# covered by ARCH-01/SEC-16 (signed harness+policy bundle).
return os.environ.get("CASAN_CP_PUB") or (store_path() + ".pub")
def _head_paths():
sp = store_path()
return sp + ".head", sp + ".head.sig"
def chain_head(store) -> str:
return store["audit"][-1]["hash"] if store["audit"] else GENESIS_HASH
def sign_head(store) -> None:
head_file, sig_file = _head_paths()
head = chain_head(store)
with open(head_file, "w", encoding="utf-8") as fh:
fh.write(head)
ossl = shutil.which("openssl")
if not ossl:
return # keyless dev: no signature — enforced mode rejects at verify time
priv, pub = _cp_priv(), _cp_pub()
os.makedirs(os.path.dirname(priv), exist_ok=True)
if not os.path.isfile(priv):
subprocess.run([ossl, "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048",
"-out", priv], capture_output=True)
try:
os.chmod(priv, 0o600)
except OSError:
pass
os.makedirs(os.path.dirname(pub) or ".", exist_ok=True)
# Always re-export the pubkey so it matches the signing key (key-sync invariant).
subprocess.run([ossl, "rsa", "-in", priv, "-pubout", "-out", pub], capture_output=True)
subprocess.run([ossl, "dgst", "-sha256", "-sign", priv, "-out", sig_file, head_file], capture_output=True)
def verify_signature(store):
"""Returns state in {'signed','unsigned','invalid'} + a detail string."""
head_file, sig_file = _head_paths()
pub = _cp_pub()
ossl = shutil.which("openssl")
if not (ossl and os.path.isfile(head_file) and os.path.isfile(sig_file) and os.path.isfile(pub)):
return "unsigned", "missing signature/pubkey/openssl"
try:
stored_head = open(head_file, encoding="utf-8").read().strip()
except OSError:
return "invalid", "head-file unreadable"
if stored_head != chain_head(store):
return "invalid", "head-file != recomputed head (chain recomputed?)"
res = subprocess.run([ossl, "dgst", "-sha256", "-verify", pub, "-signature", sig_file, head_file],
capture_output=True)
return ("signed", "ok") if res.returncode == 0 else ("invalid", "signature verify failed")
def do_set(key, value, actor, reason, approval):
policy = SETTINGS_POLICY.get(key)
if policy is None:
print(f"SETTING_NOT_ALLOWED {key}", file=sys.stderr)
return 2
if policy["securitySensitive"] and not (approval or "").strip():
print(f"APPROVAL_REQUIRED {key}", file=sys.stderr)
return 3
store = load_store()
prev = store["settings"].get(key)
if prev is not None:
store["history"].setdefault(key, []).append(prev)
nxt = {
"value": value,
"version": (prev["version"] if prev else 0) + 1,
"updatedAt": now_iso(),
"actor": actor,
"reason": reason,
}
store["settings"][key] = nxt
append_audit(store, {
"key": key, "action": "set", "value": value,
"prevValue": prev["value"] if prev else None,
"actor": actor, "reason": reason, "at": nxt["updatedAt"],
})
save_store(store)
sign_head(store)
print(json.dumps(nxt, ensure_ascii=False))
return 0
def do_rollback(key, actor, reason):
store = load_store()
history = store["history"].get(key, [])
if not history:
print(f"NO_PRIOR_VERSION {key}", file=sys.stderr)
return 4
previous = history.pop()
current = store["settings"].get(key)
restored = {
"value": previous["value"],
"version": ((current["version"] if current else previous["version"]) + 1),
"updatedAt": now_iso(),
"actor": actor,
"reason": reason,
}
store["settings"][key] = restored
append_audit(store, {
"key": key, "action": "rollback", "value": previous["value"],
"prevValue": current["value"] if current else None,
"actor": actor, "reason": reason, "at": restored["updatedAt"],
})
save_store(store)
sign_head(store)
print(json.dumps(restored, ensure_ascii=False))
return 0
def verify_audit():
store = load_store()
prev_hash = GENESIS_HASH
for entry in store["audit"]:
rest = {k: v for k, v in entry.items() if k != "hash"}
if rest.get("prevHash") != prev_hash or hash_entry(rest) != entry["hash"]:
return {"ok": False, "brokenAt": entry["seq"], "anchor": "chain-broken"}
prev_hash = entry["hash"]
# SEC-06: the chain is recomputable, so require a valid HEAD signature. A
# present-but-mismatched signature is always a failure; a MISSING signature
# fails only in enforced mode (dev stays permissive for backward compat).
sig_state, detail = verify_signature(store)
if sig_state == "invalid":
return {"ok": False, "brokenAt": None, "anchor": "signature-invalid", "detail": detail}
if sig_state == "unsigned" and _enforced():
return {"ok": False, "brokenAt": None, "anchor": "unsigned-strict-fail", "detail": detail}
return {"ok": True, "brokenAt": None, "anchor": sig_state}
def parse_value(raw):
try:
return json.loads(raw)
except (ValueError, TypeError):
return raw
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("list-policy")
sub.add_parser("get-all")
g = sub.add_parser("get"); g.add_argument("key")
s = sub.add_parser("set")
s.add_argument("key"); s.add_argument("value")
s.add_argument("--actor", required=True); s.add_argument("--reason", required=True); s.add_argument("--approval", default="")
r = sub.add_parser("rollback")
r.add_argument("key"); r.add_argument("--actor", required=True); r.add_argument("--reason", required=True)
sub.add_parser("get-audit")
sub.add_parser("verify-audit")
e = sub.add_parser("effective")
e.add_argument("key")
e.add_argument("--default", default="")
args = ap.parse_args()
if args.cmd == "list-policy":
print(json.dumps(SETTINGS_POLICY, ensure_ascii=False)); return 0
if args.cmd == "get-all":
print(json.dumps(load_store()["settings"], ensure_ascii=False)); return 0
if args.cmd == "get":
print(json.dumps(load_store()["settings"].get(args.key), ensure_ascii=False)); return 0
if args.cmd == "set":
return do_set(args.key, parse_value(args.value), args.actor, args.reason, args.approval)
if args.cmd == "rollback":
return do_rollback(args.key, args.actor, args.reason)
if args.cmd == "get-audit":
print(json.dumps(load_store()["audit"], ensure_ascii=False)); return 0
if args.cmd == "verify-audit":
verdict = verify_audit()
print(f"CP_AUDIT ok={verdict['ok']} brokenAt={verdict['brokenAt']} anchor={verdict.get('anchor')}")
return 0 if verdict["ok"] else 1
if args.cmd == "effective":
# Single read path for "effective setting": store value if set, else default.
current = load_store()["settings"].get(args.key)
if current is None:
print(args.default)
else:
value = current["value"]
print(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False))
return 0
return 2
if __name__ == "__main__":
raise SystemExit(main())