feat: add control panel

This commit is contained in:
thanhnv
2026-07-08 19:07:35 +09:00
parent a07b15e489
commit 3be9970c15
104 changed files with 3639 additions and 461 deletions
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""CASAN Approval Inbox + Delegation resolver (Plan-13 HITL surface).
Harness-owned primitive for the Control Panel:
* delegation-policy.yaml resolves whether an action needs human approval;
* pending proposals are stored in a versioned JSON file;
* approve/reject decisions are append-only oversight events with a hash chain;
* Separation of Duties is enforced in the primitive (proposer != approver).
The web app may present this data, but the governance state lives in the harness.
"""
import argparse
import hashlib
import json
import os
import sys
from datetime import datetime, timezone
GENESIS_HASH = "0" * 64
def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
p = d
while p != os.path.dirname(p):
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
return p
p = os.path.dirname(p)
return os.path.abspath(os.path.join(d, "..", "..", ".."))
ROOT = project_root()
HARNESS_ROOT = os.path.join(ROOT, "packages", "casan-harness")
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def inbox_path() -> str:
return os.environ.get("CASAN_APPROVAL_INBOX_FILE") or os.path.join(
ROOT, ".specify", "level5", "approval-inbox.json"
)
def policy_path() -> str:
return os.environ.get("CASAN_DELEGATION_POLICY") or os.path.join(
HARNESS_ROOT, "config", "delegation-policy.yaml"
)
def empty_store():
return {"proposals": [], "oversight": []}
def load_store():
path = inbox_path()
if not os.path.isfile(path):
return empty_store()
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
return {
"proposals": data.get("proposals", []),
"oversight": data.get("oversight", []),
}
def save_store(store):
path = inbox_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(store, fh, indent=2, ensure_ascii=False)
fh.write("\n")
os.replace(tmp, path)
def hash_entry(entry) -> str:
return hashlib.sha256(json.dumps(entry, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
def append_oversight(store, base):
prev = store["oversight"][-1] if store["oversight"] else None
prev_hash = prev["hash"] if prev else GENESIS_HASH
seq = len(store["oversight"]) + 1
core = {"seq": seq, **base, "prevHash": prev_hash}
store["oversight"].append({**core, "hash": hash_entry(core)})
def load_policy():
path = policy_path()
try:
with open(path, encoding="utf-8") as fh:
# File is JSON-in-YAML syntax: valid YAML, dependency-free JSON parse.
return json.load(fh)
except Exception as exc:
raise SystemExit(f"DELEGATION_POLICY_DENY unreadable policy: {exc}")
def resolve(policy, project, action, risk, sensitive):
projects = policy.get("projects", {})
project_cfg = projects.get(project, {})
level = project_cfg.get("actions", {}).get(action) or project_cfg.get("default_level") or policy.get("default_level", "L1")
level_cfg = policy.get("levels", {}).get(level)
if not level_cfg:
return {"project": project, "action": action, "level": level, "requires_approval": True, "reason": "unknown_level_fail_closed"}
high = sensitive or risk in {"high", "critical", "security-sensitive"}
requires = bool(level_cfg.get("high_requires_approval" if high else "standard_requires_approval", True))
return {
"project": project,
"action": action,
"level": level,
"risk": risk,
"sensitive": bool(sensitive),
"requires_approval": requires,
"reason": "high_risk_gate" if high and requires else ("level_requires_approval" if requires else "delegated_by_policy"),
}
def cmd_resolve(args):
verdict = resolve(load_policy(), args.project, args.action, args.risk, args.sensitive)
print(json.dumps(verdict, ensure_ascii=False))
return 0 if not verdict["requires_approval"] else 2
def cmd_submit(args):
policy_verdict = resolve(load_policy(), args.project, args.action, args.risk, args.sensitive)
store = load_store()
raw = f"{args.action}|{args.target}|{args.proposer}|{args.reason}|{now_iso()}"
pid = "AP-" + hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
try:
payload = json.loads(args.payload) if args.payload else {}
except ValueError:
print("APPROVAL_SUBMIT_DENY payload_not_json", file=sys.stderr)
return 64
status = "pending" if policy_verdict["requires_approval"] else "auto_allowed"
proposal = {
"id": pid,
"status": status,
"project": args.project,
"action": args.action,
"target": args.target,
"risk": args.risk,
"sensitive": bool(args.sensitive),
"proposer": args.proposer,
"reason": args.reason,
"payload": payload,
"delegation": policy_verdict,
"created_at": now_iso(),
"decided_at": None,
"approver": None,
"decision_reason": None,
}
store["proposals"].append(proposal)
append_oversight(store, {
"event": "proposal_submitted",
"proposal_id": pid,
"actor": args.proposer,
"action": args.action,
"target": args.target,
"status": status,
"reason": args.reason,
"at": proposal["created_at"],
})
save_store(store)
print(json.dumps(proposal, ensure_ascii=False))
return 0
def cmd_list(args):
store = load_store()
proposals = store["proposals"]
if args.status != "all":
proposals = [p for p in proposals if p.get("status") == args.status]
print(json.dumps({"count": len(proposals), "proposals": proposals, "oversight": store["oversight"][-50:]}, ensure_ascii=False))
return 0
def cmd_decide(args):
if args.decision not in {"approve", "reject"}:
print("APPROVAL_DECIDE_DENY decision_must_be_approve_or_reject", file=sys.stderr)
return 64
store = load_store()
proposal = next((p for p in store["proposals"] if p.get("id") == args.id), None)
if proposal is None:
print(f"APPROVAL_DECIDE_DENY unknown_id {args.id}", file=sys.stderr)
return 1
if proposal.get("status") != "pending":
print(f"APPROVAL_DECIDE_DENY not_pending status={proposal.get('status')}", file=sys.stderr)
return 3
if proposal.get("proposer") == args.approver:
print(f"APPROVAL_DECIDE_DENY sod_self_approval actor={args.approver}", file=sys.stderr)
return 3
proposal["status"] = "approved" if args.decision == "approve" else "rejected"
proposal["approver"] = args.approver
proposal["decision_reason"] = args.reason
proposal["decided_at"] = now_iso()
append_oversight(store, {
"event": f"proposal_{proposal['status']}",
"proposal_id": proposal["id"],
"actor": args.approver,
"action": proposal.get("action"),
"target": proposal.get("target"),
"status": proposal["status"],
"reason": args.reason,
"at": proposal["decided_at"],
})
save_store(store)
print(json.dumps(proposal, ensure_ascii=False))
return 0
def cmd_verify(args):
store = load_store()
prev = GENESIS_HASH
for entry in store["oversight"]:
rest = {k: v for k, v in entry.items() if k != "hash"}
if rest.get("prevHash") != prev or hash_entry(rest) != entry.get("hash"):
print(f"APPROVAL_INBOX_AUDIT ok=false brokenAt={entry.get('seq')}")
return 1
prev = entry["hash"]
print(f"APPROVAL_INBOX_AUDIT ok=true records={len(store['oversight'])} head={prev}")
return 0
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("resolve")
r.add_argument("--project", default="default")
r.add_argument("--action", required=True)
r.add_argument("--risk", default="standard")
r.add_argument("--sensitive", action="store_true")
s = sub.add_parser("submit")
s.add_argument("--project", default="default")
s.add_argument("--action", required=True)
s.add_argument("--target", required=True)
s.add_argument("--risk", default="standard")
s.add_argument("--sensitive", action="store_true")
s.add_argument("--proposer", required=True)
s.add_argument("--reason", required=True)
s.add_argument("--payload", default="{}")
l = sub.add_parser("list")
l.add_argument("--status", default="pending", choices=["pending", "approved", "rejected", "auto_allowed", "all"])
d = sub.add_parser("decide")
d.add_argument("--id", required=True)
d.add_argument("--decision", required=True)
d.add_argument("--approver", required=True)
d.add_argument("--reason", required=True)
sub.add_parser("verify-audit")
args = ap.parse_args()
if args.cmd == "resolve":
return cmd_resolve(args)
if args.cmd == "submit":
return cmd_submit(args)
if args.cmd == "list":
return cmd_list(args)
if args.cmd == "decide":
return cmd_decide(args)
if args.cmd == "verify-audit":
return cmd_verify(args)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -31,7 +31,7 @@ def _casan_app_root():
_d = os.path.abspath(os.path.dirname(__file__))
_p = _d
while _p != os.path.dirname(_p):
if os.path.isdir(os.path.join(_p, ".specify")):
if os.path.isdir(os.path.join(_p, ".specify")) or os.path.isdir(os.path.join(_p, "packages/casan-harness")):
return _p
_p = os.path.dirname(_p)
return os.path.abspath(os.path.join(_d, "..", "..", ".."))
@@ -24,11 +24,15 @@ fi
_casan_paths_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Walk up from a starting dir until a dir containing `.specify/` is found.
# Walk up from a starting dir to the app/bundle root. Primary marker is the `.specify`
# state dir (present in an adopted repo). Secondary marker is `packages/casan-harness`
# (present in a freshly-extracted release bundle that has no `.specify` yet) — so a
# just-unpacked core/devkit/demo bundle resolves correctly and creates `.specify` on
# first write. In a real repo `.specify` matches first, so behavior is unchanged.
_casan_find_app_root() {
local d="$1"
while [[ -n "$d" && "$d" != "/" ]]; do
if [[ -d "$d/.specify" ]]; then
if [[ -d "$d/.specify" || -d "$d/packages/casan-harness" ]]; then
printf '%s\n' "$d"
return 0
fi
@@ -89,6 +89,7 @@ run "phase-h4-split-inject" bash "$TESTS/phase-h4-split-inject-tests.sh"
run "phase10-traceability" bash "$TESTS/phase10-traceability-tests.sh"
run "phase08-compression" bash "$TESTS/phase08-compression-tests.sh"
run "phase-control-plane" bash "$TESTS/phase-control-plane-tests.sh"
run "phase-control-plane-hitl" bash "$TESTS/phase-control-plane-hitl-tests.sh"
run "phase-rbac" bash "$TESTS/phase-rbac-tests.sh"
run "phase-rbac-audit" bash "$TESTS/phase-rbac-audit-tests.sh"
run "phase-rai" bash "$TESTS/phase-rai-tests.sh"
@@ -27,7 +27,7 @@ def _casan_app_root():
_d = os.path.abspath(os.path.dirname(__file__))
_p = _d
while _p != os.path.dirname(_p):
if os.path.isdir(os.path.join(_p, ".specify")):
if os.path.isdir(os.path.join(_p, ".specify")) or os.path.isdir(os.path.join(_p, "packages/casan-harness")):
return _p
_p = os.path.dirname(_p)
return os.path.abspath(os.path.join(_d, "..", "..", ".."))
@@ -54,7 +54,7 @@ def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
p = d
while p != os.path.dirname(p):
if os.path.isdir(os.path.join(p, ".specify")):
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
return p
p = os.path.dirname(p)
return os.path.abspath(os.path.join(d, "..", "..", ".."))
@@ -25,7 +25,7 @@ def _app_root(start):
# dashboards + runtime logs live at the app's `.specify`, so walk UP for it.
d = pathlib.Path(start).resolve()
for p in (d, *d.parents):
if (p / ".specify").is_dir():
if (p / ".specify").is_dir() or (p / "packages" / "casan-harness").is_dir():
return p
return d.parents[3]
@@ -61,7 +61,7 @@ def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
p = d
while p != os.path.dirname(p):
if os.path.isdir(os.path.join(p, ".specify")):
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
return p
p = os.path.dirname(p)
return os.path.abspath(os.path.join(d, "..", "..", ".."))
@@ -31,7 +31,7 @@ def _casan_app_root():
_d = os.path.abspath(os.path.dirname(__file__))
_p = _d
while _p != os.path.dirname(_p):
if os.path.isdir(os.path.join(_p, ".specify")):
if os.path.isdir(os.path.join(_p, ".specify")) or os.path.isdir(os.path.join(_p, "packages/casan-harness")):
return _p
_p = os.path.dirname(_p)
return os.path.abspath(os.path.join(_d, "..", "..", ".."))
@@ -39,7 +39,7 @@ def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
p = d
while p != os.path.dirname(p):
if os.path.isdir(os.path.join(p, ".specify")):
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
return p
p = os.path.dirname(p)
return os.path.abspath(os.path.join(d, "..", "..", ".."))
@@ -22,7 +22,7 @@ def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
p = d
while p != os.path.dirname(p):
if os.path.isdir(os.path.join(p, ".specify")):
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
return p
p = os.path.dirname(p)
return os.path.abspath(os.path.join(d, "..", "..", ".."))