feat: add governed chat console

This commit is contained in:
thanhnv
2026-07-08 21:14:40 +09:00
parent 3be9970c15
commit 5561bcf864
28 changed files with 2023 additions and 43 deletions
+392
View File
@@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""Plan-18 MVP-1 Operator mode for registered actions only.
This script never executes user-provided commands. It resolves a user message or
explicit action id to an action in operator-actions.yaml, asks action-gate.sh to
authorize that registered command, then runs only the registered command.
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
import uuid
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")
HARNESS_BIN = os.path.join(HARNESS_ROOT, "scripts", "bash")
ROUTER = os.path.join(HARNESS_BIN, "prompt-mode-router.py")
SECURITY = os.path.join(HARNESS_BIN, "security-check.sh")
ACTION_GATE = os.path.join(HARNESS_BIN, "action-gate.sh")
def state_root() -> str:
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
def config_path() -> str:
return os.environ.get("CASAN_OPERATOR_ACTIONS_FILE") or os.path.join(HARNESS_ROOT, "config", "operator-actions.yaml")
def audit_path() -> str:
return os.environ.get("CASAN_CHAT_AUDIT_LOG") or os.path.join(state_root(), "logs", "chat", "chat-turns.jsonl")
def head_path() -> str:
return os.environ.get("CASAN_CHAT_AUDIT_HEAD") or os.path.join(state_root(), "logs", "chat", "chat-head.txt")
def metrics_path() -> str:
return os.environ.get("CASAN_CHAT_METRICS_LOG") or os.path.join(state_root(), "logs", "cost", "metrics.jsonl")
def artifact_dir() -> str:
return os.path.join(state_root(), "logs", "chat", "operator-artifacts")
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def sha(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def git_commit() -> str:
try:
r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, capture_output=True, text=True, timeout=5)
if r.returncode == 0:
return r.stdout.strip()
except Exception:
pass
return "unknown"
def provenance(source: str, path: str, verified=True):
return {
"source": source,
"artifact_path": os.path.relpath(path, ROOT) if os.path.isabs(path) and path.startswith(ROOT) else path,
"commit": git_commit(),
"run_at": now_iso(),
"verified": bool(verified),
}
def expand_token(value: str) -> str:
return value.replace("${CASAN_STATE_ROOT}", state_root()).replace("${CASAN_APP_ROOT}", ROOT)
def load_config():
try:
with open(config_path(), encoding="utf-8") as fh:
data = json.load(fh)
actions = data.get("actions", [])
if not isinstance(actions, list):
raise ValueError("actions_not_list")
return data
except Exception as exc:
return {"_error": f"operator_policy_unreadable:{exc}", "actions": []}
def lower(s: str) -> str:
return re.sub(r"\s+", " ", s.lower()).strip()
def classify(message: str):
r = subprocess.run(["python3", ROUTER, "classify", "--message", message], cwd=ROOT, capture_output=True, text=True)
if r.returncode != 0:
return {"mode": "BLOCK", "risk": "high", "reason": "router_failed", "matched_rules": [r.stderr.strip()]}
try:
return json.loads(r.stdout)
except ValueError:
return {"mode": "BLOCK", "risk": "high", "reason": "router_invalid_json", "matched_rules": []}
def run_security(text: str, mode: str):
with tempfile.TemporaryDirectory() as td:
inp = os.path.join(td, "in.txt")
out = os.path.join(td, "out.txt")
with open(inp, "w", encoding="utf-8") as fh:
fh.write(text)
r = subprocess.run(["bash", SECURITY, inp, out, mode], cwd=ROOT, capture_output=True, text=True)
safe = open(out, encoding="utf-8").read() if os.path.exists(out) else ""
return r.returncode, safe.strip(), (r.stdout + r.stderr).strip()
def select_action(actions, action_id: str, message: str):
if action_id:
return next((a for a in actions if a.get("id") == action_id), None)
text = lower(message)
matches = []
for action in actions:
for trigger in action.get("triggers", []):
if trigger.lower() in text:
matches.append((len(trigger), action))
if not matches:
return None
matches.sort(key=lambda x: -x[0])
return matches[0][1]
def append_jsonl(path: str, rec):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
def load_head() -> str:
try:
return open(head_path(), encoding="utf-8").read().strip() or GENESIS_HASH
except OSError:
return GENESIS_HASH
def record_turn(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}
append_jsonl(path, rec)
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 record_metrics(trace_id: str, message: str, answer: str, status: str, latency_ms: int, action_id: str):
input_tokens = len(message.split())
output_tokens = len(answer.split())
append_jsonl(metrics_path(), {
"timestamp": now_iso(),
"trace_id": trace_id,
"harness": "H6-agentops",
"agent": "chat.operator",
"step": f"operator:{action_id or 'none'}",
"status": status,
"exit_code": 0 if status == "success" else 2,
"latency_ms": latency_ms,
"retry_count": 0,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"cost_estimate": 0.0,
"cost_source": "operator_word_count",
"hallucination_signals": 0,
"alerts": [],
"input_hash": sha(message),
"output_hash": sha(answer),
})
def finish(started, trace_id, chat_id, turn_id, tenant_id, actor, message, router, decision, answer,
action=None, action_gate=None, artifact_path="", safe_message=""):
elapsed = int((datetime.now(timezone.utc) - started).total_seconds() * 1000)
source = []
if artifact_path:
source.append({
"path": os.path.relpath(artifact_path, ROOT) if artifact_path.startswith(ROOT) else artifact_path,
"line": 1,
"excerpt": answer[:260],
"score": 10 if decision == "ACTION_COMPLETED" else 1,
"envelope": provenance("chat-operator-artifact", artifact_path, decision == "ACTION_COMPLETED"),
})
rec = record_turn({
"timestamp": now_iso(),
"trace_id": trace_id,
"chat_id": chat_id,
"turn_id": turn_id,
"tenant_id": tenant_id,
"actor": actor,
"mode": router.get("mode", "OPERATOR"),
"risk": router.get("risk", "medium"),
"decision": decision,
"router": router,
"action_id": (action or {}).get("id"),
"action_gate": action_gate or {},
"user_msg_ref": sha(message),
"safe_preview": (safe_message or "")[:180],
"answer_ref": sha(answer),
"sources": [{"path": s.get("path"), "line": s.get("line")} for s in source],
})
record_metrics(trace_id, safe_message or message, answer, "success" if decision == "ACTION_COMPLETED" else "failed", elapsed, (action or {}).get("id", "none"))
return {
"success": decision == "ACTION_COMPLETED",
"chat_id": chat_id,
"turn_id": turn_id,
"trace_id": trace_id,
"mode": router.get("mode", "OPERATOR"),
"risk": router.get("risk", "medium"),
"decision": decision,
"answer": answer,
"sources": source,
"certified": decision == "ACTION_COMPLETED",
"audit": {"seq": rec["seq"], "record_hash": rec["record_hash"], "head": rec["record_hash"]},
"router": router,
"action": {k: action.get(k) for k in ("id", "label", "description")} if action else None,
"action_gate": action_gate or {},
}
def parse_gate_output(text: str, rc: int):
m = re.search(r"ACTION_GATE outcome=([A-Z_]+) reason=([^\n]+)", text)
return {
"exit_code": rc,
"outcome": m.group(1) if m else ("ALLOW" if rc == 0 else "BLOCK"),
"reason": m.group(2).strip() if m else text.strip()[:200],
}
def list_actions(args) -> int:
cfg = load_config()
if cfg.get("_error"):
print(json.dumps({"success": False, "error": cfg["_error"], "actions": []}, ensure_ascii=False))
return 2
out = [{k: a.get(k) for k in ("id", "label", "description", "triggers")} for a in cfg.get("actions", [])]
print(json.dumps({"success": True, "actions": out}, ensure_ascii=False))
return 0
def run(args) -> int:
started = datetime.now(timezone.utc)
trace_id = str(uuid.uuid4())
chat_id = args.chat_id or "chat-default"
turn_id = args.turn_id or f"turn-{trace_id[:12]}"
actor = args.actor or "anonymous"
tenant_id = args.tenant or "default"
message = args.message or args.action or ""
router = classify(message)
cfg = load_config()
if cfg.get("_error"):
router.update({"mode": "BLOCK", "risk": "high", "reason": cfg["_error"]})
res = finish(started, trace_id, chat_id, turn_id, tenant_id, actor, message, router, "DENIED", "Denied: operator policy unreadable.")
print(json.dumps(res, ensure_ascii=False))
return 2
rc, safe_input, scan_msg = run_security(message, "input")
if rc != 0:
router.update({"mode": "BLOCK", "risk": "high", "reason": "h4_input_denied", "matched_rules": router.get("matched_rules", []) + [scan_msg]})
res = finish(started, trace_id, chat_id, turn_id, tenant_id, actor, message, router, "DENIED", "Denied by H4 input scan.")
print(json.dumps(res, ensure_ascii=False))
return 2
action = select_action(cfg.get("actions", []), args.action, safe_input)
if not action:
router.update({"mode": "NOT_SUPPORTED", "reason": "operator_action_not_registered", "side_effect_allowed": False})
res = finish(started, trace_id, chat_id, turn_id, tenant_id, actor, message, router, "NOT_SUPPORTED", "No registered operator action matched this request.")
print(json.dumps(res, ensure_ascii=False))
return 2
if router.get("mode") != "OPERATOR" and not args.action:
answer = f"Denied by Prompt Router: mode={router.get('mode')} reason={router.get('reason')}"
res = finish(started, trace_id, chat_id, turn_id, tenant_id, actor, message, router, "DENIED", answer, action=action, safe_message=safe_input)
print(json.dumps(res, ensure_ascii=False))
return 2
cmd = [expand_token(str(x)) for x in action.get("command", [])]
writes = [expand_token(str(x)) for x in action.get("writes", [])]
if not cmd:
router.update({"mode": "BLOCK", "risk": "high", "reason": "registered_action_missing_command"})
res = finish(started, trace_id, chat_id, turn_id, tenant_id, actor, message, router, "DENIED", "Denied: registered action has no command.", action=action, safe_message=safe_input)
print(json.dumps(res, ensure_ascii=False))
return 2
gate_args = ["bash", ACTION_GATE, "--command", " ".join(cmd)]
for w in writes:
gate_args += ["--write", w]
gate = subprocess.run(gate_args, cwd=ROOT, capture_output=True, text=True)
gate_info = parse_gate_output((gate.stdout + gate.stderr).strip(), gate.returncode)
if gate.returncode == 3:
res = finish(started, trace_id, chat_id, turn_id, tenant_id, actor, message, router, "REQUIRES_APPROVAL", "Action requires approval before execution.", action=action, action_gate=gate_info, safe_message=safe_input)
print(json.dumps(res, ensure_ascii=False))
return 3
if gate.returncode != 0:
res = finish(started, trace_id, chat_id, turn_id, tenant_id, actor, message, router, "DENIED", "Action blocked by action-gate.", action=action, action_gate=gate_info, safe_message=safe_input)
print(json.dumps(res, ensure_ascii=False))
return 2
env = {**os.environ, "CASAN_STATE_ROOT": state_root()}
try:
run_res = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=int(action.get("timeout_s", 30)), env=env)
except subprocess.TimeoutExpired as exc:
output = ((exc.stdout or "") + "\n" + (exc.stderr or "")).strip()
run_res = subprocess.CompletedProcess(cmd, 124, output, "timeout")
raw_output = ((run_res.stdout or "") + "\n" + (run_res.stderr or "")).strip()
rc, safe_output, scan_msg = run_security(raw_output[:6000], "output")
decision = "ACTION_COMPLETED" if run_res.returncode == 0 and rc == 0 else "ACTION_FAILED"
if rc != 0:
decision = "DENIED"
safe_output = "Denied by H4 output scan."
router["reason"] = "h4_output_denied"
router["matched_rules"] = router.get("matched_rules", []) + [scan_msg]
os.makedirs(artifact_dir(), exist_ok=True)
artifact = os.path.join(artifact_dir(), f"{trace_id}.json")
artifact_rec = {
"timestamp": now_iso(),
"trace_id": trace_id,
"action_id": action.get("id"),
"label": action.get("label"),
"command_ref": sha(" ".join(cmd)),
"exit_code": run_res.returncode,
"action_gate": gate_info,
"output_hash": sha(raw_output),
"output_preview": safe_output[:1200],
"provenance": provenance("chat-operator", artifact, decision == "ACTION_COMPLETED"),
}
with open(artifact, "w", encoding="utf-8") as fh:
json.dump(artifact_rec, fh, indent=2, ensure_ascii=False)
answer = (
f"Operator action {action.get('id')} finished with exit_code={run_res.returncode}; "
f"gate={gate_info.get('outcome')}. Artifact: {os.path.relpath(artifact, ROOT)}\n"
f"{safe_output[:1200]}"
)
res = finish(started, trace_id, chat_id, turn_id, tenant_id, actor, message, router, decision, answer, action=action, action_gate=gate_info, artifact_path=artifact, safe_message=safe_input)
print(json.dumps(res, ensure_ascii=False))
return 0 if decision == "ACTION_COMPLETED" else 2
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
l = sub.add_parser("list-actions")
l.set_defaults(func=list_actions)
r = sub.add_parser("run")
r.add_argument("--message", default="")
r.add_argument("--action", default="")
r.add_argument("--actor", default="anonymous")
r.add_argument("--chat-id", default="")
r.add_argument("--turn-id", default="")
r.add_argument("--tenant", default="default")
r.set_defaults(func=run)
args = ap.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""Plan-18 MVP-0 Ask CASAN read-only evidence assistant.
The primitive is deliberately deterministic: no free command execution, no writes
outside chat audit/H6 telemetry, no model dependency. It reads only whitelisted
CASAN documentation/evidence artifacts and returns answer + sources.
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
import uuid
from datetime import datetime, timezone
GENESIS_HASH = "0" * 64
STOPWORDS = {
"a", "an", "and", "are", "as", "ask", "cua", "cho", "co", "con", "còn",
"do", "for", "gi", "gì", "hay", "how", "is", "ke", "kế", "la", "là",
"of", "on", "the", "to", "trong", "ve", "về", "what", "with",
}
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_BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
ROUTER = os.path.join(HARNESS_BIN, "prompt-mode-router.py")
SECURITY = os.path.join(HARNESS_BIN, "security-check.sh")
def state_root() -> str:
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
def audit_path() -> str:
return os.environ.get("CASAN_CHAT_AUDIT_LOG") or os.path.join(state_root(), "logs", "chat", "chat-turns.jsonl")
def head_path() -> str:
return os.environ.get("CASAN_CHAT_AUDIT_HEAD") or os.path.join(state_root(), "logs", "chat", "chat-head.txt")
def metrics_path() -> str:
return os.environ.get("CASAN_CHAT_METRICS_LOG") or os.path.join(state_root(), "logs", "cost", "metrics.jsonl")
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def sha(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def git_commit() -> str:
try:
r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, capture_output=True, text=True, timeout=5)
if r.returncode == 0:
return r.stdout.strip()
except Exception:
pass
return "unknown"
def provenance(source: str, path: str, verified=True):
return {
"source": source,
"artifact_path": os.path.relpath(path, ROOT),
"commit": git_commit(),
"run_at": now_iso(),
"verified": bool(verified),
}
def run_security(text: str, mode: str):
with tempfile.TemporaryDirectory() as td:
inp = os.path.join(td, "in.txt")
out = os.path.join(td, "out.txt")
with open(inp, "w", encoding="utf-8") as fh:
fh.write(text)
r = subprocess.run(["bash", SECURITY, inp, out, mode], cwd=ROOT, capture_output=True, text=True)
safe = ""
if os.path.exists(out):
safe = open(out, encoding="utf-8").read()
return r.returncode, safe.strip(), (r.stdout + r.stderr).strip()
def classify(message: str):
r = subprocess.run(["python3", ROUTER, "classify", "--message", message], cwd=ROOT, capture_output=True, text=True)
if r.returncode != 0:
return {"mode": "BLOCK", "risk": "high", "reason": "router_failed", "matched_rules": [r.stderr.strip()]}
try:
return json.loads(r.stdout)
except ValueError:
return {"mode": "BLOCK", "risk": "high", "reason": "router_invalid_json", "matched_rules": []}
def whitelist_roots():
raw = os.environ.get("CASAN_CHAT_CONTEXT_ROOTS")
if raw:
roots = [os.path.abspath(p) for p in raw.split(":") if p]
else:
roots = [
os.path.join(ROOT, "docs", "plans"),
os.path.join(ROOT, "docs", "packaging"),
os.path.join(ROOT, "docs", "output", "casan"),
]
return [r for r in roots if os.path.isdir(r)]
def allowed_file(path: str, roots) -> bool:
ap = os.path.abspath(path)
if not any(ap == root or ap.startswith(root + os.sep) for root in roots):
return False
return os.path.splitext(ap)[1].lower() in {".md", ".txt", ".json", ".jsonl", ".yaml", ".yml"}
def terms(text: str):
raw = re.findall(r"[A-Za-z0-9_\-]{2,}|[À-ỹ]{3,}", text.lower())
return [t for t in raw if t not in STOPWORDS][:20]
def collect_sources(query: str):
roots = whitelist_roots()
qterms = terms(query)
scored = []
for root in roots:
for base, _, files in os.walk(root):
for name in files:
path = os.path.join(base, name)
if not allowed_file(path, roots):
continue
try:
with open(path, encoding="utf-8", errors="ignore") as fh:
lines = fh.readlines()
except OSError:
continue
best = []
for idx, line in enumerate(lines, start=1):
l = line.strip()
if not l:
continue
low = l.lower()
score = sum(1 for t in qterms if t in low)
if score:
best.append((score, idx, l[:260]))
if best:
best.sort(key=lambda x: (-x[0], x[1]))
score, line_no, excerpt = best[0]
path_low = os.path.relpath(path, ROOT).lower()
score += sum(2 for t in qterms if t in path_low)
scored.append((score, path, line_no, excerpt))
scored.sort(key=lambda x: (-x[0], x[1]))
out = []
for score, path, line_no, excerpt in scored[:5]:
out.append({
"path": os.path.relpath(path, ROOT),
"line": line_no,
"excerpt": excerpt,
"score": score,
"envelope": provenance("chat-context-whitelist", path, True),
})
if not out:
fallback = os.path.join(ROOT, "docs", "plans", "CASAN_PLAN_18_CHAT_CONSOLE.md")
if os.path.isfile(fallback):
out.append({
"path": os.path.relpath(fallback, ROOT),
"line": 1,
"excerpt": "# KẾ HOẠCH 18 — Governed Chat Console (Chat-as-Loop qua Control Plane)",
"score": 0,
"envelope": provenance("chat-context-whitelist-fallback", fallback, True),
})
return out
def answer_from_sources(message: str, sources):
if not sources:
return "Khong tim thay nguon trong whitelist evidence/docs nen khong tra loi suy doan."
bullets = []
for s in sources[:3]:
bullets.append(f"- {s['path']}:{s['line']} — {s['excerpt']}")
return "Ask CASAN read-only answer (evidence-backed):\n" + "\n".join(bullets)
def append_jsonl(path: str, rec):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
def load_head() -> str:
try:
return open(head_path(), encoding="utf-8").read().strip() or GENESIS_HASH
except OSError:
return GENESIS_HASH
def record_turn(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}
append_jsonl(path, rec)
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 record_metrics(trace_id: str, message: str, answer: str, status: str, latency_ms: int):
input_tokens = len(message.split())
output_tokens = len(answer.split())
rec = {
"timestamp": now_iso(),
"trace_id": trace_id,
"harness": "H6-agentops",
"agent": "chat.ask-casan",
"step": "ask-casan-readonly",
"status": status,
"exit_code": 0 if status == "success" else 2,
"latency_ms": latency_ms,
"retry_count": 0,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"cost_estimate": 0.0,
"cost_source": "readonly_word_count",
"hallucination_signals": 0,
"alerts": [],
"input_hash": sha(message),
"output_hash": sha(answer),
}
append_jsonl(metrics_path(), rec)
def ask(args):
started = datetime.now(timezone.utc)
trace_id = str(uuid.uuid4())
message = args.message
router = classify(message)
actor = args.actor or "anonymous"
chat_id = args.chat_id or "chat-default"
turn_id = args.turn_id or f"turn-{trace_id[:12]}"
tenant_id = args.tenant or "default"
def finish(decision: str, answer: str, sources=None, safe_message=""):
elapsed = int((datetime.now(timezone.utc) - started).total_seconds() * 1000)
sources = sources or []
rec = record_turn({
"timestamp": now_iso(),
"trace_id": trace_id,
"chat_id": chat_id,
"turn_id": turn_id,
"tenant_id": tenant_id,
"actor": actor,
"mode": router.get("mode", "BLOCK"),
"risk": router.get("risk", "high"),
"decision": decision,
"router": router,
"user_msg_ref": sha(message),
"safe_preview": (safe_message or "")[:180],
"answer_ref": sha(answer),
"sources": [{"path": s.get("path"), "line": s.get("line")} for s in sources],
})
record_metrics(trace_id, safe_message or message, answer, "success" if decision == "ANSWERED" else "failed", elapsed)
return {
"success": decision == "ANSWERED",
"chat_id": chat_id,
"turn_id": turn_id,
"trace_id": trace_id,
"mode": router.get("mode", "BLOCK"),
"risk": router.get("risk", "high"),
"decision": decision,
"answer": answer,
"sources": sources,
"certified": decision == "ANSWERED",
"audit": {"seq": rec["seq"], "record_hash": rec["record_hash"], "head": rec["record_hash"]},
"router": router,
}
if router.get("mode") != "READ_ONLY":
answer = f"Denied by Prompt Router: mode={router.get('mode')} reason={router.get('reason')}"
print(json.dumps(finish("NOT_SUPPORTED" if router.get("mode") == "NOT_SUPPORTED" else "DENIED", answer), ensure_ascii=False))
return 2
rc, safe_input, scan_msg = run_security(message, "input")
if rc != 0:
router["mode"] = "BLOCK"
router["reason"] = "h4_input_denied"
router["matched_rules"] = router.get("matched_rules", []) + [scan_msg]
answer = "Denied by H4 input scan."
print(json.dumps(finish("DENIED", answer), ensure_ascii=False))
return 2
sources = collect_sources(safe_input)
answer = answer_from_sources(safe_input, sources)
rc, safe_answer, scan_msg = run_security(answer, "output")
if rc != 0:
router["mode"] = "BLOCK"
router["reason"] = "h4_output_denied"
router["matched_rules"] = router.get("matched_rules", []) + [scan_msg]
print(json.dumps(finish("DENIED", "Denied by H4 output scan.", sources, safe_input), ensure_ascii=False))
return 2
print(json.dumps(finish("ANSWERED", safe_answer, sources, safe_input), 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_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_AUDIT ok=false brokenAt={count}")
return 1
prev = got
print(f"CHAT_AUDIT ok=true records={count} head={prev}")
return 0
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
askp = sub.add_parser("ask")
askp.add_argument("--message", required=True)
askp.add_argument("--actor", default="anonymous")
askp.add_argument("--chat-id", default="")
askp.add_argument("--turn-id", default="")
askp.add_argument("--tenant", default="default")
sub.add_parser("verify-audit")
args = ap.parse_args()
if args.cmd == "ask":
return ask(args)
if args.cmd == "verify-audit":
return verify_audit()
return 2
if __name__ == "__main__":
raise SystemExit(main())
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Plan-18 Chat turn entrypoint.
Thin harness-owned router for Control Panel: classify once, then delegate to the
mode primitive. NestJS calls this file only; it does not own governance verdicts.
"""
import argparse
import json
import os
import subprocess
import sys
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()
BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
ROUTER = os.path.join(BIN, "prompt-mode-router.py")
READONLY = os.path.join(BIN, "chat-readonly.py")
OPERATOR = os.path.join(BIN, "chat-operator.py")
def classify(message: str):
r = subprocess.run(["python3", ROUTER, "classify", "--message", message], cwd=ROOT, capture_output=True, text=True)
try:
return json.loads(r.stdout)
except Exception:
return {"mode": "BLOCK", "risk": "high", "reason": "router_invalid_json", "matched_rules": [r.stderr.strip()]}
def run_and_passthrough(args):
r = subprocess.run(args, cwd=ROOT, text=True)
return r.returncode
def ask(args) -> int:
router = classify(args.message)
common = [
"--message", args.message,
"--actor", args.actor,
"--chat-id", args.chat_id,
"--turn-id", args.turn_id,
"--tenant", args.tenant,
]
if router.get("mode") == "OPERATOR":
return run_and_passthrough(["python3", OPERATOR, "run", *common])
return run_and_passthrough(["python3", READONLY, "ask", *common])
def verify_audit() -> int:
return run_and_passthrough(["python3", READONLY, "verify-audit"])
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
askp = sub.add_parser("ask")
askp.add_argument("--message", required=True)
askp.add_argument("--actor", default="anonymous")
askp.add_argument("--chat-id", default="")
askp.add_argument("--turn-id", default="")
askp.add_argument("--tenant", default="default")
askp.set_defaults(func=ask)
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())
@@ -144,6 +144,12 @@ run "phase-loop-trace" bash "$TESTS/phase-loop-trace-tests.sh"
run "phase-loop-metaloop" bash "$TESTS/phase-loop-metaloop-tests.sh"
run "phase-loop-run" bash "$TESTS/phase-loop-run-tests.sh"
# Plan-18 Governed Chat Console MVP-0 (Ask CASAN read-only).
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-session-audit" bash "$TESTS/phase-chat-session-audit-tests.sh"
run "phase-chat-operator" bash "$TESTS/phase-chat-operator-tests.sh"
# ARCH-02: coverage cannot silently drop; ARCH-01: harness/policy cannot silently
# drift. Both SKIP cleanly when no manifest is provisioned (non-strict dev/CI).
run "test-integrity" python3 "$SCRIPT_DIR/test-integrity.py" verify
+173
View File
@@ -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())