From 4c1c7abd336f7b1679e5a878e833364b5c8e931e Mon Sep 17 00:00:00 2001 From: Nanda Pranesh Date: Tue, 23 Jun 2026 16:42:01 +0530 Subject: [PATCH] fix(hooks): stop self-reporting benign BrokenPipeError to gateway (AI-GATEWAY-3J) The Claude Code / Codex / Copilot / Cursor hook scripts end main() by writing their JSON response to stdout via print(..., flush=True). When the host has already closed the read end of stdout (hook timeout, user cancel, session end, or a foreground PreToolUse approval-poll blocking up to 4h), the flush raises BrokenPipeError [Errno 32]. The broad `except Exception` caught it and self-reported it via log_error -> report_error_to_gateway -> /v1/hooks/errors -> Sentry, producing ~2200 noise events collapsed under one fingerprint. A secondary bug: the fallback print in the except re-hit the dead pipe and raised an UNHANDLED second BrokenPipeError (noisy non-zero exit). Fix (client-side only; the gateway is correct/fail-open and is untouched): - Add an _emit() helper that writes to stdout but treats a closed reader pipe as a benign no-op (swallows BrokenPipeError/OSError and redirects stdout to os.devnull so the interpreter-shutdown flush cannot re-raise). - Route every response write in main() through _emit(). - Split main()'s catch-all so `except BrokenPipeError` is intercepted first (benign, not reported) before `except Exception` (genuine errors are still reported and redacted via log_error). Cursor's WEB-4734 stderr redaction is preserved and guarded. Adds test_broken_pipe.py to all four hooks (12 tests): a broken pipe is not reported, real exceptions are still reported, _emit on a dead pipe does not raise. Follow-ups (out of scope): clamp the 4h foreground poll_approval_status that is the dominant trigger; add a CI job to execute these unittest files (static-checks currently runs only py_compile + pyflakes). Fixes AI-GATEWAY-3J Co-Authored-By: Claude Opus 4.8 (1M context) --- claude-code/hooks/test_broken_pipe.py | 86 ++++++++++++++++++++++++++ claude-code/hooks/unbound.py | 37 +++++++++--- codex/hooks/test_broken_pipe.py | 84 ++++++++++++++++++++++++++ codex/hooks/unbound.py | 35 ++++++++--- copilot/hooks/test_broken_pipe.py | 86 ++++++++++++++++++++++++++ copilot/hooks/unbound.py | 35 ++++++++--- cursor/test_broken_pipe.py | 87 +++++++++++++++++++++++++++ cursor/unbound.py | 50 +++++++++++---- 8 files changed, 465 insertions(+), 35 deletions(-) create mode 100644 claude-code/hooks/test_broken_pipe.py create mode 100644 codex/hooks/test_broken_pipe.py create mode 100644 copilot/hooks/test_broken_pipe.py create mode 100644 cursor/test_broken_pipe.py diff --git a/claude-code/hooks/test_broken_pipe.py b/claude-code/hooks/test_broken_pipe.py new file mode 100644 index 00000000..ac6a29ad --- /dev/null +++ b/claude-code/hooks/test_broken_pipe.py @@ -0,0 +1,86 @@ +""" +Tests for AI-GATEWAY-3J: a benign BrokenPipeError raised while emitting the +hook response (host closed the read end of stdout) must NOT be self-reported, +while genuine exceptions must still be reported. Also verifies _emit itself +swallows a dead pipe instead of crashing. +""" + +import io +import sys +import unittest +from unittest.mock import Mock, patch + +import unbound + + +class TestMainBrokenPipeNotReported(unittest.TestCase): + def test_broken_pipe_on_emit_is_not_reported(self): + # Empty stdin -> main() hits the first _emit; make that _emit raise a + # broken pipe. main() must catch it, NOT log, NOT report to gateway. + with patch.object(unbound, "_emit", side_effect=BrokenPipeError(32, "Broken pipe")), \ + patch.object(unbound, "report_error_to_gateway", Mock()) as report, \ + patch.object(unbound, "log_error", Mock()) as log, \ + patch.object(unbound, "get_api_key", lambda: "K"), \ + patch.object(unbound.sys, "stdin", io.StringIO("")): + try: + unbound.main() + except BrokenPipeError: + self.fail("main() let BrokenPipeError escape") + self.assertEqual(report.call_count, 0) + self.assertEqual(log.call_count, 0) + + +class TestMainRealExceptionStillReported(unittest.TestCase): + def test_real_exception_is_reported(self): + # A Stop event reaches append_to_audit_log; make that collaborator raise + # a genuine error. main() must report it via log_error with category + # 'general' and a message containing the original text. + event = '{"hook_event_name": "Stop", "session_id": "s"}' + with patch.object(unbound, "log_error", Mock()) as log, \ + patch.object(unbound, "append_to_audit_log", side_effect=RuntimeError("boom")), \ + patch.object(unbound, "get_api_key", lambda: "K"), \ + patch.object(unbound.sys, "stdin", io.StringIO(event)), \ + patch.object(unbound.sys, "stdout", io.StringIO()): + unbound.main() + self.assertEqual(log.call_count, 1) + args, _ = log.call_args + self.assertIn("Exception in main: boom", args[0]) + self.assertEqual(args[1], "general") + + +class TestEmitDeadPipe(unittest.TestCase): + def setUp(self): + self._real_stdout = sys.stdout + + def tearDown(self): + swapped = unbound.sys.stdout + sys.stdout = self._real_stdout + if swapped is not self._real_stdout: + try: + swapped.close() + except Exception: + pass + + def test_emit_to_dead_pipe_does_not_raise(self): + class DeadPipe: + def write(self, _): + raise BrokenPipeError(32, "Broken pipe") + + def flush(self): + raise BrokenPipeError(32, "Broken pipe") + + unbound.sys.stdout = DeadPipe() + try: + unbound._emit("{}") + except Exception as e: + self.fail(f"_emit raised on dead pipe: {e!r}") + # _emit swapped stdout to a working sink, so a second emit is also safe. + self.assertNotIsInstance(unbound.sys.stdout, DeadPipe) + try: + unbound._emit("{}") + except Exception as e: + self.fail(f"second _emit raised after swap: {e!r}") + + +if __name__ == "__main__": + unittest.main() diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 9da4d39e..b0fca678 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -150,6 +150,20 @@ def log_error(message: str, category: str = 'general'): report_error_to_gateway(message, category, _cached_api_key) +def _emit(text): + """Write a hook response line to stdout, treating a closed reader pipe as + a benign no-op. The host may close the read end (timeout, cancel, session + end, blocked approval-poll) before we flush — that is not a hook error.""" + try: + sys.stdout.write(text + "\n") + sys.stdout.flush() + except (BrokenPipeError, OSError): + try: + sys.stdout = open(os.devnull, "w") + except Exception: + pass + + def _read_policy_cache_raw() -> Optional[Dict]: """Read and JSON-parse the policy cache file. Returns None on missing/corrupt.""" try: @@ -1659,13 +1673,13 @@ def main(): input_data = sys.stdin.read().strip() if not input_data: - print('{"suppressOutput": true}', flush=True) + _emit('{"suppressOutput": true}') return try: event = json.loads(input_data) except json.JSONDecodeError: - print('{"suppressOutput": true}', flush=True) + _emit('{"suppressOutput": true}') return hook_event_name = event.get('hook_event_name') @@ -1676,7 +1690,7 @@ def main(): _device_serial() # warm the (slow) serial probe + cache once per session _check_self_update() _dispatch_discovery() - print("{}") + _emit("{}") return session_id = event.get('session_id') @@ -1684,7 +1698,7 @@ def main(): if hook_event_name == 'PreToolUse': response = process_pre_tool_use(event, api_key) response["suppressOutput"] = True - print(json.dumps(response), flush=True) + _emit(json.dumps(response)) return # Handle UserPromptSubmit - check policy before processing @@ -1699,7 +1713,7 @@ def main(): 'event': event }) response["suppressOutput"] = True - print(json.dumps(response), flush=True) + _emit(json.dumps(response)) return # If allowed, continue to log the event (output printed at end) @@ -1718,12 +1732,19 @@ def main(): cleanup_old_logs() - print('{"suppressOutput": true}', flush=True) - + _emit('{"suppressOutput": true}') + + except BrokenPipeError: + # Host closed the read end of our stdout pipe (timeout / cancel / + # session end / blocked approval-poll). Benign — do not self-report. + try: + sys.stdout = open(os.devnull, "w") + except Exception: + pass except Exception as e: # Still return empty JSON object to Claude Code to indicate completion log_error(f"Exception in main: {str(e)}", 'general') - print('{"suppressOutput": true}', flush=True) + _emit('{"suppressOutput": true}') if __name__ == '__main__': diff --git a/codex/hooks/test_broken_pipe.py b/codex/hooks/test_broken_pipe.py new file mode 100644 index 00000000..4d28f97b --- /dev/null +++ b/codex/hooks/test_broken_pipe.py @@ -0,0 +1,84 @@ +""" +Tests for AI-GATEWAY-3J: a benign BrokenPipeError raised while emitting the +hook response (host closed the read end of stdout) must NOT be self-reported, +while genuine exceptions must still be reported. Also verifies _emit itself +swallows a dead pipe instead of crashing. +""" + +import io +import sys +import unittest +from unittest.mock import Mock, patch + +import unbound + + +class TestMainBrokenPipeNotReported(unittest.TestCase): + def test_broken_pipe_on_emit_is_not_reported(self): + # Empty stdin -> main() hits the first _emit; make that _emit raise a + # broken pipe. main() must catch it, NOT log, NOT report to gateway. + with patch.object(unbound, "_emit", side_effect=BrokenPipeError(32, "Broken pipe")), \ + patch.object(unbound, "report_error_to_gateway", Mock()) as report, \ + patch.object(unbound, "log_error", Mock()) as log, \ + patch.object(unbound.sys, "stdin", io.StringIO("")): + try: + unbound.main() + except BrokenPipeError: + self.fail("main() let BrokenPipeError escape") + self.assertEqual(report.call_count, 0) + self.assertEqual(log.call_count, 0) + + +class TestMainRealExceptionStillReported(unittest.TestCase): + def test_real_exception_is_reported(self): + # A Stop event reaches append_to_audit_log; make that collaborator raise + # a genuine error. main() must report it via log_error with category + # 'general' and a message containing the original text. + event = '{"hook_event_name": "Stop", "session_id": "s"}' + with patch.object(unbound, "log_error", Mock()) as log, \ + patch.object(unbound, "append_to_audit_log", side_effect=RuntimeError("boom")), \ + patch.object(unbound.sys, "stdin", io.StringIO(event)), \ + patch.object(unbound.sys, "stdout", io.StringIO()): + unbound.main() + self.assertEqual(log.call_count, 1) + args, _ = log.call_args + self.assertIn("Exception in main: boom", args[0]) + self.assertEqual(args[1], "general") + + +class TestEmitDeadPipe(unittest.TestCase): + def setUp(self): + self._real_stdout = sys.stdout + + def tearDown(self): + swapped = unbound.sys.stdout + sys.stdout = self._real_stdout + if swapped is not self._real_stdout: + try: + swapped.close() + except Exception: + pass + + def test_emit_to_dead_pipe_does_not_raise(self): + class DeadPipe: + def write(self, _): + raise BrokenPipeError(32, "Broken pipe") + + def flush(self): + raise BrokenPipeError(32, "Broken pipe") + + unbound.sys.stdout = DeadPipe() + try: + unbound._emit("{}") + except Exception as e: + self.fail(f"_emit raised on dead pipe: {e!r}") + # _emit swapped stdout to a working sink, so a second emit is also safe. + self.assertNotIsInstance(unbound.sys.stdout, DeadPipe) + try: + unbound._emit("{}") + except Exception as e: + self.fail(f"second _emit raised after swap: {e!r}") + + +if __name__ == "__main__": + unittest.main() diff --git a/codex/hooks/unbound.py b/codex/hooks/unbound.py index c0a52526..4540e9fc 100644 --- a/codex/hooks/unbound.py +++ b/codex/hooks/unbound.py @@ -153,6 +153,20 @@ def log_error(message: str, category: str = 'general'): report_error_to_gateway(message, category, _cached_api_key) +def _emit(text): + """Write a hook response line to stdout, treating a closed reader pipe as + a benign no-op. The host may close the read end (timeout, cancel, session + end, blocked approval-poll) before we flush — that is not a hook error.""" + try: + sys.stdout.write(text + "\n") + sys.stdout.flush() + except (BrokenPipeError, OSError): + try: + sys.stdout = open(os.devnull, "w") + except Exception: + pass + + def _read_policy_cache_raw() -> Optional[Dict]: """Read and JSON-parse the policy cache file. Returns None on missing/corrupt.""" try: @@ -1570,13 +1584,13 @@ def main(): input_data = sys.stdin.read().strip() if not input_data: - print('{"suppressOutput": true}', flush=True) + _emit('{"suppressOutput": true}') return try: event = json.loads(input_data) except json.JSONDecodeError: - print('{"suppressOutput": true}', flush=True) + _emit('{"suppressOutput": true}') return hook_event_name = event.get('hook_event_name') @@ -1586,7 +1600,7 @@ def main(): if hook_event_name == "SessionStart": _check_self_update() _dispatch_discovery() - print("{}") + _emit("{}") return session_id = event.get('session_id') @@ -1594,7 +1608,7 @@ def main(): # Note: Codex PreToolUse does not support suppressOutput if hook_event_name == 'PreToolUse': response = process_pre_tool_use(event, api_key) - print(json.dumps(response), flush=True) + _emit(json.dumps(response)) return # Handle UserPromptSubmit - check policy before processing @@ -1609,7 +1623,7 @@ def main(): 'event': event }) response["suppressOutput"] = True - print(json.dumps(response), flush=True) + _emit(json.dumps(response)) return # If allowed, continue to log the event (output printed at end) @@ -1628,12 +1642,19 @@ def main(): cleanup_old_logs() - print('{"suppressOutput": true}', flush=True) + _emit('{"suppressOutput": true}') + except BrokenPipeError: + # Host closed the read end of our stdout pipe (timeout / cancel / + # session end / blocked approval-poll). Benign — do not self-report. + try: + sys.stdout = open(os.devnull, "w") + except Exception: + pass except Exception as e: # Still return empty JSON object to Codex to indicate completion log_error(f"Exception in main: {str(e)}", 'general') - print('{"suppressOutput": true}', flush=True) + _emit('{"suppressOutput": true}') if __name__ == '__main__': diff --git a/copilot/hooks/test_broken_pipe.py b/copilot/hooks/test_broken_pipe.py new file mode 100644 index 00000000..ac6a29ad --- /dev/null +++ b/copilot/hooks/test_broken_pipe.py @@ -0,0 +1,86 @@ +""" +Tests for AI-GATEWAY-3J: a benign BrokenPipeError raised while emitting the +hook response (host closed the read end of stdout) must NOT be self-reported, +while genuine exceptions must still be reported. Also verifies _emit itself +swallows a dead pipe instead of crashing. +""" + +import io +import sys +import unittest +from unittest.mock import Mock, patch + +import unbound + + +class TestMainBrokenPipeNotReported(unittest.TestCase): + def test_broken_pipe_on_emit_is_not_reported(self): + # Empty stdin -> main() hits the first _emit; make that _emit raise a + # broken pipe. main() must catch it, NOT log, NOT report to gateway. + with patch.object(unbound, "_emit", side_effect=BrokenPipeError(32, "Broken pipe")), \ + patch.object(unbound, "report_error_to_gateway", Mock()) as report, \ + patch.object(unbound, "log_error", Mock()) as log, \ + patch.object(unbound, "get_api_key", lambda: "K"), \ + patch.object(unbound.sys, "stdin", io.StringIO("")): + try: + unbound.main() + except BrokenPipeError: + self.fail("main() let BrokenPipeError escape") + self.assertEqual(report.call_count, 0) + self.assertEqual(log.call_count, 0) + + +class TestMainRealExceptionStillReported(unittest.TestCase): + def test_real_exception_is_reported(self): + # A Stop event reaches append_to_audit_log; make that collaborator raise + # a genuine error. main() must report it via log_error with category + # 'general' and a message containing the original text. + event = '{"hook_event_name": "Stop", "session_id": "s"}' + with patch.object(unbound, "log_error", Mock()) as log, \ + patch.object(unbound, "append_to_audit_log", side_effect=RuntimeError("boom")), \ + patch.object(unbound, "get_api_key", lambda: "K"), \ + patch.object(unbound.sys, "stdin", io.StringIO(event)), \ + patch.object(unbound.sys, "stdout", io.StringIO()): + unbound.main() + self.assertEqual(log.call_count, 1) + args, _ = log.call_args + self.assertIn("Exception in main: boom", args[0]) + self.assertEqual(args[1], "general") + + +class TestEmitDeadPipe(unittest.TestCase): + def setUp(self): + self._real_stdout = sys.stdout + + def tearDown(self): + swapped = unbound.sys.stdout + sys.stdout = self._real_stdout + if swapped is not self._real_stdout: + try: + swapped.close() + except Exception: + pass + + def test_emit_to_dead_pipe_does_not_raise(self): + class DeadPipe: + def write(self, _): + raise BrokenPipeError(32, "Broken pipe") + + def flush(self): + raise BrokenPipeError(32, "Broken pipe") + + unbound.sys.stdout = DeadPipe() + try: + unbound._emit("{}") + except Exception as e: + self.fail(f"_emit raised on dead pipe: {e!r}") + # _emit swapped stdout to a working sink, so a second emit is also safe. + self.assertNotIsInstance(unbound.sys.stdout, DeadPipe) + try: + unbound._emit("{}") + except Exception as e: + self.fail(f"second _emit raised after swap: {e!r}") + + +if __name__ == "__main__": + unittest.main() diff --git a/copilot/hooks/unbound.py b/copilot/hooks/unbound.py index 8c2469ed..bc895b34 100644 --- a/copilot/hooks/unbound.py +++ b/copilot/hooks/unbound.py @@ -216,6 +216,20 @@ def log_error(message, category='general'): report_error_to_gateway(message, category, _cached_api_key) +def _emit(text): + """Write a hook response line to stdout, treating a closed reader pipe as + a benign no-op. The host may close the read end (timeout, cancel, session + end, blocked approval-poll) before we flush — that is not a hook error.""" + try: + sys.stdout.write(text + "\n") + sys.stdout.flush() + except (BrokenPipeError, OSError): + try: + sys.stdout = open(os.devnull, "w") + except Exception: + pass + + def _read_policy_cache_raw(): """Read and JSON-parse the policy cache file. Returns None on missing/corrupt.""" try: @@ -1695,13 +1709,13 @@ def main(): input_data = sys.stdin.read().strip() if not input_data: - print("{}") + _emit("{}") return try: event = json.loads(input_data) except json.JSONDecodeError: - print("{}") + _emit("{}") return event_name = event.get('hook_event_name') or event.get('hookEventName') @@ -1711,12 +1725,12 @@ def main(): if event_name == 'SessionStart': _check_self_update() _dispatch_discovery() - print("{}") + _emit("{}") return if event_name in ('PreToolUse', 'preToolUse'): response = process_pre_tool_use(event, api_key) - print(json.dumps(response), flush=True) + _emit(json.dumps(response)) return if event_name == 'UserPromptSubmit': @@ -1726,7 +1740,7 @@ def main(): 'timestamp': datetime.now().astimezone().isoformat().replace('+00:00', 'Z'), 'event': event, }) - print(json.dumps(response), flush=True) + _emit(json.dumps(response)) return # Create log entry with timestamp; the event already carries hook_event_name @@ -1753,12 +1767,19 @@ def main(): cleanup_old_logs() # Output required by Copilot hooks - print("{}") + _emit("{}") + except BrokenPipeError: + # Host closed the read end of our stdout pipe (timeout / cancel / + # session end / blocked approval-poll). Benign — do not self-report. + try: + sys.stdout = open(os.devnull, "w") + except Exception: + pass except Exception as e: # Log errors but still output {} to not break Copilot log_error(f"Exception in main: {str(e)}", 'general') - print("{}") + _emit("{}") if __name__ == '__main__': diff --git a/cursor/test_broken_pipe.py b/cursor/test_broken_pipe.py new file mode 100644 index 00000000..7c2a8e44 --- /dev/null +++ b/cursor/test_broken_pipe.py @@ -0,0 +1,87 @@ +""" +Tests for AI-GATEWAY-3J: a benign BrokenPipeError raised while emitting the +hook response (host closed the read end of stdout) must NOT be self-reported, +while genuine exceptions must still be reported. Also verifies _emit itself +swallows a dead pipe instead of crashing. +""" + +import io +import sys +import unittest +from unittest.mock import Mock, patch + +import unbound + + +class TestMainBrokenPipeNotReported(unittest.TestCase): + def test_broken_pipe_on_emit_is_not_reported(self): + # Empty stdin -> main() hits the first _emit; make that _emit raise a + # broken pipe. main() must catch it, NOT log, NOT report to gateway. + with patch.object(unbound, "_emit", side_effect=BrokenPipeError(32, "Broken pipe")), \ + patch.object(unbound, "report_error_to_gateway", Mock()) as report, \ + patch.object(unbound, "log_error", Mock()) as log, \ + patch.object(unbound, "get_api_key", lambda: "K"), \ + patch.object(unbound.sys, "stdin", io.StringIO("")): + try: + unbound.main() + except BrokenPipeError: + self.fail("main() let BrokenPipeError escape") + self.assertEqual(report.call_count, 0) + self.assertEqual(log.call_count, 0) + + +class TestMainRealExceptionStillReported(unittest.TestCase): + def test_real_exception_is_reported(self): + # A stop event reaches append_to_audit_log; make that collaborator raise + # a genuine error. main() must report it via log_error with category + # 'general' and a message containing the original text. + event = '{"hook_event_name": "stop", "generation_id": "g"}' + with patch.object(unbound, "log_error", Mock()) as log, \ + patch.object(unbound, "append_to_audit_log", side_effect=RuntimeError("boom")), \ + patch.object(unbound, "get_api_key", lambda: "K"), \ + patch.object(unbound.sys, "stdin", io.StringIO(event)), \ + patch.object(unbound.sys, "stdout", io.StringIO()), \ + patch.object(unbound.sys, "stderr", io.StringIO()): + unbound.main() + self.assertEqual(log.call_count, 1) + args, _ = log.call_args + self.assertIn("Exception in main: boom", args[0]) + self.assertEqual(args[1], "general") + + +class TestEmitDeadPipe(unittest.TestCase): + def setUp(self): + self._real_stdout = sys.stdout + + def tearDown(self): + swapped = unbound.sys.stdout + sys.stdout = self._real_stdout + if swapped is not self._real_stdout: + try: + swapped.close() + except Exception: + pass + + def test_emit_to_dead_pipe_does_not_raise(self): + class DeadPipe: + def write(self, _): + raise BrokenPipeError(32, "Broken pipe") + + def flush(self): + raise BrokenPipeError(32, "Broken pipe") + + unbound.sys.stdout = DeadPipe() + try: + unbound._emit("{}") + except Exception as e: + self.fail(f"_emit raised on dead pipe: {e!r}") + # _emit swapped stdout to a working sink, so a second emit is also safe. + self.assertNotIsInstance(unbound.sys.stdout, DeadPipe) + try: + unbound._emit("{}") + except Exception as e: + self.fail(f"second _emit raised after swap: {e!r}") + + +if __name__ == "__main__": + unittest.main() diff --git a/cursor/unbound.py b/cursor/unbound.py index 5b22021d..9eb4966f 100644 --- a/cursor/unbound.py +++ b/cursor/unbound.py @@ -169,6 +169,20 @@ def log_error(message, category='general'): report_error_to_gateway(message, category, _cached_api_key) +def _emit(text): + """Write a hook response line to stdout, treating a closed reader pipe as + a benign no-op. The host may close the read end (timeout, cancel, session + end, blocked approval-poll) before we flush — that is not a hook error.""" + try: + sys.stdout.write(text + "\n") + sys.stdout.flush() + except (BrokenPipeError, OSError): + try: + sys.stdout = open(os.devnull, "w") + except Exception: + pass + + def _read_policy_cache_raw(): """Read and JSON-parse the policy cache file. Returns None on missing/corrupt.""" try: @@ -1649,14 +1663,14 @@ def main(): input_data = sys.stdin.read().strip() if not input_data: - print("{}") + _emit("{}") return - + # Parse the event try: event = json.loads(input_data) except json.JSONDecodeError: - print("{}") + _emit("{}") return # Get event details @@ -1668,14 +1682,14 @@ def main(): _device_serial() # warm the (slow) serial probe + cache once per session _check_self_update() _dispatch_discovery() - print("{}") + _emit("{}") return generation_id = event.get('generation_id') conversation_id = event.get('conversation_id') if hook_event_name == 'preToolUse': response = process_pre_tool_use(event, api_key) - print(json.dumps(response), flush=True) + _emit(json.dumps(response)) if response.get('permission') == 'deny': handle_deny_and_exit() return @@ -1683,7 +1697,7 @@ def main(): # Handle beforeShellExecution / beforeMCPExecution - check policy before execution if hook_event_name == 'beforeShellExecution': response = process_pre_tool_use_execution(event, api_key, 'Shell', event.get('command', '')) - print(json.dumps(response), flush=True) + _emit(json.dumps(response)) if response.get('permission') == 'deny': handle_deny_and_exit() return @@ -1696,7 +1710,7 @@ def main(): event, api_key, f'MCP:{mcp_tool_name}', json.dumps(event.get('tool_input') or {}), mcp_server=mcp_server, mcp_tool=mcp_tool_name ) - print(json.dumps(response), flush=True) + _emit(json.dumps(response)) if response.get('permission') == 'deny': handle_deny_and_exit() return @@ -1715,7 +1729,7 @@ def main(): 'continue': False, 'user_message': response.get('reason', 'Prompt blocked by policy') } - print(json.dumps(cursor_response), flush=True) + _emit(json.dumps(cursor_response)) sys.exit(2) # Create log entry with timestamp @@ -1742,14 +1756,24 @@ def main(): cleanup_old_logs() # Output required by Cursor hooks - print("{}") - + _emit("{}") + + except BrokenPipeError: + # Host closed the read end of our stdout pipe (timeout / cancel / + # session end / blocked approval-poll). Benign — do not self-report. + try: + sys.stdout = open(os.devnull, "w") + except Exception: + pass except Exception as e: # Log errors but still output {} to not break Cursor log_error(f"Exception in main: {str(e)}", 'general') - print("{}", file=sys.stderr) - print(f"Error: {redact_secrets(str(e), _cached_api_key)}", file=sys.stderr) - print("{}") + try: + print("{}", file=sys.stderr) + print(f"Error: {redact_secrets(str(e), _cached_api_key)}", file=sys.stderr) + except (BrokenPipeError, OSError): + pass + _emit("{}") if __name__ == '__main__':