205 lines
6.8 KiB
Python
Executable File
205 lines
6.8 KiB
Python
Executable File
#!/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; MVP-2 adds CODEGEN draft mode. 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(),
|
|
}
|
|
|
|
codegen_hits = contains_any(text, policy.get("codegen_terms", []))
|
|
if codegen_hits:
|
|
return {
|
|
"mode": "CODEGEN",
|
|
"risk": policy["codegen"]["risk"],
|
|
"gates": policy["codegen"]["gates"],
|
|
"needs_approval": bool(policy["codegen"]["needs_approval"]),
|
|
"reason": "codegen_draft_requested",
|
|
"matched_rules": codegen_hits,
|
|
"side_effect_allowed": False,
|
|
"classified_at": now_iso(),
|
|
}
|
|
|
|
analysis_hits = contains_any(text, policy.get("analysis_terms", []))
|
|
if analysis_hits:
|
|
acfg = policy.get("analysis", policy["read_only"])
|
|
return {
|
|
"mode": "ANALYSIS",
|
|
"risk": acfg["risk"],
|
|
"gates": acfg["gates"],
|
|
"needs_approval": bool(acfg.get("needs_approval", False)),
|
|
"reason": "analysis_reasoning_requested",
|
|
"matched_rules": analysis_hits,
|
|
"side_effect_allowed": False,
|
|
"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", "CODEGEN"}:
|
|
mode = mv
|
|
cfg = policy["block" if mode == "BLOCK" else ("operator" if mode == "OPERATOR" else ("codegen" if mode == "CODEGEN" else "not_supported"))]
|
|
if mode == "OPERATOR" and not operator_hits:
|
|
mode = "NOT_SUPPORTED"
|
|
cfg = policy["not_supported"]
|
|
if mode == "CODEGEN" and not codegen_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_requested_unsupported_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())
|