From a326d21ede3a7a0ba3a5049ec43860950fd2b042 Mon Sep 17 00:00:00 2001 From: Pablo LION Date: Wed, 25 Feb 2026 10:32:21 +0100 Subject: [PATCH] fix(claude_code): wrap hook output in hookSpecificOutput envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code's PreToolUse hook protocol requires responses wrapped in {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": ...}}. The previous implementation output permissionDecision at the top level, which Claude Code silently ignores — making all gait verdicts (allow, deny, ask) unenforceable regardless of policy. Fix wraps both emit_response() and the inline emit() function in the required envelope. Verified against Claude Code v2.1.47–v2.1.49 with all three verdict paths (allow, require_approval, block) in live interactive sessions. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CHANGELOG.md | 7 +++++++ examples/integrations/claude_code/gait-gate.sh | 14 ++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ee695a..d6293557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - _No unreleased entries yet._ +### Fixed + +- Fixed Claude Code hook integration (`gait-gate.sh`) to wrap hook responses in + the `hookSpecificOutput` envelope required by Claude Code's PreToolUse + protocol. Without this wrapper, Claude Code silently ignores hook responses, + making all gait verdicts (allow, deny, ask) unenforceable. + ### Changed - Gate intent normalization now treats omitted target `discovery_method` as `unknown` instead of empty so policies can deterministically match unknown/dynamic discovery paths. diff --git a/examples/integrations/claude_code/gait-gate.sh b/examples/integrations/claude_code/gait-gate.sh index 795bd7b9..b5f73d64 100755 --- a/examples/integrations/claude_code/gait-gate.sh +++ b/examples/integrations/claude_code/gait-gate.sh @@ -16,14 +16,15 @@ emit_response() { import json import os -payload = { +inner = { + "hookEventName": "PreToolUse", "permissionDecision": os.environ["DECISION"], "permissionDecisionReason": os.environ["REASON"], } trace_path = os.environ.get("TRACE_PATH", "").strip() if trace_path: - payload["tracePath"] = trace_path -print(json.dumps(payload)) + inner["tracePath"] = trace_path +print(json.dumps({"hookSpecificOutput": inner})) PY } @@ -60,13 +61,14 @@ strict_mode = os.environ.get("STRICT_MODE", "0").strip().lower() in {"1", "true" trace_path = os.environ.get("TRACE_PATH", "").strip() def emit(decision: str, reason: str) -> None: - payload = { + inner = { + "hookEventName": "PreToolUse", "permissionDecision": decision, "permissionDecisionReason": reason, } if trace_path: - payload["tracePath"] = trace_path - print(json.dumps(payload)) + inner["tracePath"] = trace_path + print(json.dumps({"hookSpecificOutput": inner})) try: decoded = json.loads(proxy_output) if proxy_output.strip() else {}