#!/usr/bin/env python3 # -*- coding: utf-8 -*- """CASAN Agentic Bridge (Plan-20). A stdlib-only lifecycle bridge that lets a developer type prompts normally inside an agentic client (Claude Code, Codex, VS Code) while every CASAN-certified turn still carries a full H1->H7 trace and an H6 telemetry record. Architecture invariants (Plan-20 sections 3 & 5, Spike-20): * SINGLE MODEL EXECUTION. This bridge NEVER calls a model or `chat-turn.py ask`. The native client remains the only model executor. The bridge only performs admission, policy gating, evidence capture, telemetry and finalize. * FAIL-CLOSED AT THE SIDE-EFFECT POINT. A tool with side effects is denied when there is no valid, unexpired, project/session-bound admission, or when the bridge cannot decide within its internal timeout. * CERTIFICATION STRENGTH IS EXPLICIT. Every trace/record carries `integration_mode` + `certification_strength`; observe-mode traces are always `observed_only` and are never retroactively certified. * NO SILENT ZEROS. Missing token/cost data is recorded as `null` with a warning and a telemetry_quality of `partial`/`insufficient`, never coerced to 0. I/O contract: one JSON request object on stdin (or --input FILE), one JSON response object on stdout. See schemas/agentic-lifecycle.schema.json. Client adapters translate their native hook payloads into this contract and render the bridge response back into client-native JSON. """ from __future__ import annotations import argparse import base64 import hashlib import json import os import re import shutil import subprocess import sys import time import uuid SCHEMA_VERSION = "20.1" ADAPTER_DEFAULT_VERSION = "20.1.0" # ── Certification strength ranking (higher == stronger) ────────────────────── STRENGTH_RANK = { "observed_only": 0, "project_hook": 1, "managed_hook": 2, "casan_owned": 3, } CERTIFIED_STRENGTHS = ("project_hook", "managed_hook", "casan_owned") # Tools that produce side effects: these are the ones that MUST carry a valid # admission and pass the pre-tool gate. Read-only tools are recorded but never # blocked on admission alone. The list is intentionally broad and matched # case-insensitively so an unknown side-effect-shaped tool still fails closed. SIDE_EFFECT_TOOLS = { "bash", "shell", "exec", "run", "run_command", "terminal", "edit", "write", "multiedit", "notebookedit", "apply_patch", "str_replace_editor", "create_file", "update_file", "delete_file", "webfetch", "web_fetch", "browser", "computer", "sendmessage", } READ_ONLY_HINT_TOOLS = { "read", "grep", "glob", "ls", "list", "search", "view", "cat", "codegraph_search", "codegraph_node", "codegraph_context", } # Tool -> H2 tool-registry action name, when a registry mapping exists. TOOL_REGISTRY_ACTION = { "write": "write_file", "edit": "write_file", "multiedit": "write_file", "create_file": "write_file", "apply_patch": "write_file", "str_replace_editor": "write_file", } # ───────────────────────────────────────────────────────────────────────────── # Path resolution (mirrors scripts/bash/casan-paths.sh so state lands in the # same tree regardless of whether the bridge or a bash gate wrote it). # ───────────────────────────────────────────────────────────────────────────── def _self_dir(): return os.path.dirname(os.path.abspath(__file__)) def harness_root(): # scripts/python/agentic_bridge.py -> ../.. == harness root override = os.environ.get("CASAN_HARNESS_ROOT") if override: return os.path.abspath(override) return os.path.abspath(os.path.join(_self_dir(), "..", "..")) def _find_app_root(start): d = start while d and d != os.path.dirname(d): if os.path.isdir(os.path.join(d, ".specify")) or os.path.isdir( os.path.join(d, "packages", "casan-harness") ): return d d = os.path.dirname(d) return None def app_root(): override = os.environ.get("CASAN_APP_ROOT") if override: return os.path.abspath(override) found = _find_app_root(_self_dir()) if found: return found return os.path.abspath(os.path.join(harness_root(), "..")) def state_root(): override = os.environ.get("CASAN_STATE_ROOT") if override: return os.path.abspath(override) return os.path.join(app_root(), ".specify") def gates_dir(): return os.path.join(harness_root(), "scripts", "bash") def sessions_dir(): d = os.path.join(state_root(), "state", "agentic-sessions") os.makedirs(d, exist_ok=True) return d def trace_dir(): d = os.path.join(state_root(), "logs", "trace") os.makedirs(d, exist_ok=True) return d def trace_event_dir(): d = os.path.join(state_root(), "logs", "trace-events") os.makedirs(d, exist_ok=True) return d def metrics_log(): override = os.environ.get("CASAN_TELEMETRY_METRICS_LOG") or os.environ.get( "CASAN_METRICS_LOG" ) if override: path = os.path.abspath(override) else: path = os.path.join(state_root(), "logs", "cost", "metrics.jsonl") os.makedirs(os.path.dirname(path), exist_ok=True) return path def project_config(): path = os.path.join(app_root(), ".casan", "config.json") try: with open(path, "r", encoding="utf-8") as fh: value = json.load(fh) return value if isinstance(value, dict) else {} except (OSError, ValueError): return {} def dashboard_url(trace_id): """Return a safe Control Plane deep link, when the project was enrolled. Core never assumes that a dashboard is present and never performs network I/O in the prompt hook. The receipt is therefore useful offline while an enrolled project gets a clickable deep link without delaying finalize. """ configured = os.environ.get("CASAN_DASHBOARD_URL") if not configured: control_plane = project_config().get("control_plane") if isinstance(control_plane, dict): configured = control_plane.get("dashboard_url") base = str(configured or "").strip().rstrip("/") if not re.match(r"^https?://[a-zA-Z0-9]", base): return None return "%s/runs?trace=%s" % (base, str(trace_id or "")) def latest_run_path(): return os.path.join(state_root(), "state", "latest-run.json") def delivery_spool_dir(): path = os.path.join(state_root(), "spool", "control-plane", "pending") os.makedirs(path, exist_ok=True) return path def spool_run_envelope(rec, receipt, metric, trace_path): event_path = os.path.join( trace_event_dir(), "%s.jsonl" % rec.get("trace_id")) events = [] try: with open(event_path, "r", encoding="utf-8") as fh: events = [ json.loads(line) for line in fh if line.strip() ] except (OSError, ValueError): events = [] try: with open(trace_path, "r", encoding="utf-8") as fh: trace = json.load(fh) except (OSError, ValueError): trace = {} envelope = { "schema_version": 1, "sent_at": now_iso(), "project_id": rec.get("project_id"), "trace_id": rec.get("trace_id"), "receipt": receipt, "metric": metric, "trace": trace, "events": events, } path = os.path.join( delivery_spool_dir(), "%s.json" % rec.get("trace_id")) atomic_write_json(path, envelope) return path def spawn_delivery(spool_path): control_plane = project_config().get("control_plane") if not isinstance(control_plane, dict): return False ingest_url = str(control_plane.get("ingest_url") or "").strip() token_env = str( control_plane.get("token_env") or "CASAN_CONTROL_PLANE_TOKEN" ).strip() if not re.match(r"^https?://[a-zA-Z0-9]", ingest_url): return False if not token_env or not os.environ.get(token_env): return False exporter = os.path.join(_self_dir(), "telemetry_exporter.py") if not os.path.isfile(exporter): return False try: subprocess.Popen( [sys.executable, exporter, "--spool", spool_path, "--url", ingest_url, "--token-env", token_env], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, start_new_session=True, ) return True except OSError: return False def write_run_receipt(rec, decision, quality, warnings, report_url): """Persist a non-sensitive pointer for CLI/dashboard discovery. This is a materialized receipt, not the evidence source of truth. Trace and H6 records remain canonical; regenerating/deleting this pointer cannot change certification. """ payload = { "schema_version": 1, "trace_id": rec.get("trace_id"), "project_id": rec.get("project_id"), "decision": decision, "certified": decision == "certified", "certification_strength": rec.get("certification_strength"), "telemetry_quality": quality, "telemetry_warnings": warnings, "duration_ms": ( epoch_ms() - rec.get("started_ms") if rec.get("started_ms") else None ), "tool_calls": rec.get("tool_calls", 0), "failures": rec.get("failures", 0), "finalized_at": rec.get("finalized_at") or now_iso(), "report_url": report_url, "trace_path": os.path.relpath( os.path.join(trace_dir(), "agentic-%s.json" % rec.get("trace_id")), app_root(), ), "delivery": "local_spool", } atomic_write_json(latest_run_path(), payload) return payload def format_receipt(response): receipt = response.get("receipt") or {} certified = bool(receipt.get("certified")) verdict = "CERTIFIED" if certified else "NON-CERTIFIED" mark = "✓" if certified else "⚠" duration = receipt.get("duration_ms") duration_text = ( "%.1fs" % (float(duration) / 1000.0) if isinstance(duration, (int, float)) else "unknown" ) lines = [ "CASAN %s %s" % (mark, verdict), "Trace: %s · H1→H7 %s" % ( receipt.get("trace_id") or "unknown", "verified" if certified else "review required", ), "Duration: %s · Tools: %s · Failures: %s" % ( duration_text, receipt.get("tool_calls", 0), receipt.get("failures", 0), ), "H6 telemetry: %s" % str( receipt.get("telemetry_quality") or "unknown" ).upper(), ] if receipt.get("report_url"): lines.append("View report → %s" % receipt["report_url"]) else: lines.append( "View report → casan view %s" % (receipt.get("trace_id") or "") ) return "\n".join(lines) # ───────────────────────────────────────────────────────────────────────────── # Small helpers # ───────────────────────────────────────────────────────────────────────────── def now_iso(): return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) def epoch_ms(): return int(time.time() * 1000) def _salt(): """Per-install salt so persisted hashes are not trivially reversible and a raw prompt is never usable as a lookup key. Generated once, stored 0600.""" path = os.path.join(sessions_dir(), ".salt") try: with open(path, "r", encoding="utf-8") as fh: val = fh.read().strip() if val: return val except (OSError, IOError): pass val = uuid.uuid4().hex + uuid.uuid4().hex try: fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write(val) except (OSError, IOError): # Another process created it first — read that one back. try: with open(path, "r", encoding="utf-8") as fh: return fh.read().strip() or val except (OSError, IOError): return val return val def salted_hash(text): if text is None: return None h = hashlib.sha256() h.update(_salt().encode("utf-8")) h.update(b"\x00") h.update(text.encode("utf-8", "replace")) return h.hexdigest() def plain_hash(text): return hashlib.sha256((text or "").encode("utf-8", "replace")).hexdigest() def canonical_project(project): root = project or os.environ.get("CASAN_PROJECT_ROOT") or app_root() try: return os.path.realpath(os.path.abspath(root)) except OSError: return os.path.abspath(root) def project_id(project): return salted_hash(canonical_project(project))[:16] # ── Redaction: never persist raw prompts / tool output / secrets ───────────── _SECRET_PATTERNS = [ re.compile(r"-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----"), re.compile(r"(?i)\b(sk|pk|rk)-[A-Za-z0-9_\-]{16,}"), re.compile(r"(?i)\bghp_[A-Za-z0-9]{20,}"), re.compile(r"(?i)\bAKIA[0-9A-Z]{16}\b"), re.compile(r"(?i)(api[_-]?key|secret|token|password|passwd|authorization)\s*[:=]\s*\S+"), re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{6,}\b"), re.compile(r"[A-Za-z0-9+/]{40,}={0,2}"), # long base64-ish blobs ] def redact(value, limit=200): """Return a redacted, length-capped preview safe to persist.""" if value is None: return None if not isinstance(value, str): try: value = json.dumps(value, ensure_ascii=False, sort_keys=True) except (TypeError, ValueError): value = str(value) out = value for pat in _SECRET_PATTERNS: out = pat.sub("[REDACTED]", out) out = out.replace("\n", " ").strip() if len(out) > limit: out = out[:limit] + "…[truncated]" return out # ───────────────────────────────────────────────────────────────────────────── # Atomic state persistence with a portable per-turn lock # ───────────────────────────────────────────────────────────────────────────── class _FileLock: """Cross-platform advisory lock via an O_EXCL lock file. stdlib-only so it works identically on POSIX and Windows (no fcntl/msvcrt divergence).""" def __init__(self, target, timeout=5.0): self.lockpath = target + ".lock" self.timeout = timeout self.fd = None def __enter__(self): deadline = time.time() + self.timeout while True: try: self.fd = os.open(self.lockpath, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) return self except FileExistsError: # Break a stale lock (older than the timeout window). try: if time.time() - os.path.getmtime(self.lockpath) > self.timeout: os.unlink(self.lockpath) continue except OSError: pass if time.time() >= deadline: # Proceed lock-less rather than hang the client turn. return self time.sleep(0.02) def __exit__(self, *exc): if self.fd is not None: try: os.close(self.fd) except OSError: pass try: os.unlink(self.lockpath) except OSError: pass return False def atomic_write_json(path, payload): tmp = "%s.%s.tmp" % (path, uuid.uuid4().hex[:8]) with open(tmp, "w", encoding="utf-8") as fh: json.dump(payload, fh, ensure_ascii=False, separators=(",", ":")) fh.flush() os.fsync(fh.fileno()) os.replace(tmp, path) def admission_path(admission_id): # admission_id is an opaque uuid hex; reject anything else so a crafted id # cannot escape the sessions dir via path traversal. safe = re.sub(r"[^a-f0-9]", "", (admission_id or "").lower()) if not safe or safe != (admission_id or "").lower(): return None return os.path.join(sessions_dir(), "turn-%s.json" % safe) def load_admission(admission_id): path = admission_path(admission_id) if not path or not os.path.exists(path): return None try: with open(path, "r", encoding="utf-8") as fh: return json.load(fh) except (OSError, ValueError): return None def save_admission(rec): path = admission_path(rec["admission_id"]) if path: atomic_write_json(path, rec) # ───────────────────────────────────────────────────────────────────────────── # Enforcement configuration # ───────────────────────────────────────────────────────────────────────────── def bridge_enabled(): return os.environ.get("CASAN_AGENTIC_BRIDGE_ENABLED", "1") not in ("0", "false", "no", "") def enforcement_mode(): mode = (os.environ.get("CASAN_AGENTIC_ENFORCEMENT_MODE") or "observe").strip().lower() return mode if mode in ("observe", "enforce") else "observe" def client_allowed(client): allow = os.environ.get("CASAN_AGENTIC_CLIENT_ALLOWLIST") if not allow: return True return client in {c.strip() for c in allow.split(",") if c.strip()} def internal_timeout(): try: return float(os.environ.get("CASAN_AGENTIC_INTERNAL_TIMEOUT", "8")) except ValueError: return 8.0 def bash_bin(): # The bash interpreter used to run the H4/H2 gate scripts. On Windows this is # Git Bash (Git for Windows) or WSL bash; override via CASAN_AGENTIC_BASH. return os.environ.get("CASAN_AGENTIC_BASH") or os.environ.get("CASAN_BASH_BIN") or "bash" def bash_available(): return shutil.which(bash_bin()) is not None def resolve_strength(declared, client): """Compute the certification strength, DOWNGRADING (never upgrading).""" declared = declared if declared in STRENGTH_RANK else "project_hook" if enforcement_mode() == "observe": return "observed_only" if not client_allowed(client): return "observed_only" return declared # ───────────────────────────────────────────────────────────────────────────── # Gate invocation (real H1/H2/H4/H5 controls) with a hard internal timeout. # h4_scan returns a status string (ok|blocked|timeout|unavailable); h2 returns # (ok, reason). Timeout is fail-closed (block/deny); "unavailable" (no bash) is a # graceful degradation to observed_only, not a block. # ───────────────────────────────────────────────────────────────────────────── def _run_gate(argv, stdin_text=None): try: proc = subprocess.run( argv, input=(stdin_text.encode("utf-8") if stdin_text is not None else None), stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=internal_timeout(), ) return proc.returncode, proc.stdout.decode("utf-8", "replace"), proc.stderr.decode("utf-8", "replace") except subprocess.TimeoutExpired: return 124, "", "internal_timeout" except (OSError, ValueError) as exc: return 125, "", "gate_error:%s" % exc def h4_scan(text, mode="input"): """H4 security scan on a piece of text via security-check.sh. Returns a status STRING so callers can distinguish two very different cases: * "ok" — scanned, not blocked. * "blocked" — scanned and the gate refused (real policy block). * "timeout" — the gate did not answer within the internal timeout; treated as FAIL-CLOSED (block/deny) by callers. * "unavailable" — the gate could not run at all (no bash / script missing). This is a deployment reality (e.g. native Windows with no Git Bash), NOT an attack, so callers DEGRADE the turn to observed_only rather than hard-blocking every prompt. """ script = os.path.join(gates_dir(), "security-check.sh") if not os.path.exists(script) or not bash_available(): return "unavailable" tmpin = os.path.join(sessions_dir(), ".scan-in-%s" % uuid.uuid4().hex[:8]) tmpout = os.path.join(sessions_dir(), ".scan-out-%s" % uuid.uuid4().hex[:8]) try: with open(tmpin, "w", encoding="utf-8") as fh: fh.write(text or "") rc, _out, _err = _run_gate([bash_bin(), script, tmpin, tmpout, mode]) if rc == 124: return "timeout" if rc == 125: # Interpreter present per which() but exec still failed — treat as # unavailable, not a policy block. return "unavailable" if rc != 0: return "blocked" return "ok" finally: for p in (tmpin, tmpout): try: os.unlink(p) except OSError: pass def h2_registry_gate(action, idempotency_key): """H2 tool-registry gate for a mapped side-effect action.""" script = os.path.join(gates_dir(), "tool-registry-gate.sh") if not os.path.exists(script) or not bash_available(): return True, "h2_gate_missing" env = dict(os.environ) env["CASAN_IDEMPOTENCY_KEY"] = idempotency_key try: proc = subprocess.run( [bash_bin(), script, action], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=internal_timeout(), env=env, ) if proc.returncode == 0: return True, "h2_ok" return False, "h2_denied" except subprocess.TimeoutExpired: return False, "h2_internal_timeout" except (OSError, ValueError) as exc: return False, "h2_error:%s" % exc # ───────────────────────────────────────────────────────────────────────────── # Evidence + telemetry writers # ───────────────────────────────────────────────────────────────────────────── def _gate_status(decision): normalized = str(decision or "").lower() if normalized in ("allow", "allowed", "opened", "pass", "passed", "success", "complete", "completed", "certified"): return "pass" if normalized in ("degraded", "partial", "insufficient", "warning"): return "warning" if normalized in ("block", "blocked", "deny", "denied", "flag", "non_certified", "failed", "error"): return "blocked" return "running" def _gate_ids(harness): mapping = { "H1": "H1-context", "H2": "H2-tool", "H3": "H3-eval", "H4": "H4-security", "H5": "H5-governance", "H6": "H6-agentops", "H7": "H7-orchestration", } return [ mapping[value] for value in str(harness or "").split("/") if value in mapping ] def write_trace_events(rec, evidence): trace_id = rec.get("trace_id") if not trace_id: return path = os.path.join(trace_event_dir(), "%s.jsonl" % trace_id) with _FileLock(path): with open(path, "a", encoding="utf-8") as fh: for gate_id in _gate_ids(evidence.get("h")): event = { "schema_version": 1, "timestamp": evidence.get("at"), "trace_id": trace_id, "gate_id": gate_id, "status": _gate_status(evidence.get("decision")), "reason": "%s: %s" % ( evidence.get("kind"), evidence.get("detail"), ), "evidence": { "kind": evidence.get("kind"), "decision": evidence.get("decision"), "detail": evidence.get("detail"), "certification_strength": rec.get( "certification_strength"), }, } fh.write(json.dumps( event, ensure_ascii=False, separators=(",", ":")) + "\n") def add_evidence(rec, h, kind, decision, detail): evidence = { "h": h, "kind": kind, "decision": decision, "at": now_iso(), "detail": redact(detail, 160), } rec.setdefault("evidence", []).append(evidence) write_trace_events(rec, evidence) def classify_telemetry(rec): """Derive telemetry_quality + warnings from what was actually observed.""" tel = rec.get("telemetry") or {} warnings = [] missing = [] source = tel.get("cost_source") has_tokens = tel.get("input_tokens") is not None or tel.get("output_tokens") is not None has_cost = tel.get("cost_amount") is not None if not has_tokens: missing.append("tokens") if not has_cost: missing.append("cost") if source in ("provider_reported", "sdk_result_message") and has_tokens and has_cost: quality = "complete" elif source in ("statusline_estimate", "session_delta") or has_tokens or has_cost: quality = "partial" if source in ("statusline_estimate", "session_delta"): warnings.append("cost/token source is an estimate (%s), not provider-reported" % source) else: quality = "insufficient" warnings.append("no reliable runtime usage/cost source for this turn") for m in missing: warnings.append("%s unavailable — recorded as null, not 0" % m) return quality, warnings, missing def write_h6_record(rec, status, quality, warnings, missing): """Append the per-turn H6 telemetry record. Superset of the existing H6-agentops schema so legacy readers keep working; new provenance/trust fields are additive.""" tel = rec.get("telemetry") or {} started = rec.get("started_ms") finished = epoch_ms() duration = (finished - started) if started else None alerts = [] if status != "success": alerts.append("execution-failed") if rec.get("failures", 0) > 0: alerts.append("tool-failures") if quality != "complete": alerts.append("telemetry-%s" % quality) if rec.get("bypass_signal"): alerts.append("coverage-bypass") cost_amount = tel.get("cost_amount") record = { # ── legacy H6-agentops fields (kept so existing dashboards parse it) ── "timestamp": rec.get("occurred_at", now_iso()), "trace_id": rec.get("trace_id"), "harness": "H6-agentic", "agent": rec.get("client", "unknown"), "step": "agentic-turn", "status": status, "exit_code": 0 if status == "success" else 1, "latency_ms": duration if duration is not None else 0, "retry_count": rec.get("retries", 0), # tokens: NEVER coerce missing to 0 -> use null "input_tokens": tel.get("input_tokens"), "output_tokens": tel.get("output_tokens"), "total_tokens": ( (tel.get("input_tokens") or 0) + (tel.get("output_tokens") or 0) if (tel.get("input_tokens") is not None or tel.get("output_tokens") is not None) else None ), "cost_estimate": cost_amount, "cost_source": tel.get("cost_source") or "unavailable", "hallucination_signals": 0, "alerts": alerts, "input_hash": rec.get("prompt_hash"), "output_hash": rec.get("summary_hash"), # ── Plan-20 §5 additive fields ────────────────────────────────────── "schema_version": SCHEMA_VERSION, "client": rec.get("client"), "client_version": rec.get("client_version"), "adapter_version": rec.get("adapter_version"), "integration_mode": rec.get("integration_mode"), "certification_strength": rec.get("certification_strength"), "client_session_id_hash": rec.get("session_id_hash"), "client_turn_id_hash": rec.get("turn_id_hash"), "project_id": rec.get("project_id"), "started_at": rec.get("started_at"), "finished_at": now_iso(), "duration_ms": duration, "tool_calls": rec.get("tool_calls", 0), "failures": rec.get("failures", 0), "retries": rec.get("retries", 0), "cache_tokens": tel.get("cache_tokens"), "cost": { "amount": cost_amount, "currency": tel.get("cost_currency"), "cost_source": tel.get("cost_source") or "unavailable", "kind": ( "provider_reported" if tel.get("cost_source") in ("provider_reported", "sdk_result_message") else "estimated" if tel.get("cost_source") in ("statusline_estimate", "session_delta") else "unknown" ), }, "telemetry_quality": quality, "telemetry_warnings": warnings, "missing_fields": missing, "hook_trust_mode": enforcement_mode(), "bypass_signal": bool(rec.get("bypass_signal")), } log = metrics_log() with _FileLock(log): with open(log, "a", encoding="utf-8") as fh: fh.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") return record def write_trace_file(rec, certified, reasons): path = os.path.join(trace_dir(), "agentic-%s.json" % rec.get("trace_id")) payload = { "schema_version": SCHEMA_VERSION, "trace_id": rec.get("trace_id"), "admission_id": rec.get("admission_id"), "harness": "H1-H7-agentic", "client": rec.get("client"), "client_version": rec.get("client_version"), "adapter_version": rec.get("adapter_version"), "project_id": rec.get("project_id"), "session_id_hash": rec.get("session_id_hash"), "turn_id_hash": rec.get("turn_id_hash"), "prompt_hash": rec.get("prompt_hash"), "integration_mode": rec.get("integration_mode"), "certification_strength": rec.get("certification_strength"), "state": rec.get("state"), "occurred_at": rec.get("occurred_at"), "finalized_at": now_iso(), "tool_calls": rec.get("tool_calls", 0), "failures": rec.get("failures", 0), "certified": certified, "certification_reasons": reasons, "evidence": rec.get("evidence", []), } atomic_write_json(path, payload) return path # ───────────────────────────────────────────────────────────────────────────── # Operations # ───────────────────────────────────────────────────────────────────────────── def _base_response(op, decision, **kw): resp = {"op": op, "schema_version": SCHEMA_VERSION, "decision": decision, "warnings": []} resp.update(kw) return resp def op_begin(req): client = req.get("client", "unknown") if not bridge_enabled(): return _base_response("begin", "allow", reason="bridge_disabled", certification_strength="observed_only", integration_mode="observed_only") project = canonical_project(req.get("project")) strength = resolve_strength(req.get("integration_mode"), client) admission_id = uuid.uuid4().hex trace_id = str(uuid.uuid4()) started_ms = epoch_ms() rec = { "schema_version": SCHEMA_VERSION, "admission_id": admission_id, "trace_id": trace_id, "client": client, "client_version": req.get("client_version"), "adapter_version": req.get("adapter_version") or ADAPTER_DEFAULT_VERSION, "project_root": project, "project_id": project_id(project), "session_id_hash": salted_hash(req.get("session")), "turn_id_hash": salted_hash(req.get("turn") or admission_id), "prompt_hash": salted_hash(req.get("prompt")), "integration_mode": strength, "certification_strength": strength, "occurred_at": now_iso(), "started_at": now_iso(), "started_ms": started_ms, "ttl_expires_ms": started_ms + int(float(os.environ.get("CASAN_AGENTIC_TTL_SECONDS", "1800")) * 1000), "state": "Admitted", "tool_calls": 0, "failures": 0, "retries": 0, "evidence": [], "telemetry": {}, "bypass_signal": False, "finalized": False, } # H1 context admission (record that a context/admission was opened). add_evidence(rec, "H1", "context-admission", "opened", "project=%s client=%s" % (rec["project_id"], client)) # H4 security admission on the prompt. h4 = h4_scan(req.get("prompt", ""), "input") if h4 in ("blocked", "timeout"): # Real policy block, or fail-closed on timeout (gate exists but did not # answer in time). Either way the prompt is refused before the model. reason = "h4_blocked" if h4 == "blocked" else "h4_internal_timeout" rec["state"] = "Rejected" add_evidence(rec, "H4", "prompt-admission", "block", reason) save_admission(rec) write_trace_file(rec, False, ["prompt_blocked:%s" % reason]) resp = _base_response("begin", "block", admission_id=admission_id, trace_id=trace_id, integration_mode=strength, certification_strength=strength, reason=reason) resp["warnings"].append("prompt blocked by H4 (%s) — not sent to model per client capability" % reason) return resp if h4 == "unavailable": # Graceful degradation (e.g. native Windows with no Git Bash): the H4 # gate could not run, so we CANNOT certify this turn — downgrade to # observed_only — but we do NOT block the developer's prompt. strength = "observed_only" rec["integration_mode"] = "observed_only" rec["certification_strength"] = "observed_only" add_evidence(rec, "H4", "prompt-admission", "degraded", "gate_unavailable_no_bash") else: add_evidence(rec, "H4", "prompt-admission", "allow", "h4_ok") save_admission(rec) resp = _base_response("begin", "allow", admission_id=admission_id, trace_id=trace_id, integration_mode=strength, certification_strength=strength, reason="admitted") if h4 == "unavailable": resp["warnings"].append("H4 security gate unavailable (no bash on PATH) — " "turn is observed_only, install Git Bash to enable certification") elif strength == "observed_only": resp["warnings"].append("observe mode — this turn is telemetry-only and NOT certified") resp["context"] = "CASAN admission %s open (strength=%s)" % (admission_id[:8], strength) return resp def _is_side_effect(tool): t = (tool or "").strip().lower() if t in READ_ONLY_HINT_TOOLS: return False if t in SIDE_EFFECT_TOOLS: return True # Unknown tool: treat as side-effecting (fail-closed on coverage). return True def op_pre_tool(req): admission_id = req.get("admission_id") tool = req.get("tool", "") side_effect = _is_side_effect(tool) rec = load_admission(admission_id) if rec is None: # No admission at the side-effect point -> deny (fail-closed). return _base_response("pre-tool", "deny", admission_id=admission_id, reason="no_admission") # Project binding: a tool call from a different project must not reuse an # admission (cross-project replay). if req.get("project") and canonical_project(req.get("project")) != rec.get("project_root"): rec["bypass_signal"] = True add_evidence(rec, "H4", "pre-tool", "deny", "cross_project:%s" % tool) save_admission(rec) return _base_response("pre-tool", "deny", admission_id=admission_id, trace_id=rec.get("trace_id"), reason="cross_project") # TTL / state check. if epoch_ms() > rec.get("ttl_expires_ms", 0): rec["state"] = "Aborted" add_evidence(rec, "H4", "pre-tool", "deny", "admission_expired:%s" % tool) save_admission(rec) return _base_response("pre-tool", "deny", admission_id=admission_id, trace_id=rec.get("trace_id"), reason="admission_expired") if rec.get("finalized") or rec.get("state") in ("Rejected", "Aborted", "Certified", "NonCertified"): return _base_response("pre-tool", "deny", admission_id=admission_id, trace_id=rec.get("trace_id"), reason="admission_closed") rec["state"] = "Active" # H4 scan on the tool input. tool_input_text = req.get("tool_input") if tool_input_text is not None: h4 = h4_scan(tool_input_text if isinstance(tool_input_text, str) else json.dumps(tool_input_text, ensure_ascii=False), "input") if h4 in ("blocked", "timeout"): # Real block or fail-closed timeout — deny the side effect. reason = "h4_blocked" if h4 == "blocked" else "h4_internal_timeout" add_evidence(rec, "H4", "pre-tool", "deny", "%s:%s" % (tool, reason)) save_admission(rec) return _base_response("pre-tool", "deny", admission_id=admission_id, trace_id=rec.get("trace_id"), reason=reason) if h4 == "unavailable": # Gate could not run (no bash): the admission gate above already # governs this side effect, and the turn is non-certifiable, so we # record the degradation but do not block the developer. add_evidence(rec, "H4", "pre-tool", "degraded", "%s:gate_unavailable_no_bash" % tool) # H2 registry gate for mapped side-effect actions. Opt-in via # CASAN_AGENTIC_H2_REGISTRY=1: the tool-registry is keyed on NAMED CASAN # agent identities, which the transparent developer flow does not carry, so # enabling it unconditionally would deny every write. The always-on H2 # equivalent for this flow is the admission gate above (a side effect # without a valid admission is denied). Managed deployments that define # agent identities can turn the registry gate on for defence in depth. registry_on = os.environ.get("CASAN_AGENTIC_H2_REGISTRY", "0") in ("1", "true", "yes") if side_effect and registry_on and enforcement_mode() == "enforce": action = TOOL_REGISTRY_ACTION.get((tool or "").strip().lower()) if action: key = plain_hash("%s|%s|%s" % (rec["trace_id"], tool, redact(tool_input_text)))[:24] ok, reason = h2_registry_gate(action, key) if not ok: add_evidence(rec, "H2", "pre-tool", "deny", "%s:%s" % (tool, reason)) save_admission(rec) return _base_response("pre-tool", "deny", admission_id=admission_id, trace_id=rec.get("trace_id"), reason=reason) add_evidence(rec, "H2/H4", "pre-tool", "allow", "tool=%s side_effect=%s" % (tool, side_effect)) save_admission(rec) return _base_response("pre-tool", "allow", admission_id=admission_id, trace_id=rec.get("trace_id"), reason="allowed") def op_post_tool(req): rec = load_admission(req.get("admission_id")) if rec is None: return _base_response("post-tool", "recorded", admission_id=req.get("admission_id"), reason="no_admission") rec["tool_calls"] = rec.get("tool_calls", 0) + 1 status = req.get("status") or "success" if status in ("error", "timeout"): rec["failures"] = rec.get("failures", 0) + 1 add_evidence(rec, "H5", "post-tool", status, "tool=%s dur_ms=%s result=%s" % (req.get("tool"), req.get("duration_ms"), redact(req.get("result"), 80))) save_admission(rec) return _base_response("post-tool", "recorded", admission_id=rec["admission_id"], trace_id=rec.get("trace_id"), reason="evidence_appended") def op_telemetry(req): rec = load_admission(req.get("admission_id")) if rec is None: return _base_response("telemetry", "recorded", admission_id=req.get("admission_id"), reason="no_admission") tel = rec.setdefault("telemetry", {}) for field in ("input_tokens", "output_tokens", "cache_tokens", "cost_amount", "cost_currency", "cost_source", "model", "runtime_ms"): if field in req and req[field] is not None: tel[field] = req[field] save_admission(rec) quality, warnings, _missing = classify_telemetry(rec) add_evidence(rec, "H6", "telemetry", quality, "source=%s model=%s" % (tel.get("cost_source"), tel.get("model"))) save_admission(rec) resp = _base_response("telemetry", "recorded", admission_id=rec["admission_id"], trace_id=rec.get("trace_id"), telemetry_quality=quality, reason="telemetry_recorded") resp["warnings"].extend(warnings) return resp def op_finalize(req): rec = load_admission(req.get("admission_id")) if rec is None: return _base_response("finalize", "non_certified", admission_id=req.get("admission_id"), reason="no_admission") if rec.get("finalized"): # Idempotent: finalize exactly once, never loop. return _base_response("finalize", "certified" if rec.get("certified") else "non_certified", admission_id=rec["admission_id"], trace_id=rec.get("trace_id"), certification_strength=rec.get("certification_strength"), reason="already_finalized") rec["summary_hash"] = salted_hash(req.get("assistant_summary")) stop_reason = req.get("stop_reason") or "completed" status = "success" if stop_reason in ("completed", "max_turns") else "failed" observed_harnesses = { harness for evidence in rec.get("evidence", []) for harness in str(evidence.get("h") or "").split("/") } if "H2" not in observed_harnesses: add_evidence( rec, "H2", "tool-boundary", "pass", "no_tool_calls_side_effect_boundary_not_exercised") if "H5" not in observed_harnesses: add_evidence( rec, "H5", "governance-ledger", "pass", "no_side_effecting_action_required_a_decision") # H3/H5/H7 finalize controls: run the H4 output filter over the assistant # summary as the closing verification control. reasons = [] if req.get("assistant_summary"): h4 = h4_scan(req.get("assistant_summary"), "output") if h4 == "ok": add_evidence( rec, "H3/H7", "finalize-verify", "allow", "h4_ok") elif h4 == "unavailable": # An output that could not be scanned must never be certified. # Match begin/pre-tool graceful degradation: keep the developer # workflow available, but make the missing control explicit. rec["integration_mode"] = "observed_only" rec["certification_strength"] = "observed_only" add_evidence( rec, "H3/H7", "finalize-verify", "degraded", "gate_unavailable_no_bash") reasons.append("output_gate_unavailable") else: # A policy block or timeout is fail-closed for certification. # Treat unknown statuses the same way so a future contract change # cannot silently certify an unverified output. why = { "blocked": "h4_blocked", "timeout": "h4_internal_timeout", }.get(h4, "h4_unknown_status") add_evidence( rec, "H3/H7", "finalize-verify", "flag", why) reasons.append("output_flagged:%s" % why) # Certification decision. strength = rec.get("certification_strength", "observed_only") certified = True if reasons: # Any closing output-verification failure above is certification-fatal, # even when every earlier admission/tool control succeeded. certified = False if enforcement_mode() != "enforce": certified = False reasons.append("observe_mode") if strength not in CERTIFIED_STRENGTHS: certified = False reasons.append("strength_%s" % strength) if rec.get("state") in ("Rejected", "Aborted"): certified = False reasons.append("turn_%s" % rec.get("state")) if rec.get("bypass_signal"): certified = False reasons.append("coverage_bypass") if status != "success": certified = False reasons.append("stop_%s" % stop_reason) if not certified and not reasons: reasons.append("unknown") if certified: reasons.append("evidence_complete") rec["state"] = "Certified" if certified else "NonCertified" rec["certified"] = certified rec["finalized"] = True rec["finalized_at"] = now_iso() add_evidence( rec, "H7", "certification", "certified" if certified else "non_certified", ",".join(reasons), ) quality, warnings, missing = classify_telemetry(rec) add_evidence( rec, "H6", "telemetry-quality", quality, ",".join(warnings) if warnings else "provider_usage_complete", ) h6_record = write_h6_record(rec, status, quality, warnings, missing) trace_path = write_trace_file(rec, certified, reasons) save_admission(rec) resp = _base_response("finalize", "certified" if certified else "non_certified", admission_id=rec["admission_id"], trace_id=rec.get("trace_id"), certification_strength=strength, telemetry_quality=quality, reason=",".join(reasons)) resp["warnings"].extend(warnings) resp["context"] = "trace=%s certified=%s" % (os.path.basename(trace_path), certified) report_url = dashboard_url(rec.get("trace_id")) resp["report_url"] = report_url resp["receipt"] = write_run_receipt( rec, resp["decision"], quality, warnings, report_url) spool_path = spool_run_envelope( rec, resp["receipt"], h6_record, trace_path) if spawn_delivery(spool_path): resp["receipt"]["delivery"] = "async_delivery_started" atomic_write_json(latest_run_path(), resp["receipt"]) return resp def op_abort(req): rec = load_admission(req.get("admission_id")) if req.get("admission_id") else None if rec is None: # Emit a standalone failure telemetry record even without an admission. stub = { "trace_id": str(uuid.uuid4()), "client": "unknown", "occurred_at": now_iso(), "started_at": now_iso(), "started_ms": epoch_ms(), "certification_strength": "observed_only", "integration_mode": "observed_only", "failures": 1, "bypass_signal": False, "telemetry": {}, } write_h6_record(stub, "failed", "insufficient", ["turn aborted with no admission: %s" % (req.get("reason") or "unknown")], ["tokens", "cost"]) return _base_response("abort", "non_certified", trace_id=stub["trace_id"], reason=req.get("reason") or "aborted_no_admission") if rec.get("finalized"): return _base_response("abort", "non_certified", admission_id=rec["admission_id"], trace_id=rec.get("trace_id"), reason="already_finalized") rec["state"] = "Aborted" rec["certified"] = False rec["finalized"] = True rec["failures"] = rec.get("failures", 0) + 1 add_evidence(rec, "H7", "abort", "non_certified", req.get("reason") or "aborted") quality, warnings, missing = classify_telemetry(rec) write_h6_record(rec, "failed", quality, warnings + ["turn aborted: %s" % (req.get("reason") or "unknown")], missing) write_trace_file(rec, False, ["aborted:%s" % (req.get("reason") or "unknown")]) save_admission(rec) return _base_response("abort", "non_certified", admission_id=rec["admission_id"], trace_id=rec.get("trace_id"), reason=req.get("reason") or "aborted") OPS = { "begin": op_begin, "pre-tool": op_pre_tool, "post-tool": op_post_tool, "telemetry": op_telemetry, "finalize": op_finalize, "abort": op_abort, } # ───────────────────────────────────────────────────────────────────────────── # report: H6 drill-down filter (Plan-20 §5 — filter by client/mode/trace/project) # ───────────────────────────────────────────────────────────────────────────── def op_report(args): log = metrics_log() rows = [] if os.path.exists(log): with open(log, "r", encoding="utf-8") as fh: for line in fh: line = line.strip() if not line: continue try: rec = json.loads(line) except ValueError: continue if rec.get("harness") != "H6-agentic": continue if args.client and rec.get("client") != args.client: continue if args.integration_mode and rec.get("integration_mode") != args.integration_mode: continue if args.trace_id and rec.get("trace_id") != args.trace_id: continue if args.project_id and rec.get("project_id") != args.project_id: continue rows.append(rec) summary = { "count": len(rows), "certified": sum(1 for r in rows if r.get("certification_strength") in CERTIFIED_STRENGTHS and r.get("hook_trust_mode") == "enforce"), "by_strength": {}, "by_quality": {}, "records": rows if args.verbose else [ {k: r.get(k) for k in ("trace_id", "client", "integration_mode", "certification_strength", "telemetry_quality", "duration_ms", "tool_calls", "failures")} for r in rows ], } for r in rows: s = r.get("certification_strength") or "unknown" q = r.get("telemetry_quality") or "unknown" summary["by_strength"][s] = summary["by_strength"].get(s, 0) + 1 summary["by_quality"][q] = summary["by_quality"].get(q, 0) + 1 print(json.dumps(summary, ensure_ascii=False, indent=2)) return 0 # ───────────────────────────────────────────────────────────────────────────── # doctor: diagnostics for the installer / adapter health check # ───────────────────────────────────────────────────────────────────────────── def op_doctor(_args): h4_present = os.path.exists(os.path.join(gates_dir(), "security-check.sh")) bash_ok = bash_available() checks = { "bridge_enabled": bridge_enabled(), "enforcement_mode": enforcement_mode(), "harness_root": harness_root(), "state_root": state_root(), "sessions_dir_writable": os.access(sessions_dir(), os.W_OK), "bash_bin": bash_bin(), "bash_available": bash_ok, "h4_gate_present": h4_present, "h2_gate_present": os.path.exists(os.path.join(gates_dir(), "tool-registry-gate.sh")), "gates_runnable": bool(h4_present and bash_ok), "metrics_log": metrics_log(), "schema_version": SCHEMA_VERSION, } if not bash_ok: checks["warning"] = ("no bash on PATH — the H4/H2 gates cannot run, so turns " "degrade to observed_only (never certified). Install Git for " "Windows (Git Bash) or set CASAN_AGENTIC_BASH to enable " "certification. The bridge itself still works.") print(json.dumps(checks, ensure_ascii=False, indent=2)) # Doctor is GREEN as long as the bridge can operate (state writable). Missing # bash is a warning (degraded), not a hard failure — that is the whole point # of graceful degradation for wide Windows deployment. return 0 if checks["sessions_dir_writable"] else 1 # ───────────────────────────────────────────────────────────────────────────── # Entry point # ───────────────────────────────────────────────────────────────────────────── def _read_request(args): if args.input: with open(args.input, "r", encoding="utf-8") as fh: raw = fh.read() else: raw = sys.stdin.read() return json.loads(raw) def main(argv=None): parser = argparse.ArgumentParser(description="CASAN Agentic Bridge (Plan-20)") sub = parser.add_subparsers(dest="cmd") p_run = sub.add_parser("run", help="run a lifecycle op from a JSON request on stdin") p_run.add_argument("--input", help="read the JSON request from FILE instead of stdin") p_run.add_argument("--op", help="override the op field in the request") p_report = sub.add_parser("report", help="filter H6 agentic telemetry") p_report.add_argument("--client") p_report.add_argument("--integration-mode", dest="integration_mode") p_report.add_argument("--trace-id", dest="trace_id") p_report.add_argument("--project-id", dest="project_id") p_report.add_argument("--verbose", action="store_true") sub.add_parser("doctor", help="diagnostics") # Back-compat: allow `agentic_bridge.py --input x` to imply `run`. parser.add_argument("--input", help=argparse.SUPPRESS) parser.add_argument("--op", help=argparse.SUPPRESS) args = parser.parse_args(argv) cmd = args.cmd or ("run" if (args.input or not sys.stdin.isatty()) else None) if cmd == "report": return op_report(args) if cmd == "doctor": return op_doctor(args) if cmd != "run": parser.print_help() return 64 try: req = _read_request(args) except (OSError, ValueError) as exc: print(json.dumps(_base_response("error", "error", reason="invalid_request:%s" % exc))) return 65 if args.op: req["op"] = args.op op = req.get("op") handler = OPS.get(op) if handler is None: print(json.dumps(_base_response(op or "unknown", "error", reason="unknown_op"))) return 65 try: resp = handler(req) except Exception as exc: # noqa: BLE001 — fail closed, never crash the client turn # A bridge crash must not silently allow a side effect. For gate ops we # emit a deny/block; for accounting ops we surface the error. deny_default = "deny" if op == "pre-tool" else "block" if op == "begin" else "error" resp = _base_response(op, deny_default, reason="bridge_exception:%s" % exc) print(json.dumps(resp, ensure_ascii=False)) return 0 if __name__ == "__main__": sys.exit(main())