230 lines
7.5 KiB
Python
Executable File
230 lines
7.5 KiB
Python
Executable File
#!/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
|
|
resp = 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({"systemMessage": bridge.format_receipt(resp)})
|
|
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())
|