diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index b7e55b86..fa8e095e 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -127,16 +127,20 @@ def install_hooks( ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None, - report_mode: bool = False, ) -> tuple[bool, str]: """Install Cycode AI guardrails hooks for ``ide``.""" hooks_path = ide.settings_path(scope, repo_path) - existing = _load_hooks_file(hooks_path) or {'version': 1, 'hooks': {}} - existing.setdefault('version', 1) + existing = _load_hooks_file(hooks_path) or {'hooks': {}} existing.setdefault('hooks', {}) - rendered = ide.render_hooks_config(async_mode=report_mode) + rendered = ide.render_hooks_config() + + # Top-level fields come from the IDE's render only - Codex rejects a hooks file + # with unknown top-level fields, so no `version` may be injected here. + for key, value in rendered.items(): + if key != 'hooks': + existing[key] = value for event, entries in rendered['hooks'].items(): existing['hooks'].setdefault(event, []) diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index 29b4b200..38b16def 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -14,7 +14,6 @@ JSON response shape that the IDE expects on stdout. """ -import platform from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum @@ -25,24 +24,6 @@ from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType -def shell_background_suffix(async_mode: bool) -> str: - """`' &'` when backgrounding is requested and the platform's shell supports it. - - Only valid for hooks whose runner is stdin-safe under backgrounding (zsh keeps - a backgrounded command's stdin; verified for Cursor/Codex). bash/sh reattach it - to /dev/null, silently emptying the payload — hooks that run under bash (e.g. - Copilot's `bash` field) must add an explicit `<&0` redirect instead. - - Windows gets no suffix: depending on the IDE, hooks may run under cmd (where a - trailing `&` is a no-op separator) or Windows PowerShell (where it's a parse - error that would fail the hook). Until the CLI can self-detach in report mode, - Windows hooks run synchronously. - """ - if not async_mode or platform.system() == 'Windows': - return '' - return ' &' - - class DecisionAction(str, Enum): """Canonical decision action returned by event handlers.""" @@ -118,7 +99,7 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: """ @abstractmethod - def render_hooks_config(self, async_mode: bool = False) -> dict: + def render_hooks_config(self) -> dict: """Return the settings blob to merge into the IDE's settings file. Shape is IDE-specific (Cursor uses a flat ``{event: [{command}]}`` dict; diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 131e8e1a..ba2d04fa 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -249,12 +249,8 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME return _USER_HOOKS_DIR / _HOOKS_FILE_NAME - def render_hooks_config(self, async_mode: bool = False) -> dict: - # Claude Code uses a nested hook structure with optional async/timeout. + def render_hooks_config(self) -> dict: hook_entry: dict = {'type': 'command', 'command': _SCAN_COMMAND} - if async_mode: - hook_entry['async'] = True - hook_entry['timeout'] = 20 return { 'hooks': { diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index c9e48393..bdcc5889 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -20,7 +20,7 @@ resolve_cached_plugin_dir, walk_enabled_plugins, ) -from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision, shell_background_suffix +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType from cycode.cli.utils.jwt_utils import decode_jwt_unverified @@ -189,11 +189,10 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: return repo_path / _CONFIG_DIR_NAME / _HOOKS_FILE_NAME return _codex_home() / _HOOKS_FILE_NAME - def render_hooks_config(self, async_mode: bool = False) -> dict: - # Codex's TOML `async: true` flag is unimplemented; shell-background via - # `&` is the working mechanism (unix only). SessionStart stays sync so - # the conversation context is registered before any scan hook fires. - scan_cmd = f'{_SCAN_COMMAND}{shell_background_suffix(async_mode)}' + def render_hooks_config(self) -> dict: + # SessionStart stays sync so the conversation context is registered + # before any scan hook fires. + scan_cmd = _SCAN_COMMAND return { 'hooks': { 'SessionStart': [ diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py index 5f3bc76a..99e6cbac 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -379,24 +379,9 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: return repo_path / _REPO_HOOKS_SUBDIR / _HOOKS_FILE_NAME return _copilot_home() / 'hooks' / _HOOKS_FILE_NAME - def render_hooks_config(self, async_mode: bool = False) -> dict: + def render_hooks_config(self) -> dict: + # Single cross-platform `command` field, copied to both shells by Copilot. def entry(command: str) -> dict: - if async_mode: - # Copilot has no async hook flag; background via shell on unix. Both - # redirects are load-bearing. `<&0` keeps the payload flowing: a bare - # `cmd &` gets its stdin reattached to /dev/null by the shell (job - # control is off in hooks), so the scan reads nothing and allows. The - # stdout redirect is what actually makes it async: the backgrounded - # child inherits the hook's stdout and the runner waits on that pipe - # for EOF, so without it the scan blocks the response it was meant to - # run behind. Windows PowerShell has no trailing-&, so it stays sync. - return { - 'type': 'command', - 'bash': f'{command} <&0 >/dev/null 2>&1 &', - 'powershell': command, - 'timeoutSec': _HOOK_TIMEOUT_SEC, - } - # Single cross-platform `command` field, copied to both shells by Copilot. return {'type': 'command', 'command': command, 'timeoutSec': _HOOK_TIMEOUT_SEC} return { diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py index 01c65edb..910097ca 100644 --- a/cycode/cli/apps/ai_guardrails/ides/cursor.py +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -7,7 +7,7 @@ from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_file -from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision, shell_background_suffix +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType from cycode.logger import get_logger @@ -68,9 +68,8 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME return _user_hooks_dir() / _HOOKS_FILE_NAME - def render_hooks_config(self, async_mode: bool = False) -> dict: - command = f'{_SCAN_COMMAND}{shell_background_suffix(async_mode)}' - hooks = {event: [{'command': command}] for event in self.hook_events} + def render_hooks_config(self) -> dict: + hooks = {event: [{'command': _SCAN_COMMAND}] for event in self.hook_events} hooks['sessionStart'] = [{'command': _SESSION_START_COMMAND}] return {'version': 1, 'hooks': hooks} diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index 155cf83a..48fb090c 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -65,11 +65,9 @@ def install_command( repo_path = resolve_repo_path(scope, repo_path) ides_to_install = resolve_ides(ide) - report_mode = mode == GuardrailsMode.REPORT - results: list[tuple[str, bool, str]] = [] for current_ide in ides_to_install: - success, message = install_hooks(current_ide, scope, repo_path, report_mode=report_mode) + success, message = install_hooks(current_ide, scope, repo_path) results.append((current_ide.display_name, success, message)) any_success = False @@ -108,6 +106,6 @@ def _print_next_steps(results: list[tuple[str, bool, str]], mode: GuardrailsMode console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml') console.print() if mode == GuardrailsMode.REPORT: - console.print('[dim]Report mode: hooks run async (non-blocking) and policy is set to warn.[/]') + console.print('[dim]Report mode: policy is set to warn.[/]') else: console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]') diff --git a/cycode/cli/apps/ai_guardrails/scan/detach.py b/cycode/cli/apps/ai_guardrails/scan/detach.py new file mode 100644 index 00000000..2506111f --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/detach.py @@ -0,0 +1,71 @@ +"""Self-detach support for report-mode scans. + +In report mode nobody consumes the hook verdict, so the scan respawns itself +detached and the parent exits immediately — the IDE is released after roughly +CLI startup instead of waiting for a full scan. The child's handles are set at +process-creation time (stdout/stderr to devnull, payload piped to stdin), so it +never inherits the IDE's pipes: a backgrounded child that shares the hook's +stdout keeps EOF-waiting runners blocked for the scan's full duration. +""" + +import os +import subprocess +import sys + +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails') + +DETACHED_ENV_VAR = '_CYCODE_DETACHED' + +# Numeric fallbacks let non-Windows platforms (tests included) build the same flags. +_WINDOWS_CREATIONFLAGS = ( + getattr(subprocess, 'DETACHED_PROCESS', 0x00000008) + | getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0x00000200) + | getattr(subprocess, 'CREATE_NO_WINDOW', 0x08000000) +) + + +def is_detached_child() -> bool: + """Whether this process is the respawned detached child.""" + return os.environ.get(DETACHED_ENV_VAR) == '1' + + +def build_respawn_command() -> list[str]: + """The command that re-runs the current invocation. + + Under PyInstaller sys.executable is the CLI binary itself; otherwise it is + the Python interpreter and argv[0] is the console script to re-run. + """ + if getattr(sys, 'frozen', False): + return [sys.executable, *sys.argv[1:]] + return [sys.executable, *sys.argv] + + +def respawn_detached(stdin_payload: str) -> bool: + """Respawn the current command detached, feeding it ``stdin_payload``. + + Returns False when the respawn failed, so the caller can fall back to the + synchronous path instead of dropping the event. + """ + try: + detach_kwargs = ( + {'creationflags': _WINDOWS_CREATIONFLAGS} if sys.platform == 'win32' else {'start_new_session': True} + ) + process = subprocess.Popen( # noqa: S603 + build_respawn_command(), + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env={**os.environ, DETACHED_ENV_VAR: '1'}, + **detach_kwargs, + ) + # Hand over the payload and close, then return without waiting - the + # whole point is that the parent exits while the child scans. + process.stdin.write(stdin_payload.encode('utf-8')) + process.stdin.close() + logger.debug('Respawned detached scan', extra={'child_pid': process.pid}) + return True + except Exception as e: + logger.debug('Failed to respawn detached, falling back to synchronous scan', exc_info=e) + return False diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 4e773cf1..cbe50637 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -318,6 +318,30 @@ def get_effective_mode(policy: dict, feature_config: dict) -> GuardrailsMode: return GuardrailsMode.BLOCK if (mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK) else GuardrailsMode.REPORT +# The policy section each event's handler reads its feature config from. +_FEATURE_KEY_BY_EVENT_TYPE: dict[str, str] = { + AiHookEventType.PROMPT.value: 'prompt', + AiHookEventType.FILE_READ.value: 'file_read', + AiHookEventType.MCP_EXECUTION.value: 'mcp', +} + + +def should_detach_scan(policy: dict, event_name: str) -> bool: + """Whether this event's scan is safe to run detached. + + Report mode never blocks, so nobody consumes the verdict. Fail-closed + configs stay synchronous even in report mode: their deny on scan failure + must reach the IDE. Unknown events stay synchronous - they exit fast anyway. + """ + feature_key = _FEATURE_KEY_BY_EVENT_TYPE.get(event_name) + if feature_key is None: + return False + if not get_policy_value(policy, 'fail_open', default=True): + return False + feature_config = get_policy_value(policy, feature_key, default={}) + return get_effective_mode(policy, feature_config) == GuardrailsMode.REPORT + + def build_ai_guardrails_scan_parameters( ctx: typer.Context, paths: Optional[tuple[str, ...]], diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index 5cf5da38..5c3b2513 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -15,7 +15,8 @@ from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide from cycode.cli.apps.ai_guardrails.ides.base import HookDecision -from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event +from cycode.cli.apps.ai_guardrails.scan.detach import is_detached_child, respawn_detached +from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event, should_detach_scan from cycode.cli.apps.ai_guardrails.scan.policy import load_policy from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType from cycode.cli.apps.ai_guardrails.scan.utils import output_json, read_stdin_text, safe_json_parse @@ -139,6 +140,12 @@ def scan_command( workspace_roots = payload.get('workspace_roots') or ['.'] policy = load_policy(workspace_roots[0]) + # Report mode: nobody consumes the verdict, so hand the scan to a detached + # child and release the IDE immediately. Runs before any client or network + # work. A failed respawn falls through to the synchronous path. + if not is_detached_child() and should_detach_scan(policy, event_name) and respawn_detached(stdin_data): + return + try: _initialize_clients(ctx) diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index 33137311..fc0e8849 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -8,7 +8,6 @@ import pytest from pyfakefs.fake_filesystem import FakeFilesystem -from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.ides.base import HookDecision from cycode.cli.apps.ai_guardrails.ides.codex import ( @@ -158,40 +157,23 @@ def test_render_hooks_session_start_matches_all_sources() -> None: def test_render_hooks_never_emits_async_toml_flags() -> None: """Codex's TOML `async: true` / `timeout` flags are unimplemented; we must not emit them.""" - for mode in (False, True): - rendered = Codex().render_hooks_config(async_mode=mode) - for entry in rendered['hooks']['PreToolUse']: - for hook in entry['hooks']: - assert 'async' not in hook - assert 'timeout' not in hook - - -def test_render_hooks_async_backgrounds_scan_hooks(mocker: MockerFixture) -> None: - """In async mode, UserPromptSubmit + PreToolUse scan hooks shell-background (unix).""" - mocker.patch('platform.system', return_value='Linux') - rendered = Codex().render_hooks_config(async_mode=True) - prompt_cmd = rendered['hooks']['UserPromptSubmit'][0]['hooks'][0]['command'] - pretool_cmd = rendered['hooks']['PreToolUse'][0]['hooks'][0]['command'] - assert prompt_cmd.endswith(' &') - assert pretool_cmd.endswith(' &') + rendered = Codex().render_hooks_config() + for entry in rendered['hooks']['PreToolUse']: + for hook in entry['hooks']: + assert 'async' not in hook + assert 'timeout' not in hook -def test_render_hooks_async_windows_stays_sync(mocker: MockerFixture) -> None: - """No '&' on Windows - nothing there detaches safely, so hooks run sync.""" - mocker.patch('platform.system', return_value='Windows') - rendered = Codex().render_hooks_config(async_mode=True) +def test_render_hooks_commands_always_plain_sync() -> None: + """Hooks are always installed plain sync: warn-vs-block synchronicity is + decided at scan time by the CLI itself (self-detach).""" + rendered = Codex().render_hooks_config() prompt_cmd = rendered['hooks']['UserPromptSubmit'][0]['hooks'][0]['command'] pretool_cmd = rendered['hooks']['PreToolUse'][0]['hooks'][0]['command'] + session_cmd = rendered['hooks']['SessionStart'][0]['hooks'][0]['command'] assert '&' not in prompt_cmd assert '&' not in pretool_cmd - - -def test_render_hooks_session_start_always_synchronous() -> None: - """SessionStart registers the conversation context — never backgrounded.""" - for mode in (False, True): - rendered = Codex().render_hooks_config(async_mode=mode) - session_cmd = rendered['hooks']['SessionStart'][0]['hooks'][0]['command'] - assert '&' not in session_cmd + assert '&' not in session_cmd def test_render_hooks_pretooluse_matchers_are_mcp_only() -> None: diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py index 0984c97a..fb0ca2df 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_contract.py +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -8,7 +8,6 @@ from pathlib import Path import pytest -from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.ides import IDES from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision @@ -61,14 +60,24 @@ def test_render_hooks_config_has_hooks_key(ide: IDE) -> None: assert isinstance(rendered['hooks'], dict) -def test_render_hooks_config_async_changes_output(ide: IDE, mocker: MockerFixture) -> None: - """async_mode must influence the rendered output. - - Pinned to a unix platform: IDEs that background via a shell `&` render - identical sync/async configs on Windows, where no safe suffix exists. - """ - mocker.patch('platform.system', return_value='Linux') - assert ide.render_hooks_config(async_mode=False) != ide.render_hooks_config(async_mode=True) +def test_render_hooks_config_is_always_plain_sync(ide: IDE) -> None: + """Hook commands never carry shell-background plumbing or async flags: + warn-vs-block synchronicity is decided at scan time by the CLI itself + (self-detach in report mode).""" + + def assert_plain(node: object) -> None: + if isinstance(node, dict): + assert 'async' not in node + for field in ('command', 'bash', 'powershell'): + if field in node: + assert not str(node[field]).rstrip().endswith('&') + for value in node.values(): + assert_plain(value) + elif isinstance(node, list): + for item in node: + assert_plain(item) + + assert_plain(ide.render_hooks_config()) def test_matches_payload_rejects_empty(ide: IDE) -> None: diff --git a/tests/cli/commands/ai_guardrails/ides/test_copilot.py b/tests/cli/commands/ai_guardrails/ides/test_copilot.py index 5d5f6d4f..d11e81ab 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_copilot.py +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -316,14 +316,15 @@ def test_render_hooks_config_sync_uses_cross_platform_command() -> None: assert session_entry['command'] == 'cycode ai-guardrails session-start --ide copilot' -def test_render_hooks_config_async_backgrounds_on_unix() -> None: - rendered = Copilot().render_hooks_config(async_mode=True) - tool_entry = rendered['hooks']['PreToolUse'][0] - # <&0 keeps the payload (a bare `cmd &` gets stdin from /dev/null and scans nothing); - # the stdout redirect releases the pipe the runner waits on, or it still blocks. - assert tool_entry['bash'].endswith('<&0 >/dev/null 2>&1 &') - assert not tool_entry['powershell'].endswith('&') - assert 'command' not in tool_entry +def test_render_hooks_config_never_shell_splits() -> None: + """Entries always use the single cross-platform `command` field: warn-vs-block + synchronicity is decided at scan time by the CLI itself (self-detach).""" + rendered = Copilot().render_hooks_config() + for entries in rendered['hooks'].values(): + for entry in entries: + assert 'bash' not in entry + assert 'powershell' not in entry + assert '&' not in entry['command'] def test_settings_path_user_scope() -> None: diff --git a/tests/cli/commands/ai_guardrails/scan/test_detach.py b/tests/cli/commands/ai_guardrails/scan/test_detach.py new file mode 100644 index 00000000..35e610ea --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_detach.py @@ -0,0 +1,81 @@ +"""Tests for the report-mode self-detach mechanism.""" + +import subprocess +import sys +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.scan.detach import ( + DETACHED_ENV_VAR, + build_respawn_command, + is_detached_child, + respawn_detached, +) + + +def test_is_detached_child_reads_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(DETACHED_ENV_VAR, raising=False) + assert is_detached_child() is False + + monkeypatch.setenv(DETACHED_ENV_VAR, '1') + assert is_detached_child() is True + + +def test_build_respawn_command_script_install(monkeypatch: pytest.MonkeyPatch) -> None: + """pip install: sys.executable is python, argv[0] is the console script to re-run.""" + monkeypatch.delattr(sys, 'frozen', raising=False) + monkeypatch.setattr(sys, 'argv', ['/usr/local/bin/cycode', 'ai-guardrails', 'scan', '--ide', 'cursor']) + + assert build_respawn_command() == [ + sys.executable, + '/usr/local/bin/cycode', + 'ai-guardrails', + 'scan', + '--ide', + 'cursor', + ] + + +def test_build_respawn_command_frozen(monkeypatch: pytest.MonkeyPatch) -> None: + """PyInstaller: sys.executable is the CLI binary itself; argv[0] is dropped.""" + monkeypatch.setattr(sys, 'frozen', True, raising=False) + monkeypatch.setattr(sys, 'argv', ['cycode', 'ai-guardrails', 'scan', '--ide', 'cursor']) + + assert build_respawn_command() == [sys.executable, 'ai-guardrails', 'scan', '--ide', 'cursor'] + + +def _mock_popen(mocker: MockerFixture) -> MagicMock: + popen = mocker.patch('cycode.cli.apps.ai_guardrails.scan.detach.subprocess.Popen') + popen.return_value.pid = 4242 + return popen + + +def test_respawn_detached_hands_over_payload_with_fresh_handles(mocker: MockerFixture) -> None: + popen = _mock_popen(mocker) + + assert respawn_detached('{"prompt": "hi"}') is True + + kwargs = popen.call_args.kwargs + # Fresh handles are the whole point: the child must not inherit the IDE's + # pipes, or EOF-waiting hook runners block for the scan's full duration. + assert kwargs['stdin'] == subprocess.PIPE + assert kwargs['stdout'] == subprocess.DEVNULL + assert kwargs['stderr'] == subprocess.DEVNULL + assert kwargs['env'][DETACHED_ENV_VAR] == '1' + if sys.platform == 'win32': + assert kwargs['creationflags'] != 0 + else: + assert kwargs['start_new_session'] is True + + child_stdin = popen.return_value.stdin + child_stdin.write.assert_called_once_with(b'{"prompt": "hi"}') + child_stdin.close.assert_called_once_with() + popen.return_value.wait.assert_not_called() + + +def test_respawn_detached_failure_returns_false(mocker: MockerFixture) -> None: + mocker.patch('cycode.cli.apps.ai_guardrails.scan.detach.subprocess.Popen', side_effect=OSError('spawn failed')) + + assert respawn_detached('{}') is False 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 8b7b611e..c7351b67 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -255,6 +255,135 @@ def test_copilot_cli_payload_skipped( assert json.loads(capsys.readouterr().out) == {} +class TestSelfDetach: + """Report-mode scans respawn detached and release the IDE immediately.""" + + @pytest.fixture + def mock_respawn(self, mocker: MockerFixture) -> MagicMock: + return mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.respawn_detached', return_value=True) + + @pytest.fixture + def not_detached(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv('_CYCODE_DETACHED', raising=False) + + def _run_prompt_scan(self, mock_ctx: MagicMock, mocker: MockerFixture, policy: dict) -> str: + payload = json.dumps({'hook_event_name': 'beforeSubmitPrompt', 'conversation_id': 'c-1', 'prompt': 'test'}) + mocker.patch('sys.stdin', StringIO(payload)) + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.load_policy', return_value=policy) + scan_command(mock_ctx, ide='cursor') + return payload + + def test_warn_mode_detaches_before_any_client_work( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_respawn: MagicMock, + not_detached: None, + ) -> None: + initialize_clients = mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients') + handler = MagicMock() + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event', return_value=handler) + + payload = self._run_prompt_scan(mock_ctx, mocker, {'mode': 'warn', 'fail_open': True}) + + # The parent hands the raw payload to the child and exits with no output. + mock_respawn.assert_called_once_with(payload) + initialize_clients.assert_not_called() + handler.assert_not_called() + assert capsys.readouterr().out == '' + + def test_block_mode_stays_synchronous( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + mock_respawn: MagicMock, + not_detached: None, + ) -> None: + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients') + handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event', return_value=handler) + + self._run_prompt_scan(mock_ctx, mocker, {'mode': 'block', 'fail_open': True}) + + mock_respawn.assert_not_called() + handler.assert_called_once() + + def test_detached_child_never_respawns_again( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + mock_respawn: MagicMock, + ) -> None: + monkeypatch.setenv('_CYCODE_DETACHED', '1') + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients') + handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event', return_value=handler) + + self._run_prompt_scan(mock_ctx, mocker, {'mode': 'warn', 'fail_open': True}) + + mock_respawn.assert_not_called() + handler.assert_called_once() + + def test_fail_closed_policy_stays_synchronous_even_in_warn_mode( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + mock_respawn: MagicMock, + not_detached: None, + ) -> None: + """A fail-closed deny on scan failure must reach the IDE - it cannot go to devnull.""" + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients') + handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event', return_value=handler) + + self._run_prompt_scan(mock_ctx, mocker, {'mode': 'warn', 'fail_open': False}) + + mock_respawn.assert_not_called() + handler.assert_called_once() + + def test_detach_decision_is_per_event( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + mock_respawn: MagicMock, + not_detached: None, + ) -> None: + """Prompt blocks while file-read reports: only the file-read event detaches.""" + policy = {'mode': 'block', 'fail_open': True, 'file_read': {'action': 'warn'}} + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients') + handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event', return_value=handler) + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.load_policy', return_value=policy) + + mocker.patch('sys.stdin', StringIO(json.dumps({'hook_event_name': 'beforeSubmitPrompt', 'prompt': 'x'}))) + scan_command(mock_ctx, ide='cursor') + mock_respawn.assert_not_called() + + read_payload = {'hook_event_name': 'beforeReadFile', 'file_path': '/workspace/app.py'} + mocker.patch('sys.stdin', StringIO(json.dumps(read_payload))) + scan_command(mock_ctx, ide='cursor') + mock_respawn.assert_called_once() + + def test_failed_respawn_falls_back_to_synchronous_scan( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + mock_respawn: MagicMock, + not_detached: None, + ) -> None: + mock_respawn.return_value = False + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients') + handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event', return_value=handler) + + self._run_prompt_scan(mock_ctx, mocker, {'mode': 'warn', 'fail_open': True}) + + mock_respawn.assert_called_once() + handler.assert_called_once() + + class TestDefaultIdeParameterViaCli: """Tests that verify default IDE parameter works correctly via CLI invocation.""" diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index 1a7b7c2f..67dfbeb1 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -9,7 +9,6 @@ if TYPE_CHECKING: import pytest - from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.consts import ( CYCODE_SCAN_PROMPT_COMMAND, @@ -79,28 +78,6 @@ def test_cursor_render_hooks_sync() -> None: assert '&' not in entry['command'] -def test_cursor_render_hooks_async(mocker: 'MockerFixture') -> None: - """Cursor async hooks: '&' suffix on scan commands (unix).""" - mocker.patch('platform.system', return_value='Linux') - config = Cursor().render_hooks_config(async_mode=True) - scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} - for entries in scan_hooks.values(): - for entry in entries: - assert entry['command'].endswith('&') - assert CYCODE_SCAN_PROMPT_COMMAND in entry['command'] - - -def test_cursor_render_hooks_async_windows_stays_sync(mocker: 'MockerFixture') -> None: - """No '&' on Windows: cmd treats it as a no-op separator and Windows - PowerShell rejects it outright - either way nothing detaches.""" - mocker.patch('platform.system', return_value='Windows') - config = Cursor().render_hooks_config(async_mode=True) - scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} - for entries in scan_hooks.values(): - for entry in entries: - assert '&' not in entry['command'] - - def test_cursor_render_hooks_session_start() -> None: """Cursor session_start carries the --ide flag explicitly.""" config = Cursor().render_hooks_config() @@ -111,8 +88,9 @@ def test_cursor_render_hooks_session_start() -> None: assert '--ide cursor' in entries[0]['command'] -def test_claude_code_render_hooks_sync() -> None: - """Claude Code sync hooks: no async/timeout fields.""" +def test_claude_code_render_hooks_never_async() -> None: + """Hooks are always installed plain sync: warn-vs-block synchronicity is + decided at scan time by the CLI itself (self-detach).""" config = ClaudeCode().render_hooks_config() scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} for event_entries in scan_events.values(): @@ -122,16 +100,6 @@ def test_claude_code_render_hooks_sync() -> None: assert 'timeout' not in hook -def test_claude_code_render_hooks_async() -> None: - """Claude Code async hooks: 'async' flag + timeout.""" - config = ClaudeCode().render_hooks_config(async_mode=True) - scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} - for event_entries in scan_events.values(): - for event_entry in event_entries: - for hook in event_entry['hooks']: - assert hook['async'] is True - - def test_claude_code_render_hooks_session_start() -> None: """Claude Code SessionStart fires on every source (a forked session reports 'resume', so the matcher is empty -> match-all).""" @@ -239,6 +207,22 @@ def test_install_preserves_user_hook_colocated_with_cycode( assert saved['hooks']['PostToolUse'][0]['hooks'][0]['command'] == '/usr/local/bin/user-postlog.sh' +def test_codex_install_never_writes_version_field(fs: FakeFilesystem, monkeypatch: 'pytest.MonkeyPatch') -> None: + """Codex rejects a hooks file with unknown top-level fields, so install must not + inject `version`.""" + repo = Path('/repo') + fs.create_dir(repo) + monkeypatch.setenv('CODEX_HOME', '/codex-home') + fs.create_dir('/codex-home') + + success, _ = install_hooks(Codex(), scope='repo', repo_path=repo) + assert success is True + + saved = json.loads((repo / '.codex' / 'hooks.json').read_text()) + assert 'version' not in saved + assert saved['hooks']['UserPromptSubmit'] + + def test_uninstall_preserves_user_hook_colocated_with_cycode( fs: FakeFilesystem, monkeypatch: 'pytest.MonkeyPatch' ) -> None: @@ -290,12 +274,12 @@ def test_copilot_dedicated_file_install_uninstall_lifecycle(fs: FakeFilesystem) assert set(saved['hooks']) == {'SessionStart', 'UserPromptSubmit', 'PreToolUse'} assert all(len(entries) == 1 for entries in saved['hooks'].values()) - # Reinstall (also flipping mode) must replace, not duplicate. - success, _ = install_hooks(copilot, report_mode=True) + # Reinstall must replace, not duplicate. + success, _ = install_hooks(copilot) assert success is True saved = json.loads(hooks_path.read_text()) assert all(len(entries) == 1 for entries in saved['hooks'].values()) - assert saved['hooks']['PreToolUse'][0]['bash'].endswith('&') + assert saved['hooks']['PreToolUse'][0]['command'] == 'cycode ai-guardrails scan --ide copilot' # Uninstall deletes the emptied dedicated file rather than leaving a husk. success, _ = uninstall_hooks(copilot)