291 lines
11 KiB
Python
291 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Plan-18 MVP-2 agent/skill governance resolver.
|
|
|
|
This is a harness-owned policy primitive. It binds a chat turn to an agent,
|
|
skill, delegation level, model role, and tool allowlist before any runtime is
|
|
invoked. Unknown policy, RBAC failure, role mismatch, delegation escalation, or
|
|
tool outside the agent allowlist all fail closed.
|
|
"""
|
|
import argparse
|
|
import datetime
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
PYTHON_BIN = sys.executable or "/usr/bin/python3"
|
|
|
|
|
|
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")
|
|
HARNESS_BIN = os.path.join(HARNESS_ROOT, "scripts", "bash")
|
|
RBAC = os.path.join(HARNESS_BIN, "rbac-check.py")
|
|
TENANT_STORE = os.path.join(HARNESS_BIN, "tenant-store.sh")
|
|
GENESIS_HASH = "0" * 64
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def sha(text: str) -> str:
|
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def state_root() -> str:
|
|
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
|
|
|
|
|
def tenant_path(logical: str, fallback: str) -> str:
|
|
if os.environ.get("CASAN_TENANT_ID"):
|
|
r = subprocess.run(["bash", TENANT_STORE, "resolve", logical], cwd=ROOT, capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
|
return r.stdout.strip()
|
|
return os.path.join(state_root(), fallback)
|
|
|
|
|
|
def guarded_override(path: str) -> str:
|
|
if path and os.environ.get("CASAN_TENANT_ID"):
|
|
r = subprocess.run(["bash", TENANT_STORE, "guard", path], cwd=ROOT, capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
|
return path
|
|
|
|
|
|
def registry_path() -> str:
|
|
return os.environ.get("CASAN_AGENT_REGISTRY_FILE") or os.path.join(HARNESS_ROOT, "config", "agent-registry.yaml")
|
|
|
|
|
|
def audit_path() -> str:
|
|
return guarded_override(os.environ["CASAN_AGENT_BIND_AUDIT_LOG"]) if os.environ.get("CASAN_AGENT_BIND_AUDIT_LOG") else tenant_path("chat/agent-bindings.jsonl", "logs/chat/agent-bindings.jsonl")
|
|
|
|
|
|
def head_path() -> str:
|
|
return guarded_override(os.environ["CASAN_AGENT_BIND_AUDIT_HEAD"]) if os.environ.get("CASAN_AGENT_BIND_AUDIT_HEAD") else tenant_path("chat/agent-bindings-head.txt", "logs/chat/agent-bindings-head.txt")
|
|
|
|
|
|
def load_registry():
|
|
try:
|
|
with open(registry_path(), encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
agents = data.get("agents")
|
|
if not isinstance(agents, list):
|
|
raise ValueError("agents_not_list")
|
|
return data
|
|
except Exception as exc:
|
|
return {"_error": f"agent_registry_unreadable:{exc}", "agents": []}
|
|
|
|
|
|
def find_agent(registry, agent_id: str):
|
|
return next((a for a in registry.get("agents", []) if a.get("id") == agent_id), None)
|
|
|
|
|
|
def load_head() -> str:
|
|
try:
|
|
return open(head_path(), encoding="utf-8").read().strip() or GENESIS_HASH
|
|
except OSError:
|
|
return GENESIS_HASH
|
|
|
|
|
|
def append_audit(base):
|
|
path = audit_path()
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
seq = 1
|
|
if os.path.isfile(path):
|
|
with open(path, encoding="utf-8") as fh:
|
|
seq = sum(1 for line in fh if line.strip()) + 1
|
|
prev = load_head()
|
|
core = {"seq": seq, **base, "prev_hash": prev}
|
|
record_hash = sha(json.dumps(core, sort_keys=True, ensure_ascii=False))
|
|
rec = {**core, "record_hash": record_hash}
|
|
with open(path, "a", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
os.makedirs(os.path.dirname(head_path()), exist_ok=True)
|
|
with open(head_path(), "w", encoding="utf-8") as fh:
|
|
fh.write(record_hash + "\n")
|
|
return rec
|
|
|
|
|
|
def decision_payload(args, decision: str, reason: str, agent=None, skill="", tools=None):
|
|
tools = tools or []
|
|
success = decision == "BOUND"
|
|
payload = {
|
|
"success": success,
|
|
"decision": decision,
|
|
"reason": reason,
|
|
"agent_selected": (agent or {}).get("id") or args.agent,
|
|
"skill_selected": skill or "",
|
|
"delegation_level": args.delegation_level,
|
|
"model_role": (agent or {}).get("model_role"),
|
|
"mode": (agent or {}).get("mode"),
|
|
"tool_allowlist": (agent or {}).get("tool_allowlist", []),
|
|
"requested_tools": tools,
|
|
"side_effect_allowed": bool((agent or {}).get("tool_allowlist")),
|
|
"requires_approval": bool((agent or {}).get("requires_approval")) or decision == "REQUIRES_APPROVAL",
|
|
}
|
|
rec = append_audit({
|
|
"timestamp": now_iso(),
|
|
"harness": "H5-chat-agent-bind",
|
|
"actor": args.actor,
|
|
"role": args.role,
|
|
"project": args.project,
|
|
"tenant": args.tenant,
|
|
"agent_selected": payload["agent_selected"],
|
|
"skill_selected": payload["skill_selected"],
|
|
"delegation_level": args.delegation_level,
|
|
"decision": decision,
|
|
"reason": reason,
|
|
"tool_allowlist_ref": sha(json.dumps(payload["tool_allowlist"], sort_keys=True)),
|
|
"requested_tools_ref": sha(json.dumps(tools, sort_keys=True)),
|
|
})
|
|
payload["audit"] = {"seq": rec["seq"], "record_hash": rec["record_hash"], "head": rec["record_hash"]}
|
|
return payload
|
|
|
|
|
|
def run_rbac(args):
|
|
r = subprocess.run([
|
|
PYTHON_BIN, RBAC, "check",
|
|
"--role", args.role,
|
|
"--resource", "chat",
|
|
"--action", "select_agent",
|
|
"--role-project", args.project,
|
|
"--target-project", args.target_project or args.project,
|
|
"--role-tenant", args.tenant,
|
|
"--target-tenant", args.target_tenant or args.tenant,
|
|
], cwd=ROOT, capture_output=True, text=True)
|
|
return r.returncode, (r.stdout + r.stderr).strip()
|
|
|
|
|
|
def bind(args) -> int:
|
|
if args.tenant and args.tenant != "default":
|
|
os.environ["CASAN_TENANT_ID"] = args.tenant
|
|
registry = load_registry()
|
|
if registry.get("_error"):
|
|
out = decision_payload(args, "DENIED", registry["_error"])
|
|
print(json.dumps(out, ensure_ascii=False))
|
|
return 2
|
|
|
|
agent = find_agent(registry, args.agent)
|
|
if not agent:
|
|
out = decision_payload(args, "DENIED", "unknown_agent")
|
|
print(json.dumps(out, ensure_ascii=False))
|
|
return 2
|
|
|
|
rc, msg = run_rbac(args)
|
|
if rc != 0:
|
|
out = decision_payload(args, "DENIED", f"rbac_denied:{msg}", agent)
|
|
print(json.dumps(out, ensure_ascii=False))
|
|
return 2
|
|
|
|
if args.role not in agent.get("roles_allowed", []):
|
|
out = decision_payload(args, "DENIED", "role_not_allowed_for_agent", agent)
|
|
print(json.dumps(out, ensure_ascii=False))
|
|
return 2
|
|
|
|
skill = args.skill or (agent.get("skills_allowed", [""])[0] if agent.get("skills_allowed") else "")
|
|
if skill and skill not in agent.get("skills_allowed", []):
|
|
out = decision_payload(args, "DENIED", "skill_not_allowed_for_agent", agent, skill)
|
|
print(json.dumps(out, ensure_ascii=False))
|
|
return 2
|
|
|
|
max_level = int(agent.get("max_delegation_level", 0))
|
|
if args.delegation_level > max_level:
|
|
out = decision_payload(args, "REQUIRES_APPROVAL", "delegation_exceeds_agent_max", agent, skill)
|
|
print(json.dumps(out, ensure_ascii=False))
|
|
return 3
|
|
|
|
requested_tools = list(args.tool or [])
|
|
allow = set(agent.get("tool_allowlist", []))
|
|
outside = [t for t in requested_tools if t not in allow]
|
|
if outside:
|
|
out = decision_payload(args, "DENIED", "tool_not_allowlisted", agent, skill, requested_tools)
|
|
out["action_gate"] = {"outcome": "BLOCK", "reason": f"tool_not_allowlisted:{','.join(outside)}"}
|
|
print(json.dumps(out, ensure_ascii=False))
|
|
return 2
|
|
|
|
out = decision_payload(args, "BOUND", "agent_bound", agent, skill, requested_tools)
|
|
print(json.dumps(out, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
def list_agents(args) -> int:
|
|
registry = load_registry()
|
|
if registry.get("_error"):
|
|
print(json.dumps({"success": False, "error": registry["_error"], "agents": []}, ensure_ascii=False))
|
|
return 2
|
|
agents = []
|
|
for agent in registry.get("agents", []):
|
|
item = {k: agent.get(k) for k in (
|
|
"id", "label", "mode", "model_role", "roles_allowed", "skills_allowed",
|
|
"tool_allowlist", "max_delegation_level", "requires_approval",
|
|
)}
|
|
item["allowed_for_role"] = args.role in agent.get("roles_allowed", []) if args.role else None
|
|
agents.append(item)
|
|
print(json.dumps({"success": True, "version": registry.get("version"), "agents": agents}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
def verify_audit() -> int:
|
|
prev = GENESIS_HASH
|
|
count = 0
|
|
try:
|
|
fh = open(audit_path(), encoding="utf-8")
|
|
except OSError:
|
|
print("CHAT_AGENT_AUDIT ok=true records=0 head=" + prev)
|
|
return 0
|
|
with fh:
|
|
for line in fh:
|
|
if not line.strip():
|
|
continue
|
|
count += 1
|
|
rec = json.loads(line)
|
|
got = rec.get("record_hash")
|
|
rest = {k: v for k, v in rec.items() if k != "record_hash"}
|
|
if rest.get("prev_hash") != prev or sha(json.dumps(rest, sort_keys=True, ensure_ascii=False)) != got:
|
|
print(f"CHAT_AGENT_AUDIT ok=false brokenAt={count}")
|
|
return 1
|
|
prev = got
|
|
print(f"CHAT_AGENT_AUDIT ok=true records={count} head={prev}")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
b = sub.add_parser("bind")
|
|
b.add_argument("--agent", required=True)
|
|
b.add_argument("--skill", default="")
|
|
b.add_argument("--actor", default="anonymous")
|
|
b.add_argument("--role", default="viewer")
|
|
b.add_argument("--project", default="default")
|
|
b.add_argument("--tenant", default="default")
|
|
b.add_argument("--target-project", default="")
|
|
b.add_argument("--target-tenant", default="")
|
|
b.add_argument("--delegation-level", type=int, default=0)
|
|
b.add_argument("--tool", action="append", default=[])
|
|
b.set_defaults(func=bind)
|
|
|
|
l = sub.add_parser("list-agents")
|
|
l.add_argument("--role", default="")
|
|
l.set_defaults(func=list_agents)
|
|
|
|
sub.add_parser("verify-audit").set_defaults(func=lambda _args: verify_audit())
|
|
args = ap.parse_args()
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|