feat: add control panel
This commit is contained in:
@@ -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, "..", "..", ".."))
|
||||
|
||||
Reference in New Issue
Block a user