feat(harness): implement Plan-20 transparent agentic client bridge
Wave 0 + Wave 1 core of the transparent agentic-client integration: a
developer types prompts normally in Claude Code / Codex while every
certified turn still carries a full H1->H7 trace and an H6 record.
- agentic_bridge.py: stdlib-only lifecycle state machine (begin/pre-tool/
post-tool/telemetry/finalize/abort + report/doctor). Single-model
invariant (never calls a model), fail-closed at the side-effect point,
admission TTL + canonical-project/session binding, atomic state under
.specify/state/agentic-sessions/, secret redaction, null-not-zero H6.
- agentic-lifecycle.schema.json: client-agnostic JSON contract.
- adapters/claude-code + adapters/codex: thin hook renderers + config
templates that call the core bridge.
- phase-agentic-bridge-tests.sh: C1-C12 acceptance + threat suite (30/30).
- devkit templates/{claude,codex} + windows/install-agentic.ps1
(install/doctor/uninstall with manifest, path-safe).
- docs/casan Windows + security/bypass guides; plan status -> IMPLEMENTED.
- harden generate-agentops-dashboard.py aggregation against null H6 costs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0cc43d94d3
commit
4bb184b935
+229
@@ -0,0 +1,229 @@
|
||||
#!/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"):
|
||||
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())
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"//": "CASAN Plan-20 Claude Code project hooks. Commit this as .claude/settings.json in the target repo (the devkit installer does this). Commands self-resolve the repo root via $CLAUDE_PROJECT_DIR — no machine-specific path is baked in. Secrets and absolute paths must NOT be added here.",
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR/packages/casan-harness/adapters/claude-code/claude_hook.py\" --event UserPromptSubmit",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR/packages/casan-harness/adapters/claude-code/claude_hook.py\" --event PreToolUse",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR/packages/casan-harness/adapters/claude-code/claude_hook.py\" --event PostToolUse",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR/packages/casan-harness/adapters/claude-code/claude_hook.py\" --event Stop",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Codex adapter for the CASAN agentic bridge (Plan-20, Wave 3).
|
||||
|
||||
Thin renderer that maps Codex lifecycle hook payloads (on stdin) into the
|
||||
client-agnostic bridge contract and renders the response back. It reuses the
|
||||
core bridge module so the bridge never learns Codex's JSON shape.
|
||||
|
||||
Codex project hooks load only AFTER a trust review (Spike-20 §4.2) — the devkit
|
||||
`doctor` surfaces the trust/onboarding state so this is never hidden from a
|
||||
member. Codex tool hooks are a guardrail, not a complete boundary: a turn that
|
||||
uses a hosted/specialized tool outside hook coverage is DOWNGRADED, not
|
||||
silently certified.
|
||||
|
||||
Output contract (kept portable across Codex versions, which is why the exact
|
||||
field mapping is a Wave-3 experiment): a decision JSON on stdout AND an exit
|
||||
code — 0 = allow, 2 = block/deny — so a host that reads either signal fails
|
||||
closed the same way. Field names are accepted defensively (tool_name|tool,
|
||||
cwd|project, session_id|session) to absorb payload drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
_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-codex"
|
||||
|
||||
|
||||
def integration_mode():
|
||||
# Managed/MDM deployments set this to managed_hook; default project_hook.
|
||||
return os.environ.get("CASAN_AGENTIC_INTEGRATION_MODE", "project_hook")
|
||||
|
||||
|
||||
def _first(payload, *keys):
|
||||
for k in keys:
|
||||
if payload.get(k) is not None:
|
||||
return payload.get(k)
|
||||
return None
|
||||
|
||||
|
||||
def _ptr_path(session):
|
||||
h = hashlib.sha256(("codex:" + (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, exit_code):
|
||||
sys.stdout.write(json.dumps(obj, ensure_ascii=False))
|
||||
sys.stdout.write("\n")
|
||||
return exit_code
|
||||
|
||||
|
||||
def handle_user_prompt_submit(payload):
|
||||
session = _first(payload, "session_id", "session", "conversation_id")
|
||||
resp = bridge.op_begin({
|
||||
"op": "begin",
|
||||
"client": "codex",
|
||||
"client_version": _first(payload, "client_version", "codex_version"),
|
||||
"adapter_version": ADAPTER_VERSION,
|
||||
"project": _first(payload, "cwd", "project", "project_dir", "workspace"),
|
||||
"session": session,
|
||||
"turn": _first(payload, "turn_id", "message_id"),
|
||||
"prompt": _first(payload, "prompt", "input", "message") or "",
|
||||
"integration_mode": integration_mode(),
|
||||
})
|
||||
if resp.get("admission_id"):
|
||||
store_pointer(session, resp["admission_id"], resp.get("trace_id"))
|
||||
if resp.get("decision") == "block":
|
||||
return _emit({"decision": "block", "reason": resp.get("reason"),
|
||||
"trace_id": resp.get("trace_id")}, 2)
|
||||
return _emit({"decision": "allow", "context": resp.get("context"),
|
||||
"certification_strength": resp.get("certification_strength"),
|
||||
"warnings": resp.get("warnings", [])}, 0)
|
||||
|
||||
|
||||
def handle_pre_tool_use(payload):
|
||||
session = _first(payload, "session_id", "session", "conversation_id")
|
||||
ptr = load_pointer(session)
|
||||
resp = bridge.op_pre_tool({
|
||||
"op": "pre-tool",
|
||||
"admission_id": (ptr or {}).get("admission_id") or "",
|
||||
"tool": _first(payload, "tool_name", "tool", "name") or "",
|
||||
"tool_input": _first(payload, "tool_input", "input", "arguments", "args"),
|
||||
"project": _first(payload, "cwd", "project", "workspace"),
|
||||
})
|
||||
if resp.get("decision") == "allow":
|
||||
return _emit({"decision": "allow", "reason": resp.get("reason")}, 0)
|
||||
return _emit({"decision": "deny", "reason": resp.get("reason"),
|
||||
"trace_id": resp.get("trace_id")}, 2)
|
||||
|
||||
|
||||
def handle_post_tool_use(payload):
|
||||
session = _first(payload, "session_id", "session", "conversation_id")
|
||||
ptr = load_pointer(session)
|
||||
if not ptr:
|
||||
return _emit({"decision": "allow", "reason": "no_admission"}, 0)
|
||||
status = _first(payload, "status") or ("error" if _first(payload, "error", "is_error") else "success")
|
||||
bridge.op_post_tool({
|
||||
"op": "post-tool",
|
||||
"admission_id": ptr.get("admission_id"),
|
||||
"tool": _first(payload, "tool_name", "tool", "name") or "",
|
||||
"status": status,
|
||||
"duration_ms": _first(payload, "duration_ms", "elapsed_ms"),
|
||||
"result": None,
|
||||
})
|
||||
# Opportunistic usage/cost capture — only when Codex actually supplies a
|
||||
# source. Absent a stable source, we record nothing (bridge keeps it null +
|
||||
# partial rather than inventing a number). See Spike-20 X6.
|
||||
usage = _first(payload, "usage")
|
||||
if isinstance(usage, dict) and usage.get("source"):
|
||||
bridge.op_telemetry({
|
||||
"op": "telemetry",
|
||||
"admission_id": ptr.get("admission_id"),
|
||||
"model": _first(payload, "model"),
|
||||
"input_tokens": usage.get("input_tokens"),
|
||||
"output_tokens": usage.get("output_tokens"),
|
||||
"cost_amount": usage.get("cost_amount"),
|
||||
"cost_currency": usage.get("cost_currency"),
|
||||
"cost_source": usage.get("source"),
|
||||
})
|
||||
return _emit({"decision": "allow"}, 0)
|
||||
|
||||
|
||||
def handle_stop(payload):
|
||||
if payload.get("stop_hook_active"):
|
||||
return _emit({"decision": "allow"}, 0)
|
||||
session = _first(payload, "session_id", "session", "conversation_id")
|
||||
ptr = load_pointer(session)
|
||||
if not ptr:
|
||||
return _emit({"decision": "allow", "reason": "no_admission"}, 0)
|
||||
resp = bridge.op_finalize({
|
||||
"op": "finalize",
|
||||
"admission_id": ptr.get("admission_id"),
|
||||
"stop_reason": _first(payload, "stop_reason") or "completed",
|
||||
"assistant_summary": _first(payload, "last_assistant_message", "assistant_summary"),
|
||||
})
|
||||
clear_pointer(session)
|
||||
return _emit({"decision": "allow", "certified": resp.get("decision") == "certified",
|
||||
"certification_strength": resp.get("certification_strength")}, 0)
|
||||
|
||||
|
||||
HANDLERS = {
|
||||
"UserPromptSubmit": handle_user_prompt_submit,
|
||||
"PreToolUse": handle_pre_tool_use,
|
||||
"PostToolUse": handle_post_tool_use,
|
||||
"Stop": handle_stop,
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="CASAN Codex hook adapter")
|
||||
parser.add_argument("--event", help="Codex 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 _first(payload, "hook_event_name", "event", "type")
|
||||
handler = HANDLERS.get(event)
|
||||
if handler is None:
|
||||
return _emit({"decision": "allow", "reason": "unknown_event"}, 0)
|
||||
try:
|
||||
return handler(payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if event == "PreToolUse":
|
||||
return _emit({"decision": "deny", "reason": "adapter_error:%s" % exc}, 2)
|
||||
return _emit({"decision": "allow", "reason": "adapter_error:%s" % exc}, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,18 @@
|
||||
# CASAN Plan-20 Codex config fragment (.codex/config.toml).
|
||||
# Merge these keys into the target repo's .codex/config.toml. This enables the
|
||||
# project hooks in hooks.template.json after Codex trust review.
|
||||
#
|
||||
# For ENTERPRISE enforcement, the managed policy path pins hooks so a member
|
||||
# cannot disable them (Spike-20 §4.2, Plan-20 Wave 3.3). In that deployment set
|
||||
# CASAN_AGENTIC_INTEGRATION_MODE=managed_hook via managed environment/MDM, not
|
||||
# in this committed file.
|
||||
|
||||
[hooks]
|
||||
enabled = true
|
||||
# project-local hooks load only after the user accepts the trust prompt.
|
||||
project_hooks = true
|
||||
|
||||
[casan]
|
||||
# Bridge feature flags — safe defaults (observe first, then enforce per Plan-20 §9).
|
||||
enforcement_mode = "observe" # observe | enforce
|
||||
integration_mode = "project_hook"
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"//": "CASAN Plan-20 Codex project hooks. Commit as .codex/hooks.json in the target repo. Codex loads project-local hooks ONLY after a trust review — run `casan doctor --client codex` to confirm the trust/onboarding state (Spike-20 §4.2). The exact key names are pinned during the Wave-3 Codex payload spike; the command contract (stdin JSON -> exit 0 allow / exit 2 block) is stable. No secrets or absolute paths here.",
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{ "command": ["python3", "packages/casan-harness/adapters/codex/codex_hook.py", "--event", "UserPromptSubmit"], "timeout_ms": 15000 }
|
||||
],
|
||||
"PreToolUse": [
|
||||
{ "matcher": "*", "command": ["python3", "packages/casan-harness/adapters/codex/codex_hook.py", "--event", "PreToolUse"], "timeout_ms": 15000 }
|
||||
],
|
||||
"PostToolUse": [
|
||||
{ "matcher": "*", "command": ["python3", "packages/casan-harness/adapters/codex/codex_hook.py", "--event", "PostToolUse"], "timeout_ms": 15000 }
|
||||
],
|
||||
"Stop": [
|
||||
{ "command": ["python3", "packages/casan-harness/adapters/codex/codex_hook.py", "--event", "Stop"], "timeout_ms": 15000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user