feat: govern chat model selection by policy

This commit is contained in:
thanhnv
2026-07-10 17:09:28 +09:00
parent f8215cd2eb
commit b0ced79af5
9 changed files with 186 additions and 12 deletions
@@ -6,26 +6,34 @@
"local": {
"model": "ollama:ornith:9b",
"class": "local",
"requires_key": false
"requires_key": false,
"roles_allowed": ["viewer", "auditor", "operator", "project-admin", "org-admin"],
"model_roles_allowed": ["read_only", "analysis", "operator", "codegen"]
},
"cloud-anthropic": {
"model": "anthropic:claude-3-5-sonnet-latest",
"class": "cloud",
"requires_key": true,
"key_env": "ANTHROPIC_API_KEY"
"key_env": "ANTHROPIC_API_KEY",
"roles_allowed": ["project-admin", "org-admin"],
"model_roles_allowed": ["analysis", "codegen"]
},
"cloud-openai": {
"model": "openai:gpt-4o-mini",
"class": "cloud",
"requires_key": true,
"key_env": "OPENAI_API_KEY"
"key_env": "OPENAI_API_KEY",
"roles_allowed": ["project-admin", "org-admin"],
"model_roles_allowed": ["analysis", "codegen"]
},
"omniroute": {
"model": "openai-compatible:auto/best-coding",
"class": "gateway",
"requires_key": true,
"key_env": "CASAN_OPENAI_COMPATIBLE_API_KEY",
"requires_preflight": true
"requires_preflight": true,
"roles_allowed": ["operator", "project-admin", "org-admin"],
"model_roles_allowed": ["read_only", "analysis", "operator", "codegen"]
}
},
"role_bindings": {
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Governed model-provider selector for Chat.
The browser sends only a provider ID. This resolver reads the managed provider
policy, checks actor role, agent model role and credential availability, writes
an append-only selection audit, and returns no secret material.
"""
import argparse, hashlib, json, os, sys
from datetime import datetime, timezone
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
CONFIG = os.path.join(ROOT, "packages", "casan-harness", "config", "model-providers.yaml")
def now(): return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def sha(v): return hashlib.sha256(v.encode()).hexdigest()
def state_root(): return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
def audit_path(): return os.environ.get("CASAN_CHAT_MODEL_AUDIT_LOG") or os.path.join(state_root(), "logs", "chat", "model-selection.jsonl")
def load():
try:
return json.load(open(os.environ.get("CASAN_MODEL_PROVIDERS_FILE") or CONFIG, encoding="utf-8"))
except Exception as exc:
return {"_error": f"providers_unreadable:{exc}"}
def visible(pid, p, role, model_role):
allowed_roles = p.get("roles_allowed", [])
allowed_models = p.get("model_roles_allowed", [])
enabled = not p.get("requires_key") or bool(os.environ.get(p.get("key_env", "")))
return {"id": pid, "model": p.get("model", ""), "class": p.get("class", "local"),
"requires_preflight": bool(p.get("requires_preflight")), "allowed": role in allowed_roles and model_role in allowed_models,
"configured": enabled, "reason": "" if enabled else "credential_not_configured"}
def append(rec):
path=audit_path(); os.makedirs(os.path.dirname(path), exist_ok=True)
prev=""
if os.path.isfile(path):
for line in open(path, encoding="utf-8"):
if line.strip(): prev=json.loads(line).get("record_hash", prev)
core={**rec,"prev_hash":prev}; core["record_hash"]=sha(json.dumps(core,sort_keys=True,ensure_ascii=False))
with open(path,"a",encoding="utf-8") as f: f.write(json.dumps(core,ensure_ascii=False)+"\n")
return core
def list_models(args):
cfg=load()
if cfg.get("_error"): print(json.dumps({"success":False,"providers":[],"reason":cfg["_error"]})); return 2
rows=[visible(pid,p,args.role,args.model_role) for pid,p in cfg.get("providers",{}).items()]
print(json.dumps({"success":True,"model_role":args.model_role,"default":cfg.get("role_bindings",{}).get(args.model_role,""),"providers":rows}))
return 0
def bind(args):
cfg=load()
if cfg.get("_error"): print(json.dumps({"success":False,"decision":"DENIED","reason":cfg["_error"]})); return 2
pid=args.provider or cfg.get("role_bindings",{}).get(args.model_role,"")
p=cfg.get("providers",{}).get(pid)
if not p:
decision,reason="DENIED","unknown_provider"
else:
row=visible(pid,p,args.role,args.model_role)
decision,reason=("BOUND", "provider_bound") if row["allowed"] and row["configured"] else ("DENIED", row["reason"] or "provider_not_allowed")
rec=append({"timestamp":now(),"harness":"H5-chat-model-bind","actor":args.actor,"role":args.role,"model_role":args.model_role,"provider":pid,"decision":decision,"reason":reason})
out={"success":decision=="BOUND","decision":decision,"reason":reason,"provider":pid,"model":(p or {}).get("model"),"class":(p or {}).get("class"),"audit":{"seq":rec.get("record_hash","")[:16],"record_hash":rec["record_hash"]}}
print(json.dumps(out,ensure_ascii=False)); return 0 if decision=="BOUND" else 2
def main():
ap=argparse.ArgumentParser(); subs=ap.add_subparsers(dest="cmd",required=True)
for name in ("list","bind"):
p=subs.add_parser(name); p.add_argument("--role",required=True); p.add_argument("--model-role",required=True); p.add_argument("--actor",default="anonymous")
if name=="bind": p.add_argument("--provider",default="")
args=ap.parse_args(); return list_models(args) if args.cmd=="list" else bind(args)
if __name__=="__main__": raise SystemExit(main())
@@ -29,6 +29,7 @@ ROOT = project_root()
BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
ROUTER = os.path.join(BIN, "prompt-mode-router.py")
AGENT_RESOLVER = os.path.join(BIN, "chat-agent-resolver.py")
MODEL_RESOLVER = os.path.join(BIN, "chat-model-resolver.py")
READONLY = os.path.join(BIN, "chat-readonly.py")
OPERATOR = os.path.join(BIN, "chat-operator.py")
APPROVAL_INBOX = os.path.join(BIN, "approval-inbox.py")
@@ -112,6 +113,8 @@ def run_mode(args, binding, loop_run=None, scan_output_label=""):
env = os.environ.copy()
if loop_run is not None:
env["CASAN_CHAT_LOOP_RUN_JSON"] = json.dumps(loop_run, ensure_ascii=False, sort_keys=True)
if binding and binding.get("model_binding", {}).get("decision") == "BOUND":
env["CASAN_CHAT_MODEL_PROVIDER"] = binding["model_binding"].get("provider", "")
r = subprocess.run(args, cwd=ROOT, capture_output=True, text=True, env=env)
if scan_output_label:
scan_rc, scan_msg = scan_tool_output(r.stdout + "\n" + r.stderr, scan_output_label)
@@ -144,8 +147,8 @@ def run_mode(args, binding, loop_run=None, scan_output_label=""):
return r.returncode
def run_and_passthrough(args):
r = subprocess.run(args, cwd=ROOT, text=True)
def run_and_passthrough(args, env=None):
r = subprocess.run(args, cwd=ROOT, text=True, env=env)
return r.returncode
@@ -622,6 +625,20 @@ def bind_agent(args, router):
return 2, None
def bind_model(args, binding):
r = subprocess.run([
"python3", MODEL_RESOLVER, "bind",
"--provider", args.model_provider,
"--actor", args.actor,
"--role", args.role,
"--model-role", binding.get("model_role") or "read_only",
], cwd=ROOT, capture_output=True, text=True)
try:
return r.returncode, json.loads(r.stdout)
except Exception:
return 2, {"success": False, "decision": "DENIED", "reason": "model_resolver_invalid_json"}
def submit_escalation(args, router, binding):
turn_id = args.turn_id or str(uuid.uuid4())
payload = {
@@ -695,6 +712,12 @@ def ask(args) -> int:
if binding:
print(json.dumps(binding, ensure_ascii=False))
return bind_rc
model_rc, model_binding = bind_model(args, binding)
if model_rc != 0:
print(json.dumps({"success": False, "mode": router.get("mode"), "decision": "DENIED", "reason": model_binding.get("reason"), "agent_binding": binding, "model_binding": model_binding}, ensure_ascii=False))
return model_rc
binding["model_binding"] = model_binding
os.environ["CASAN_CHAT_MODEL_PROVIDER"] = model_binding.get("provider", "")
common = [
"--message", args.message,
"--actor", args.actor,
@@ -712,7 +735,9 @@ def ask(args) -> int:
loop_run, loop_rc = certify_codegen_draft(args, router, binding)
return finish_codegen(args, router, binding, loop_run, loop_rc)
if getattr(args, "stream", False) and router.get("mode") in ("READ_ONLY", "ANALYSIS"):
return run_and_passthrough(["python3", READONLY, "ask", "--stream", *common])
env = os.environ.copy()
env["CASAN_CHAT_MODEL_PROVIDER"] = model_binding.get("provider", "")
return run_and_passthrough(["python3", READONLY, "ask", "--stream", *common], env)
return run_mode(["python3", READONLY, "ask", *common], binding)
@@ -740,6 +765,7 @@ def main() -> int:
askp.add_argument("--tenant", default="default")
askp.add_argument("--agent", default="")
askp.add_argument("--skill", default="")
askp.add_argument("--model-provider", default="")
askp.add_argument("--delegation-level", type=int, default=0)
askp.add_argument("--stream", action="store_true")
askp.set_defaults(func=ask)
@@ -151,6 +151,7 @@ run "phase-loop-run" bash "$TESTS/phase-loop-run-tests.sh"
run "phase-chat-prompt-router" bash "$TESTS/phase-chat-prompt-router-tests.sh"
run "phase-chat-readonly" bash "$TESTS/phase-chat-readonly-tests.sh"
run "phase-chat-model-synthesis" bash "$TESTS/phase-chat-model-synthesis-tests.sh"
run "phase-chat-model-selection" bash "$TESTS/phase-chat-model-selection-tests.sh"
run "phase-chat-advanced" bash "$TESTS/phase-chat-advanced-tests.sh"
run "phase-chat-session-audit" bash "$TESTS/phase-chat-session-audit-tests.sh"
run "phase-chat-operator" bash "$TESTS/phase-chat-operator-tests.sh"
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
RESOLVER="$CASAN_HARNESS_ROOT/scripts/bash/chat-model-resolver.py"
TURN="$CASAN_HARNESS_ROOT/scripts/bash/chat-turn.py"
W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT
export CASAN_STATE_ROOT="$W/state"
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS+1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL+1)); }
python3 "$RESOLVER" list --actor viewer-a --role viewer --model-role read_only > "$W/list.json"
python3 - "$W/list.json" <<'PY' && pass "viewer can see only policy-allowed local read model" || fail "model list policy incorrect"
import json, sys
d=json.load(open(sys.argv[1])); local=next(x for x in d['providers'] if x['id']=='local')
cloud=next(x for x in d['providers'] if x['id']=='cloud-openai')
assert local['allowed'] and local['configured']; assert not cloud['allowed']
PY
python3 "$RESOLVER" bind --actor viewer-a --role viewer --model-role read_only --provider local > "$W/local.json" \
&& grep -q '"decision": "BOUND"' "$W/local.json" && pass "local provider selection is audited and bound" || fail "local provider binding failed"
set +e
python3 "$RESOLVER" bind --actor viewer-a --role viewer --model-role read_only --provider cloud-openai > "$W/cloud.json"
RC=$?
set -e
[[ "$RC" -eq 2 ]] && grep -q '"decision": "DENIED"' "$W/cloud.json" && pass "unauthorized or unconfigured cloud provider denied" || fail "cloud provider was not denied"
set +e
python3 "$TURN" ask --message 'Summarize Plan 18 status' --actor viewer-a --role viewer --project default --chat-id model-select --model-provider cloud-openai > "$W/turn.json"
RC=$?
set -e
[[ "$RC" -eq 2 ]] && grep -q 'credential_not_configured\|provider_not_allowed' "$W/turn.json" && pass "chat turn cannot bypass model provider policy" || fail "chat bypassed model policy"
echo "===== CHAT MODEL SELECTION SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]]