Skip to content

Commit 220829c

Browse files
MaorDavidzonclaude
andcommitted
CM-62984: Add Codex CLI support to ai-guardrails
Extend ai-guardrails hooks to cover OpenAI Codex CLI alongside Cursor and Claude Code. Installs ~/.codex/hooks.json for UserPromptSubmit, SessionStart, and PreToolUse:Bash events, and merges `[features] codex_hooks = true` into ~/.codex/config.toml while preserving existing keys. Adds a new canonical CommandExec event for Bash command scanning since Codex's PreToolUse only intercepts Bash today. CodexResponseBuilder reuses the Claude Code response shapes (Codex accepts them verbatim). Adds tomli-w (and tomli on py<3.11) as direct deps to manage the Codex TOML config safely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e1f5eb7 commit 220829c

15 files changed

Lines changed: 655 additions & 3 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Codex CLI config.toml management for AI guardrails.
2+
3+
Codex requires `[features] codex_hooks = true` in its `config.toml` for hook
4+
scripts to be invoked. This module merges that flag into the user-scope
5+
(`~/.codex/config.toml`) or repo-scope (`<repo>/.codex/config.toml`) file while
6+
preserving any existing keys.
7+
"""
8+
9+
import sys
10+
from pathlib import Path
11+
from typing import Optional
12+
13+
import tomli_w
14+
15+
if sys.version_info >= (3, 11):
16+
import tomllib
17+
else: # pragma: no cover - py<3.11 fallback
18+
import tomli as tomllib
19+
20+
from cycode.logger import get_logger
21+
22+
logger = get_logger('AI Guardrails Codex Config')
23+
24+
CODEX_CONFIG_FILE_NAME = 'config.toml'
25+
CODEX_CONFIG_DIR_NAME = '.codex'
26+
27+
28+
def get_codex_config_path(scope: str, repo_path: Optional[Path] = None) -> Path:
29+
"""Get the Codex config.toml path for the given scope."""
30+
if scope == 'repo' and repo_path:
31+
return repo_path / CODEX_CONFIG_DIR_NAME / CODEX_CONFIG_FILE_NAME
32+
return Path.home() / CODEX_CONFIG_DIR_NAME / CODEX_CONFIG_FILE_NAME
33+
34+
35+
def enable_codex_hooks_feature(scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]:
36+
"""Ensure `[features] codex_hooks = true` is set in Codex's config.toml.
37+
38+
Preserves existing keys. Creates the file (and parent dir) if missing.
39+
40+
Returns:
41+
Tuple of (success, message).
42+
"""
43+
config_path = get_codex_config_path(scope, repo_path)
44+
45+
config: dict = {}
46+
if config_path.exists():
47+
try:
48+
with config_path.open('rb') as f:
49+
config = tomllib.load(f)
50+
except Exception as e:
51+
logger.error('Failed to parse Codex config.toml', exc_info=e)
52+
return False, f'Failed to parse existing Codex config at {config_path}'
53+
54+
features = config.get('features')
55+
if not isinstance(features, dict):
56+
features = {}
57+
features['codex_hooks'] = True
58+
config['features'] = features
59+
60+
try:
61+
config_path.parent.mkdir(parents=True, exist_ok=True)
62+
with config_path.open('wb') as f:
63+
tomli_w.dump(config, f)
64+
return True, f'Enabled codex_hooks in {config_path}'
65+
except Exception as e:
66+
logger.error('Failed to write Codex config.toml', exc_info=e)
67+
return False, f'Failed to write Codex config at {config_path}'

cycode/cli/apps/ai_guardrails/consts.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Currently supports:
44
- Cursor
55
- Claude Code
6+
- Codex
67
"""
78

89
import platform
@@ -17,6 +18,7 @@ class AIIDEType(str, Enum):
1718

1819
CURSOR = 'cursor'
1920
CLAUDE_CODE = 'claude-code'
21+
CODEX = 'codex'
2022

2123

2224
class PolicyMode(str, Enum):
@@ -61,6 +63,14 @@ def _get_claude_code_hooks_dir() -> Path:
6163
return Path.home() / '.claude'
6264

6365

66+
def _get_codex_hooks_dir() -> Path:
67+
"""Get Codex hooks directory.
68+
69+
Codex uses ~/.codex on all platforms.
70+
"""
71+
return Path.home() / '.codex'
72+
73+
6474
# IDE-specific configurations
6575
IDE_CONFIGS: dict[AIIDEType, IDEConfig] = {
6676
AIIDEType.CURSOR: IDEConfig(
@@ -77,6 +87,13 @@ def _get_claude_code_hooks_dir() -> Path:
7787
hooks_file_name='settings.json',
7888
hook_events=['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'],
7989
),
90+
AIIDEType.CODEX: IDEConfig(
91+
name='Codex',
92+
hooks_dir=_get_codex_hooks_dir(),
93+
repo_hooks_subdir='.codex',
94+
hooks_file_name='hooks.json',
95+
hook_events=['UserPromptSubmit', 'PreToolUse:Bash'],
96+
),
8097
}
8198

8299
# Default IDE
@@ -141,6 +158,47 @@ def _get_claude_code_hooks_config(async_mode: bool = False) -> dict:
141158
}
142159

143160

161+
def _get_codex_hooks_config(async_mode: bool = False) -> dict:
162+
"""Get Codex-specific hooks configuration.
163+
164+
Codex uses the same nested hook-entry format as Claude Code:
165+
- events are keyed by name
166+
- each entry has an optional 'matcher' (regex on tool name / start source)
167+
and a 'hooks' array with {type, command, ...}
168+
169+
Codex only supports intercepting Bash for PreToolUse today; MCP and file
170+
reads are not exposed to hooks.
171+
"""
172+
command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide {AIIDEType.CODEX.value}'
173+
174+
hook_entry = {'type': 'command', 'command': command}
175+
if async_mode:
176+
hook_entry['async'] = True
177+
hook_entry['timeout'] = 20
178+
179+
return {
180+
'hooks': {
181+
'SessionStart': [
182+
{
183+
'matcher': 'startup',
184+
'hooks': [{'type': 'command', 'command': CYCODE_ENSURE_AUTH_COMMAND}],
185+
}
186+
],
187+
'UserPromptSubmit': [
188+
{
189+
'hooks': [deepcopy(hook_entry)],
190+
}
191+
],
192+
'PreToolUse': [
193+
{
194+
'matcher': 'Bash',
195+
'hooks': [deepcopy(hook_entry)],
196+
},
197+
],
198+
},
199+
}
200+
201+
144202
def get_hooks_config(ide: AIIDEType, async_mode: bool = False) -> dict:
145203
"""Get the hooks configuration for a specific IDE.
146204
@@ -153,4 +211,6 @@ def get_hooks_config(ide: AIIDEType, async_mode: bool = False) -> dict:
153211
"""
154212
if ide == AIIDEType.CLAUDE_CODE:
155213
return _get_claude_code_hooks_config(async_mode=async_mode)
214+
if ide == AIIDEType.CODEX:
215+
return _get_codex_hooks_config(async_mode=async_mode)
156216
return _get_cursor_hooks_config(async_mode=async_mode)

cycode/cli/apps/ai_guardrails/install_command.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import typer
77

8+
from cycode.cli.apps.ai_guardrails.codex_config import enable_codex_hooks_feature
89
from cycode.cli.apps.ai_guardrails.command_utils import (
910
console,
1011
resolve_repo_path,
@@ -29,7 +30,7 @@ def install_command(
2930
str,
3031
typer.Option(
3132
'--ide',
32-
help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.',
33+
help='IDE to install hooks for ("cursor", "claude-code", "codex", or "all"). Defaults to cursor.',
3334
),
3435
] = AIIDEType.CURSOR.value,
3536
repo_path: Annotated[
@@ -74,11 +75,14 @@ def install_command(
7475
ides_to_install: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type]
7576

7677
results: list[tuple[str, bool, str]] = []
78+
extra_messages: list[tuple[bool, str]] = []
7779
for current_ide in ides_to_install:
7880
ide_name = IDE_CONFIGS[current_ide].name
7981
report_mode = mode == InstallMode.REPORT
8082
success, message = install_hooks(scope, repo_path, ide=current_ide, report_mode=report_mode)
8183
results.append((ide_name, success, message))
84+
if success and current_ide == AIIDEType.CODEX:
85+
extra_messages.append(enable_codex_hooks_feature(scope, repo_path))
8286

8387
# Report results for each IDE
8488
any_success = False
@@ -91,6 +95,13 @@ def install_command(
9195
console.print(f'[red]✗[/] {message}', style='bold red')
9296
all_success = False
9397

98+
for success, message in extra_messages:
99+
if success:
100+
console.print(f'[green]✓[/] {message}')
101+
else:
102+
console.print(f'[red]✗[/] {message}', style='bold red')
103+
all_success = False
104+
94105
if any_success:
95106
policy_mode = PolicyMode.WARN if mode == InstallMode.REPORT else PolicyMode.BLOCK
96107
_install_policy(scope, repo_path, policy_mode)

cycode/cli/apps/ai_guardrails/scan/consts.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,9 @@
4545
'action': 'block',
4646
'scan_arguments': True,
4747
},
48+
'command_exec': {
49+
'enabled': True,
50+
'action': 'block',
51+
'scan_arguments': True,
52+
},
4853
}

cycode/cli/apps/ai_guardrails/scan/handlers.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,73 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
259259
)
260260

261261

262+
def handle_before_command_exec(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict:
263+
"""
264+
Handle PreToolUse:Bash (CommandExec) hook.
265+
266+
Scans the shell command the agent is about to run for secrets before
267+
execution. Returns deny_permission to block, ask_permission to warn,
268+
allow_permission to allow.
269+
"""
270+
ai_client = ctx.obj['ai_security_client']
271+
ide = payload.ide_provider
272+
response_builder = get_response_builder(ide)
273+
274+
command_config = get_policy_value(policy, 'command_exec', default={})
275+
ai_client.create_conversation(payload)
276+
if not get_policy_value(command_config, 'enabled', default=True):
277+
ai_client.create_event(payload, AiHookEventType.COMMAND_EXEC, AIHookOutcome.ALLOWED)
278+
return response_builder.allow_permission()
279+
280+
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
281+
command = payload.command or ''
282+
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
283+
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
284+
clipped = truncate_utf8(command, max_bytes)
285+
action = get_policy_value(command_config, 'action', default=PolicyMode.BLOCK)
286+
287+
scan_id = None
288+
block_reason = None
289+
outcome = AIHookOutcome.ALLOWED
290+
error_message = None
291+
292+
try:
293+
if get_policy_value(command_config, 'scan_arguments', default=True):
294+
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
295+
if violation_summary:
296+
block_reason = BlockReason.SECRETS_IN_COMMAND
297+
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
298+
outcome = AIHookOutcome.BLOCKED
299+
user_message = f'Cycode blocked shell command execution. {violation_summary}'
300+
return response_builder.deny_permission(
301+
user_message,
302+
'Do not embed secrets in shell commands. Use environment variables or secret references.',
303+
)
304+
outcome = AIHookOutcome.WARNED
305+
return response_builder.ask_permission(
306+
f'{violation_summary} in shell command. Allow execution?',
307+
'Possible secrets detected in command; proceed with caution.',
308+
)
309+
310+
return response_builder.allow_permission()
311+
except Exception as e:
312+
outcome = (
313+
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
314+
)
315+
block_reason = BlockReason.SCAN_FAILURE
316+
error_message = str(e)
317+
raise e
318+
finally:
319+
ai_client.create_event(
320+
payload,
321+
AiHookEventType.COMMAND_EXEC,
322+
outcome,
323+
scan_id=scan_id,
324+
block_reason=block_reason,
325+
error_message=error_message,
326+
)
327+
328+
262329
def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context, AIHookPayload, dict], dict]]:
263330
"""Get the appropriate handler function for a canonical event type.
264331
@@ -272,6 +339,7 @@ def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context,
272339
AiHookEventType.PROMPT.value: handle_before_submit_prompt,
273340
AiHookEventType.FILE_READ.value: handle_before_read_file,
274341
AiHookEventType.MCP_EXECUTION.value: handle_before_mcp_execution,
342+
AiHookEventType.COMMAND_EXEC.value: handle_before_command_exec,
275343
}
276344
return handlers.get(event_type)
277345

cycode/cli/apps/ai_guardrails/scan/payload.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from cycode.cli.apps.ai_guardrails.scan.types import (
1212
CLAUDE_CODE_EVENT_MAPPING,
1313
CLAUDE_CODE_EVENT_NAMES,
14+
CODEX_EVENT_MAPPING,
15+
CODEX_EVENT_NAMES,
1416
CURSOR_EVENT_MAPPING,
1517
CURSOR_EVENT_NAMES,
1618
AiHookEventType,
@@ -139,6 +141,7 @@ class AIHookPayload:
139141
mcp_server_name: Optional[str] = None # For mcp_execution events
140142
mcp_tool_name: Optional[str] = None # For mcp_execution events
141143
mcp_arguments: Optional[dict] = None # For mcp_execution events
144+
command: Optional[str] = None # For command_exec events (e.g., Codex PreToolUse:Bash)
142145

143146
@classmethod
144147
def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload':
@@ -227,6 +230,44 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload':
227230
mcp_arguments=mcp_arguments,
228231
)
229232

233+
@classmethod
234+
def from_codex_payload(cls, payload: dict) -> 'AIHookPayload':
235+
"""Create AIHookPayload from Codex CLI payload.
236+
237+
Codex payload shape (per https://developers.openai.com/codex/hooks):
238+
- hook_event_name: 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'SessionStart' | 'Stop'
239+
- session_id, turn_id, cwd, model delivered at top level
240+
- For UserPromptSubmit: prompt field
241+
- For PreToolUse: tool_name (currently only 'Bash') and tool_input.command
242+
"""
243+
hook_event_name = payload.get('hook_event_name', '')
244+
tool_name = payload.get('tool_name', '')
245+
tool_input = payload.get('tool_input')
246+
247+
if hook_event_name == 'UserPromptSubmit':
248+
canonical_event = AiHookEventType.PROMPT
249+
elif hook_event_name == 'PreToolUse' and tool_name == 'Bash':
250+
canonical_event = AiHookEventType.COMMAND_EXEC
251+
else:
252+
# Unknown or unsupported event combination; fall back to raw event name
253+
canonical_event = CODEX_EVENT_MAPPING.get(hook_event_name, hook_event_name)
254+
255+
command = None
256+
if tool_name == 'Bash' and isinstance(tool_input, dict):
257+
command = tool_input.get('command')
258+
259+
return cls(
260+
event_name=canonical_event,
261+
conversation_id=payload.get('session_id'),
262+
generation_id=payload.get('turn_id'),
263+
ide_user_email=None,
264+
model=payload.get('model'),
265+
ide_provider=AIIDEType.CODEX.value,
266+
ide_version=payload.get('codex_version'),
267+
prompt=payload.get('prompt', ''),
268+
command=command,
269+
)
270+
230271
@staticmethod
231272
def is_payload_for_ide(payload: dict, ide: str) -> bool:
232273
"""Check if the payload's event name matches the expected IDE.
@@ -248,6 +289,8 @@ def is_payload_for_ide(payload: dict, ide: str) -> bool:
248289
return hook_event_name in CLAUDE_CODE_EVENT_NAMES
249290
if ide == AIIDEType.CURSOR:
250291
return hook_event_name in CURSOR_EVENT_NAMES
292+
if ide == AIIDEType.CODEX:
293+
return hook_event_name in CODEX_EVENT_NAMES
251294

252295
# Unknown IDE, allow processing
253296
return True
@@ -270,4 +313,6 @@ def from_payload(cls, payload: dict, tool: str = AIIDEType.CURSOR.value) -> 'AIH
270313
return cls.from_cursor_payload(payload)
271314
if tool == AIIDEType.CLAUDE_CODE:
272315
return cls.from_claude_code_payload(payload)
316+
if tool == AIIDEType.CODEX:
317+
return cls.from_codex_payload(payload)
273318
raise ValueError(f'Unsupported IDE/tool: {tool}')

0 commit comments

Comments
 (0)