Files
CASAN/packages/casan-harness/scripts/bash/chat-operator.py
T

410 lines
16 KiB
Python
Executable File

#!/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 sha_file(path: str) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
h.update(chunk)
return h.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)
loop_run = {}
if os.environ.get("CASAN_CHAT_LOOP_RUN_JSON"):
try:
loop_run = json.loads(os.environ["CASAN_CHAT_LOOP_RUN_JSON"])
except ValueError:
loop_run = {"success": False, "decision": "DENIED", "reason": "loop_run_json_invalid"}
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,
"hash": sha_file(artifact_path),
"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"), "hash": s.get("hash")} for s in source],
"loop_run": loop_run,
})
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 {},
"loop_run": loop_run,
}
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())