feat: add governed chat console
This commit is contained in:
+173
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan-18 deterministic prompt mode router.
|
||||
|
||||
MVP-0 emits READ_ONLY/BLOCK/NOT_SUPPORTED. MVP-1 adds OPERATOR only for
|
||||
registered action phrases; free commands remain NOT_SUPPORTED/BLOCK.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
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 policy_path() -> str:
|
||||
return os.environ.get("CASAN_PROMPT_MODES_FILE") or os.path.join(HARNESS_ROOT, "config", "prompt-modes.yaml")
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def load_policy():
|
||||
try:
|
||||
with open(policy_path(), encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except Exception as exc:
|
||||
return {"_error": f"policy_unreadable:{exc}"}
|
||||
|
||||
|
||||
def lower(s: str) -> str:
|
||||
return re.sub(r"\s+", " ", s.lower()).strip()
|
||||
|
||||
|
||||
def contains_any(text: str, patterns):
|
||||
hits = []
|
||||
for pattern in patterns:
|
||||
if pattern and re.search(re.escape(pattern.lower()), text):
|
||||
hits.append(pattern)
|
||||
return hits
|
||||
|
||||
|
||||
def classify(message: str, model_verdict: str = ""):
|
||||
policy = load_policy()
|
||||
if policy.get("_error"):
|
||||
return {
|
||||
"mode": "BLOCK",
|
||||
"risk": "high",
|
||||
"gates": ["H5_CHAT_AUDIT"],
|
||||
"needs_approval": False,
|
||||
"reason": policy["_error"],
|
||||
"matched_rules": ["policy_fail_closed"],
|
||||
"side_effect_allowed": False,
|
||||
"classified_at": now_iso(),
|
||||
}
|
||||
|
||||
text = lower(message)
|
||||
if not text:
|
||||
return {
|
||||
"mode": "BLOCK",
|
||||
"risk": "high",
|
||||
"gates": policy["block"]["gates"],
|
||||
"needs_approval": False,
|
||||
"reason": "empty_message",
|
||||
"matched_rules": ["empty_message"],
|
||||
"side_effect_allowed": False,
|
||||
"classified_at": now_iso(),
|
||||
}
|
||||
|
||||
block_hits = contains_any(text, policy.get("block_patterns", []))
|
||||
if block_hits:
|
||||
return {
|
||||
"mode": "BLOCK",
|
||||
"risk": "high",
|
||||
"gates": policy["block"]["gates"],
|
||||
"needs_approval": False,
|
||||
"reason": "blocked_by_rule",
|
||||
"matched_rules": block_hits,
|
||||
"side_effect_allowed": False,
|
||||
"classified_at": now_iso(),
|
||||
}
|
||||
|
||||
unsupported_hits = contains_any(text, policy.get("not_supported_patterns", []))
|
||||
if unsupported_hits:
|
||||
return {
|
||||
"mode": "NOT_SUPPORTED",
|
||||
"risk": "medium",
|
||||
"gates": policy["not_supported"]["gates"],
|
||||
"needs_approval": True,
|
||||
"reason": "side_effect_not_supported_in_mvp0",
|
||||
"matched_rules": unsupported_hits,
|
||||
"side_effect_allowed": False,
|
||||
"classified_at": now_iso(),
|
||||
}
|
||||
|
||||
operator_hits = contains_any(text, policy.get("operator_terms", []))
|
||||
if operator_hits:
|
||||
return {
|
||||
"mode": "OPERATOR",
|
||||
"risk": policy["operator"]["risk"],
|
||||
"gates": policy["operator"]["gates"],
|
||||
"needs_approval": bool(policy["operator"]["needs_approval"]),
|
||||
"reason": "registered_operator_action",
|
||||
"matched_rules": operator_hits,
|
||||
"side_effect_allowed": True,
|
||||
"classified_at": now_iso(),
|
||||
}
|
||||
|
||||
read_terms = policy.get("read_only_terms", [])
|
||||
read_hits = [t for t in read_terms if re.search(rf"\b{re.escape(t.lower())}\b", text)]
|
||||
# Model-assisted verdict can only increase caution. In MVP-0 an unsafe model
|
||||
# verdict is refused, while READ_ONLY from the model cannot override rules.
|
||||
mv = (model_verdict or "").strip().upper()
|
||||
if mv in {"BLOCK", "NOT_SUPPORTED", "OPERATOR"}:
|
||||
mode = mv
|
||||
cfg = policy["block" if mode == "BLOCK" else ("operator" if mode == "OPERATOR" else "not_supported")]
|
||||
if mode == "OPERATOR" and not operator_hits:
|
||||
mode = "NOT_SUPPORTED"
|
||||
cfg = policy["not_supported"]
|
||||
return {
|
||||
"mode": mode,
|
||||
"risk": cfg["risk"],
|
||||
"gates": cfg["gates"],
|
||||
"needs_approval": bool(cfg["needs_approval"]),
|
||||
"reason": "model_escalated" if mode != "NOT_SUPPORTED" else "model_operator_without_registered_action",
|
||||
"matched_rules": [f"model:{mode}"],
|
||||
"side_effect_allowed": mode == "OPERATOR",
|
||||
"classified_at": now_iso(),
|
||||
}
|
||||
|
||||
return {
|
||||
"mode": "READ_ONLY",
|
||||
"risk": "low",
|
||||
"gates": policy["read_only"]["gates"],
|
||||
"needs_approval": False,
|
||||
"reason": "read_only_terms" if read_hits else "default_read_only_no_side_effect",
|
||||
"matched_rules": read_hits,
|
||||
"side_effect_allowed": False,
|
||||
"classified_at": now_iso(),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("classify", nargs="?")
|
||||
ap.add_argument("--message", default="")
|
||||
ap.add_argument("--input", default="")
|
||||
ap.add_argument("--model-verdict", default="")
|
||||
args = ap.parse_args()
|
||||
message = args.message
|
||||
if args.input:
|
||||
with open(args.input, encoding="utf-8") as fh:
|
||||
message = fh.read()
|
||||
print(json.dumps(classify(message, args.model_verdict), ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user