From 0b163a0f52819af3275c58448244b515ba356302 Mon Sep 17 00:00:00 2001 From: Nikita Fishbakh Date: Mon, 5 Oct 2026 12:32:00 +0200 Subject: [PATCH] CM-73962: Stop sending explicit allow decisions from the Codex hook Older Codex runtimes reject PreToolUse permissionDecision "allow" as unsupported and report a failed hook on every permitted MCP call. Return an empty response for allow instead; deny responses are unchanged. Co-Authored-By: Claude Opus 5.5 --- cycode/cli/apps/ai_guardrails/ides/codex.py | 21 ++++---- .../commands/ai_guardrails/ides/test_codex.py | 3 +- .../ai_guardrails/scan/test_scan_command.py | 54 +++++++++++++++++++ 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index 53ec4ec3..df0024b9 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -269,21 +269,18 @@ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: ) def build_hook_response(self, decision: HookDecision) -> dict: - # Codex accepts the same hook response shapes as Claude Code: - # - PROMPT: empty for allow, {"decision": "block", "reason": ...} for deny - # - PreToolUse: hookSpecificOutput.permissionDecision + # Codex hook response shapes: + # - allow: empty (no objection). Older Codex runtimes reject an explicit + # PreToolUse permissionDecision "allow" as unsupported and report a + # failed hook on every call, so never send it. + # - PROMPT deny: {"decision": "block", "reason": ...} + # - PreToolUse deny: hookSpecificOutput.permissionDecision + if decision.action == DecisionAction.ALLOW: + return {} + if decision.event_type == AiHookEventType.PROMPT: - if decision.action == DecisionAction.ALLOW: - return {} return {'decision': 'block', 'reason': decision.user_message or ''} - if decision.action == DecisionAction.ALLOW: - return { - 'hookSpecificOutput': { - 'hookEventName': 'PreToolUse', - 'permissionDecision': 'allow', - } - } return { 'hookSpecificOutput': { 'hookEventName': 'PreToolUse', diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index 889a3ea1..7a05b68e 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -97,7 +97,8 @@ def test_build_prompt_responses() -> None: def test_build_mcp_execution_allow_and_deny() -> None: codex = Codex() allow = codex.build_hook_response(HookDecision.allow(AiHookEventType.MCP_EXECUTION)) - assert allow == {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow'}} + # Explicit permissionDecision "allow" is rejected by older Codex runtimes. + assert allow == {} deny = codex.build_hook_response(HookDecision.deny(AiHookEventType.MCP_EXECUTION, 'secret in args!')) assert deny == { diff --git a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py index e60e6047..8acaf1ac 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -265,6 +265,60 @@ def test_copilot_cli_payload_skipped( assert json.loads(capsys.readouterr().out) == {} +class TestCodexMcpResponses: + """Codex PreToolUse responses for MCP calls through scan_command.""" + + _PAYLOAD = { # noqa: RUF012 + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-123', + 'turn_id': 'turn-1', + 'tool_name': 'mcp__grafana__query_loki_logs', + 'tool_input': {'logql': '{app="api"}'}, + } + + def test_permitted_mcp_call_returns_empty_response( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Older Codex runtimes reject permissionDecision "allow" as unsupported + and report a failed hook on every MCP call - allow must be empty.""" + mocker.patch('sys.stdin', StringIO(json.dumps(self._PAYLOAD))) + mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} + mock_scan_command_deps['get_handler'].return_value = MagicMock( + return_value=HookDecision.allow(AiHookEventType.MCP_EXECUTION) + ) + + scan_command(mock_ctx, ide='codex') + + assert json.loads(capsys.readouterr().out) == {} + + def test_blocked_mcp_call_returns_deny_with_reason( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + mocker.patch('sys.stdin', StringIO(json.dumps(self._PAYLOAD))) + mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} + mock_scan_command_deps['get_handler'].return_value = MagicMock( + return_value=HookDecision.deny(AiHookEventType.MCP_EXECUTION, 'Secret detected in MCP arguments') + ) + + scan_command(mock_ctx, ide='codex') + + assert json.loads(capsys.readouterr().out) == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'Secret detected in MCP arguments', + } + } + + class TestSelfDetach: """Report-mode scans respawn detached and release the IDE immediately."""