feat: add governed chat console
This commit is contained in:
+392
@@ -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
@@ -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
@@ -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
@@ -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