Files
CASAN/packages/casan-harness/scripts/python/agentic_bridge.py
T

1411 lines
57 KiB
Python
Executable File

#!/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())