Skip to content

Commit f89bbe6

Browse files
authored
CM-61986-add-mcp-and-email-enrichment-from-claude-json (#421)
1 parent d778209 commit f89bbe6

File tree

4 files changed

+158
-1
lines changed

4 files changed

+158
-1
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Reader for ~/.claude.json configuration file.
2+
3+
Extracts user email from the Claude Code global config file
4+
for use in AI guardrails scan enrichment.
5+
"""
6+
7+
import json
8+
from pathlib import Path
9+
from typing import Optional
10+
11+
from cycode.logger import get_logger
12+
13+
logger = get_logger('AI Guardrails Claude Config')
14+
15+
_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json'
16+
17+
18+
def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]:
19+
"""Load and parse ~/.claude.json.
20+
21+
Args:
22+
config_path: Override path for testing. Defaults to ~/.claude.json.
23+
24+
Returns:
25+
Parsed dict or None if file is missing or invalid.
26+
"""
27+
path = config_path or _CLAUDE_CONFIG_PATH
28+
if not path.exists():
29+
logger.debug('Claude config file not found', extra={'path': str(path)})
30+
return None
31+
try:
32+
content = path.read_text(encoding='utf-8')
33+
return json.loads(content)
34+
except Exception as e:
35+
logger.debug('Failed to load Claude config file', exc_info=e)
36+
return None
37+
38+
39+
def get_user_email(config: dict) -> Optional[str]:
40+
"""Extract user email from Claude config.
41+
42+
Reads oauthAccount.emailAddress from the config dict.
43+
"""
44+
return config.get('oauthAccount', {}).get('emailAddress')

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Optional
88

99
from cycode.cli.apps.ai_guardrails.consts import AIIDEType
10+
from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config
1011
from cycode.cli.apps.ai_guardrails.scan.types import (
1112
CLAUDE_CODE_EVENT_MAPPING,
1213
CLAUDE_CODE_EVENT_NAMES,
@@ -207,11 +208,15 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload':
207208
# Extract IDE version, model, and generation ID from transcript file
208209
ide_version, model, generation_id = _extract_from_claude_transcript(payload.get('transcript_path'))
209210

211+
# Extract user email from ~/.claude.json
212+
claude_config = load_claude_config()
213+
ide_user_email = get_user_email(claude_config) if claude_config else None
214+
210215
return cls(
211216
event_name=canonical_event,
212217
conversation_id=payload.get('session_id'),
213218
generation_id=generation_id,
214-
ide_user_email=None, # Claude Code doesn't provide this in hook payload
219+
ide_user_email=ide_user_email,
215220
model=model,
216221
ide_provider=AIIDEType.CLAUDE_CODE.value,
217222
ide_version=ide_version,

tests/cli/commands/ai_guardrails/scan/test_payload.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,60 @@ def test_from_claude_code_payload_gets_latest_user_uuid(mocker: MockerFixture) -
322322
assert unified.generation_id == 'latest-user-uuid'
323323

324324

325+
# Claude Code email extraction tests
326+
327+
328+
def test_from_claude_code_payload_extracts_email_from_config(mocker: MockerFixture) -> None:
329+
"""Test that ide_user_email is populated from ~/.claude.json."""
330+
mocker.patch(
331+
'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config',
332+
return_value={'oauthAccount': {'emailAddress': 'user@example.com'}},
333+
)
334+
335+
claude_payload = {
336+
'hook_event_name': 'UserPromptSubmit',
337+
'session_id': 'session-123',
338+
'prompt': 'test',
339+
}
340+
341+
unified = AIHookPayload.from_claude_code_payload(claude_payload)
342+
assert unified.ide_user_email == 'user@example.com'
343+
344+
345+
def test_from_claude_code_payload_email_none_when_config_missing(mocker: MockerFixture) -> None:
346+
"""Test that ide_user_email is None when ~/.claude.json is missing."""
347+
mocker.patch(
348+
'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config',
349+
return_value=None,
350+
)
351+
352+
claude_payload = {
353+
'hook_event_name': 'UserPromptSubmit',
354+
'session_id': 'session-123',
355+
'prompt': 'test',
356+
}
357+
358+
unified = AIHookPayload.from_claude_code_payload(claude_payload)
359+
assert unified.ide_user_email is None
360+
361+
362+
def test_from_claude_code_payload_email_none_when_no_oauth(mocker: MockerFixture) -> None:
363+
"""Test that ide_user_email is None when oauthAccount is missing from config."""
364+
mocker.patch(
365+
'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config',
366+
return_value={'someOtherKey': 'value'},
367+
)
368+
369+
claude_payload = {
370+
'hook_event_name': 'UserPromptSubmit',
371+
'session_id': 'session-123',
372+
'prompt': 'test',
373+
}
374+
375+
unified = AIHookPayload.from_claude_code_payload(claude_payload)
376+
assert unified.ide_user_email is None
377+
378+
325379
# IDE detection tests
326380

327381

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Tests for Claude Code config file reader."""
2+
3+
import json
4+
from pathlib import Path
5+
6+
from pyfakefs.fake_filesystem import FakeFilesystem
7+
8+
from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config
9+
10+
11+
def test_load_claude_config_valid(fs: FakeFilesystem) -> None:
12+
"""Test loading a valid ~/.claude.json file."""
13+
config = {'oauthAccount': {'emailAddress': 'user@example.com'}}
14+
config_path = Path.home() / '.claude.json'
15+
fs.create_file(config_path, contents=json.dumps(config))
16+
17+
result = load_claude_config(config_path)
18+
assert result == config
19+
20+
21+
def test_load_claude_config_missing_file(fs: FakeFilesystem) -> None:
22+
"""Test loading when ~/.claude.json does not exist."""
23+
fs.create_dir(Path.home())
24+
config_path = Path.home() / '.claude.json'
25+
26+
result = load_claude_config(config_path)
27+
assert result is None
28+
29+
30+
def test_load_claude_config_corrupt_file(fs: FakeFilesystem) -> None:
31+
"""Test loading when ~/.claude.json contains invalid JSON."""
32+
config_path = Path.home() / '.claude.json'
33+
fs.create_file(config_path, contents='not valid json {{{')
34+
35+
result = load_claude_config(config_path)
36+
assert result is None
37+
38+
39+
def test_get_user_email_present() -> None:
40+
"""Test extracting email when oauthAccount.emailAddress exists."""
41+
config = {'oauthAccount': {'emailAddress': 'user@example.com'}}
42+
assert get_user_email(config) == 'user@example.com'
43+
44+
45+
def test_get_user_email_missing_oauth_account() -> None:
46+
"""Test extracting email when oauthAccount key is missing."""
47+
config = {'someOtherKey': 'value'}
48+
assert get_user_email(config) is None
49+
50+
51+
def test_get_user_email_missing_email_address() -> None:
52+
"""Test extracting email when oauthAccount exists but emailAddress is missing."""
53+
config = {'oauthAccount': {'someOtherField': 'value'}}
54+
assert get_user_email(config) is None

0 commit comments

Comments
 (0)