1134 lines
48 KiB
Python
Executable File
1134 lines
48 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 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
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 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 add_evidence(rec, h, kind, decision, detail):
|
|
rec.setdefault("evidence", []).append({
|
|
"h": h,
|
|
"kind": kind,
|
|
"decision": decision,
|
|
"at": now_iso(),
|
|
"detail": redact(detail, 160),
|
|
})
|
|
|
|
|
|
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"
|
|
|
|
# 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()
|
|
|
|
quality, warnings, missing = classify_telemetry(rec)
|
|
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)
|
|
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())
|