Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 9 additions & 12 deletions cycode/cli/apps/ai_guardrails/ides/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion tests/cli/commands/ai_guardrails/ides/test_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == {
Expand Down
54 changes: 54 additions & 0 deletions tests/cli/commands/ai_guardrails/scan/test_scan_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading