feat: escalate chat approvals through inbox

This commit is contained in:
thanhnv
2026-07-08 23:09:43 +09:00
parent a029a51e72
commit 780fe86273
15 changed files with 316 additions and 26 deletions
@@ -30,6 +30,7 @@ ROUTER = os.path.join(BIN, "prompt-mode-router.py")
AGENT_RESOLVER = os.path.join(BIN, "chat-agent-resolver.py")
READONLY = os.path.join(BIN, "chat-readonly.py")
OPERATOR = os.path.join(BIN, "chat-operator.py")
APPROVAL_INBOX = os.path.join(BIN, "approval-inbox.py")
LOOP_RUN = os.path.join(BIN, "loop-run.sh")
LOOP_TRACE = os.path.join(BIN, "loop-trace.py")
PREFLIGHT = os.path.join(BIN, "harness-preflight.sh")
@@ -41,6 +42,14 @@ 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 sha(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
@@ -127,6 +136,32 @@ def loop_dir(run_id: str) -> str:
return os.path.join(state_root(), "logs", "chat", "loop-runs", run_id)
def load_chat_head() -> str:
try:
return open(head_path(), encoding="utf-8").read().strip() or ("0" * 64)
except OSError:
return "0" * 64
def append_chat_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_chat_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}
with open(path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
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 run_preflight_and_context(run_id: str, draft_path: str):
preflight_out = os.path.join(loop_dir(run_id), "preflight.json")
r = subprocess.run(["bash", PREFLIGHT, draft_path, preflight_out, "--model", "local:chat-turn"], cwd=ROOT, capture_output=True, text=True)
@@ -257,20 +292,85 @@ def bind_agent(args, router):
for tool in tools:
cmd += ["--tool", tool]
r = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True)
if r.returncode != 0:
sys.stdout.write((r.stdout or r.stderr).strip() + "\n")
return r.returncode, None
try:
return 0, json.loads(r.stdout)
return r.returncode, json.loads(r.stdout)
except Exception:
if r.returncode != 0 and (r.stdout or r.stderr):
sys.stdout.write((r.stdout or r.stderr).strip() + "\n")
return r.returncode, None
print(json.dumps({"success": False, "decision": "DENIED", "reason": "agent_resolver_invalid_json"}, ensure_ascii=False))
return 2, None
def submit_escalation(args, router, binding):
turn_id = args.turn_id or str(uuid.uuid4())
payload = {
"chat_id": args.chat_id or "chat-default",
"turn_id": turn_id,
"tenant_id": args.tenant,
"actor": args.actor,
"role": args.role,
"message_ref": sha(args.message),
"message_preview": args.message[:240],
"router": router,
"agent_binding": binding,
}
reason = f"chat turn requires approval: {binding.get('reason', 'requires_approval')}"
r = subprocess.run([
"python3", APPROVAL_INBOX, "submit",
"--project", args.project,
"--action", "chat.escalate",
"--target", f"chat:{args.chat_id or 'chat-default'}:{turn_id}",
"--risk", router.get("risk", "high"),
"--sensitive",
"--proposer", args.actor,
"--reason", reason,
"--payload", json.dumps(payload, ensure_ascii=False, sort_keys=True),
], cwd=ROOT, capture_output=True, text=True)
try:
proposal = json.loads(r.stdout)
except Exception:
proposal = {"status": "failed", "error": (r.stdout + r.stderr).strip()}
rec = append_chat_turn({
"timestamp": proposal.get("created_at") or "",
"trace_id": "chat-escalation-" + sha(f"{args.chat_id}|{turn_id}|{args.message}")[:12],
"chat_id": args.chat_id or "chat-default",
"turn_id": turn_id,
"tenant_id": args.tenant,
"actor": args.actor,
"mode": router.get("mode", "READ_ONLY"),
"risk": router.get("risk", "high"),
"decision": "ESCALATED" if r.returncode == 0 else "DENIED",
"answer": "Chat turn escalated to approval inbox." if r.returncode == 0 else "Chat escalation failed closed.",
"sources": [],
"router": router,
"agent_binding": binding,
"approval": {"proposal_id": proposal.get("id"), "status": proposal.get("status"), "target": proposal.get("target")},
})
print(json.dumps({
"success": False,
"mode": router.get("mode", "READ_ONLY"),
"risk": router.get("risk", "high"),
"decision": "ESCALATED" if r.returncode == 0 else "DENIED",
"answer": "Chat turn escalated to approval inbox." if r.returncode == 0 else "Chat escalation failed closed.",
"sources": [],
"certified": False,
"audit": {"seq": rec["seq"], "record_hash": rec["record_hash"], "head": rec["record_hash"], "path": audit_path()},
"router": router,
"agent_binding": binding,
"approval": {"proposal": proposal, "submit_rc": r.returncode, "output": (r.stdout + r.stderr).strip()[:400]},
}, ensure_ascii=False))
return 3 if r.returncode == 0 else 2
def ask(args) -> int:
router = classify(args.message)
bind_rc, binding = bind_agent(args, router)
if bind_rc != 0:
if binding and binding.get("decision") == "REQUIRES_APPROVAL":
return submit_escalation(args, router, binding)
if binding:
print(json.dumps(binding, ensure_ascii=False))
return bind_rc
common = [
"--message", args.message,
@@ -153,6 +153,7 @@ run "phase-chat-agent-select" bash "$TESTS/phase-chat-agent-select-tests.sh"
run "phase-chat-pipeline" bash "$TESTS/phase-chat-pipeline-tests.sh"
run "phase-chat-stream-hold" bash "$TESTS/phase-chat-stream-hold-tests.sh"
run "phase-chat-replay" bash "$TESTS/phase-chat-replay-tests.sh"
run "phase-chat-approval" bash "$TESTS/phase-chat-approval-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).
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -uo pipefail
# Plan-18 MVP-2 Track 8.4: chat turns that exceed delegated authority are
# escalated into the Plan-13 approval inbox and remain tamper-verifiable.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
TURN="$CASAN_HARNESS_ROOT/scripts/bash/chat-turn.py"
APPROVAL="$CASAN_HARNESS_ROOT/scripts/bash/approval-inbox.py"
REPLAY="$CASAN_HARNESS_ROOT/scripts/bash/chat-replay.py"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
export CASAN_STATE_ROOT="$WORK/state"
export CASAN_APPROVAL_INBOX_FILE="$WORK/approval-inbox.json"
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
echo "===== Plan-18 MVP-2 chat approvals escalation ====="
set +e
python3 "$TURN" ask \
--message "Summarize Plan 18 MVP-2 status" \
--actor alice \
--role viewer \
--chat-id approval-chat \
--tenant tenant-a \
--delegation-level 1 > "$WORK/escalated.json"
RC=$?
set -e 2>/dev/null || true
python3 - "$WORK/escalated.json" "$RC" <<'PY' \
&& pass "delegation escalation creates pending approval proposal" || fail "chat turn did not escalate to approval inbox"
import json, sys
d = json.load(open(sys.argv[1]))
assert int(sys.argv[2]) == 3
assert d["success"] is False
assert d["decision"] == "ESCALATED"
assert d["agent_binding"]["decision"] == "REQUIRES_APPROVAL"
assert d["approval"]["proposal"]["status"] == "pending"
assert d["approval"]["proposal"]["action"] == "chat.escalate"
assert d["approval"]["proposal"]["payload"]["chat_id"] == "approval-chat"
PY
python3 "$APPROVAL" list --status pending > "$WORK/pending.json"
python3 - "$WORK/pending.json" <<'PY' \
&& pass "approval inbox lists escalated chat proposal" || fail "pending chat proposal missing from inbox"
import json, sys
d = json.load(open(sys.argv[1]))
assert d["count"] == 1
p = d["proposals"][0]
assert p["status"] == "pending"
assert p["action"] == "chat.escalate"
assert p["payload"]["agent_binding"]["requires_approval"] is True
PY
python3 "$REPLAY" verify-chain > "$WORK/chat-chain.out" \
&& grep -q '"decision": "OK"' "$WORK/chat-chain.out" \
&& pass "escalated chat turn audit chain verifies" || fail "escalated chat audit chain invalid"
python3 "$APPROVAL" verify-audit > "$WORK/approval-audit.out" \
&& grep -q "APPROVAL_INBOX_AUDIT ok=true" "$WORK/approval-audit.out" \
&& pass "approval oversight audit chain verifies" || fail "approval oversight audit invalid"
echo ""
echo "===== CHAT APPROVAL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1