#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Claude Code adapter for the CASAN agentic bridge (Plan-20, Wave 1). Thin renderer only: it translates Claude Code lifecycle hook JSON (on stdin) into the client-agnostic bridge contract, calls the core bridge, and renders the bridge response back into Claude-native hook output. The core bridge stays ignorant of Claude's JSON shape (Plan-20 §4 "Hook response renderer tách theo adapter"). Wire-up in `.claude/settings.json` (see settings.template.json in this folder): UserPromptSubmit -> claude_hook.py --event UserPromptSubmit PreToolUse -> claude_hook.py --event PreToolUse PostToolUse -> claude_hook.py --event PostToolUse Stop -> claude_hook.py --event Stop The event may also be taken from the `hook_event_name` field Claude includes in the payload, so `--event` is optional. Claude hook contracts honored here: * UserPromptSubmit: `{"decision":"block","reason":...}` blocks the prompt; `hookSpecificOutput.additionalContext` injects extra context on allow. * PreToolUse: `hookSpecificOutput.permissionDecision` = allow|deny|ask. * PostToolUse / Stop: observational; Stop finalizes exactly once and guards against the stop-hook loop via `stop_hook_active`. """ from __future__ import annotations import argparse import hashlib import json import os import sys # Import the core bridge as a module so the adapter reuses its state layout, # hashing and op handlers without duplicating path logic or shelling out. _ADAPTER_DIR = os.path.dirname(os.path.abspath(__file__)) _BRIDGE_DIR = os.path.abspath(os.path.join(_ADAPTER_DIR, "..", "..", "scripts", "python")) if _BRIDGE_DIR not in sys.path: sys.path.insert(0, _BRIDGE_DIR) import agentic_bridge as bridge # noqa: E402 ADAPTER_VERSION = "20.1.0-claude" def integration_mode(): return os.environ.get("CASAN_AGENTIC_INTEGRATION_MODE", "project_hook") def _ptr_path(session): h = hashlib.sha256(("claude:" + (session or "nosession")).encode("utf-8")).hexdigest()[:24] return os.path.join(bridge.sessions_dir(), "ptr-%s.json" % h) def store_pointer(session, admission_id, trace_id): bridge.atomic_write_json(_ptr_path(session), { "admission_id": admission_id, "trace_id": trace_id}) def load_pointer(session): path = _ptr_path(session) if 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 clear_pointer(session): try: os.unlink(_ptr_path(session)) except OSError: pass def _emit(obj): sys.stdout.write(json.dumps(obj, ensure_ascii=False)) sys.stdout.write("\n") def handle_user_prompt_submit(payload): session = payload.get("session_id") resp = bridge.op_begin({ "op": "begin", "client": "claude-code", "client_version": payload.get("client_version") or os.environ.get("CASAN_CLIENT_VERSION"), "adapter_version": ADAPTER_VERSION, "project": payload.get("cwd") or payload.get("project_dir"), "session": session, "turn": payload.get("prompt_id") or payload.get("turn_id"), "prompt": payload.get("prompt", ""), "integration_mode": integration_mode(), }) if resp.get("admission_id") and resp.get("decision") != "block": store_pointer(session, resp["admission_id"], resp.get("trace_id")) if resp.get("decision") == "block": _emit({ "decision": "block", "reason": "CASAN blocked this prompt: %s" % (resp.get("reason") or "policy"), }) return 0 ctx = "[CASAN] %s" % (resp.get("context") or "admission open") if resp.get("warnings"): ctx += " | " + "; ".join(resp["warnings"]) _emit({ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": ctx, } }) return 0 def handle_pre_tool_use(payload): session = payload.get("session_id") ptr = load_pointer(session) admission_id = ptr.get("admission_id") if ptr else None resp = bridge.op_pre_tool({ "op": "pre-tool", "admission_id": admission_id or "", "tool": payload.get("tool_name", ""), "tool_input": payload.get("tool_input"), "project": payload.get("cwd"), }) decision = "allow" if resp.get("decision") == "allow" else "deny" _emit({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": decision, "permissionDecisionReason": "CASAN: %s" % (resp.get("reason") or decision), } }) return 0 def handle_post_tool_use(payload): session = payload.get("session_id") ptr = load_pointer(session) if not ptr: _emit({"hookSpecificOutput": {"hookEventName": "PostToolUse"}}) return 0 resp = payload.get("tool_response") or {} status = "success" if isinstance(resp, dict): if resp.get("error") or resp.get("is_error") or resp.get("success") is False: status = "error" bridge.op_post_tool({ "op": "post-tool", "admission_id": ptr.get("admission_id"), "tool": payload.get("tool_name", ""), "status": status, "duration_ms": payload.get("duration_ms"), "result": resp if isinstance(resp, (str, int, float)) else None, }) _emit({"hookSpecificOutput": {"hookEventName": "PostToolUse"}}) return 0 def handle_stop(payload): # Guard against the stop-hook loop: if a prior Stop hook is already active, # do nothing (bridge finalize is also idempotent as a second line of defense). if payload.get("stop_hook_active"): _emit({}) return 0 session = payload.get("session_id") ptr = load_pointer(session) if not ptr: _emit({}) return 0 bridge.op_finalize({ "op": "finalize", "admission_id": ptr.get("admission_id"), "stop_reason": "completed", "assistant_summary": payload.get("last_assistant_message") or payload.get("assistant_summary"), }) clear_pointer(session) _emit({}) return 0 HANDLERS = { "UserPromptSubmit": handle_user_prompt_submit, "PreToolUse": handle_pre_tool_use, "PostToolUse": handle_post_tool_use, "Stop": handle_stop, "SubagentStop": handle_stop, } def main(argv=None): parser = argparse.ArgumentParser(description="CASAN Claude Code hook adapter") parser.add_argument("--event", help="Claude hook event name (else read from payload)") args = parser.parse_args(argv) try: payload = json.loads(sys.stdin.read() or "{}") except ValueError: payload = {} event = args.event or payload.get("hook_event_name") handler = HANDLERS.get(event) if handler is None: # Unknown event: never block the client turn — emit a no-op. _emit({}) return 0 try: return handler(payload) except Exception as exc: # noqa: BLE001 # Fail safe for the client turn. For PreToolUse we default to deny so a # crash cannot silently allow a side effect; other events no-op. if event == "PreToolUse": _emit({"hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "CASAN adapter error: %s" % exc, }}) else: _emit({}) return 0 if __name__ == "__main__": sys.exit(main())