diff --git a/cycode/cli/apps/ai_guardrails/scan/claude_config.py b/cycode/cli/apps/ai_guardrails/scan/claude_config.py new file mode 100644 index 00000000..cff0a5d7 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/claude_config.py @@ -0,0 +1,44 @@ +"""Reader for ~/.claude.json configuration file. + +Extracts user email from the Claude Code global config file +for use in AI guardrails scan enrichment. +""" + +import json +from pathlib import Path +from typing import Optional + +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Claude Config') + +_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' + + +def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse ~/.claude.json. + + Args: + config_path: Override path for testing. Defaults to ~/.claude.json. + + Returns: + Parsed dict or None if file is missing or invalid. + """ + path = config_path or _CLAUDE_CONFIG_PATH + if not path.exists(): + logger.debug('Claude config file not found', extra={'path': str(path)}) + return None + try: + content = path.read_text(encoding='utf-8') + return json.loads(content) + except Exception as e: + logger.debug('Failed to load Claude config file', exc_info=e) + return None + + +def get_user_email(config: dict) -> Optional[str]: + """Extract user email from Claude config. + + Reads oauthAccount.emailAddress from the config dict. + """ + return config.get('oauthAccount', {}).get('emailAddress') diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index 08e96f9a..9a19970c 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -7,6 +7,7 @@ from typing import Optional from cycode.cli.apps.ai_guardrails.consts import AIIDEType +from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config from cycode.cli.apps.ai_guardrails.scan.types import ( CLAUDE_CODE_EVENT_MAPPING, CLAUDE_CODE_EVENT_NAMES, @@ -207,11 +208,15 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': # Extract IDE version, model, and generation ID from transcript file ide_version, model, generation_id = _extract_from_claude_transcript(payload.get('transcript_path')) + # Extract user email from ~/.claude.json + claude_config = load_claude_config() + ide_user_email = get_user_email(claude_config) if claude_config else None + return cls( event_name=canonical_event, conversation_id=payload.get('session_id'), generation_id=generation_id, - ide_user_email=None, # Claude Code doesn't provide this in hook payload + ide_user_email=ide_user_email, model=model, ide_provider=AIIDEType.CLAUDE_CODE.value, ide_version=ide_version, diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py index e17d833d..1ef5fad0 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -322,6 +322,60 @@ def test_from_claude_code_payload_gets_latest_user_uuid(mocker: MockerFixture) - assert unified.generation_id == 'latest-user-uuid' +# Claude Code email extraction tests + + +def test_from_claude_code_payload_extracts_email_from_config(mocker: MockerFixture) -> None: + """Test that ide_user_email is populated from ~/.claude.json.""" + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', + return_value={'oauthAccount': {'emailAddress': 'user@example.com'}}, + ) + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + assert unified.ide_user_email == 'user@example.com' + + +def test_from_claude_code_payload_email_none_when_config_missing(mocker: MockerFixture) -> None: + """Test that ide_user_email is None when ~/.claude.json is missing.""" + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', + return_value=None, + ) + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + assert unified.ide_user_email is None + + +def test_from_claude_code_payload_email_none_when_no_oauth(mocker: MockerFixture) -> None: + """Test that ide_user_email is None when oauthAccount is missing from config.""" + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', + return_value={'someOtherKey': 'value'}, + ) + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + assert unified.ide_user_email is None + + # IDE detection tests diff --git a/tests/cli/commands/ai_guardrails/test_claude_config.py b/tests/cli/commands/ai_guardrails/test_claude_config.py new file mode 100644 index 00000000..6bbdbcab --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_claude_config.py @@ -0,0 +1,54 @@ +"""Tests for Claude Code config file reader.""" + +import json +from pathlib import Path + +from pyfakefs.fake_filesystem import FakeFilesystem + +from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config + + +def test_load_claude_config_valid(fs: FakeFilesystem) -> None: + """Test loading a valid ~/.claude.json file.""" + config = {'oauthAccount': {'emailAddress': 'user@example.com'}} + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents=json.dumps(config)) + + result = load_claude_config(config_path) + assert result == config + + +def test_load_claude_config_missing_file(fs: FakeFilesystem) -> None: + """Test loading when ~/.claude.json does not exist.""" + fs.create_dir(Path.home()) + config_path = Path.home() / '.claude.json' + + result = load_claude_config(config_path) + assert result is None + + +def test_load_claude_config_corrupt_file(fs: FakeFilesystem) -> None: + """Test loading when ~/.claude.json contains invalid JSON.""" + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents='not valid json {{{') + + result = load_claude_config(config_path) + assert result is None + + +def test_get_user_email_present() -> None: + """Test extracting email when oauthAccount.emailAddress exists.""" + config = {'oauthAccount': {'emailAddress': 'user@example.com'}} + assert get_user_email(config) == 'user@example.com' + + +def test_get_user_email_missing_oauth_account() -> None: + """Test extracting email when oauthAccount key is missing.""" + config = {'someOtherKey': 'value'} + assert get_user_email(config) is None + + +def test_get_user_email_missing_email_address() -> None: + """Test extracting email when oauthAccount exists but emailAddress is missing.""" + config = {'oauthAccount': {'someOtherField': 'value'}} + assert get_user_email(config) is None