Skip to content

Commit 3845595

Browse files
Altruistusclaude
andcommitted
CM-73403: Address review: typed models and one session cache check
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 7d8c2b8 commit 3845595

8 files changed

Lines changed: 195 additions & 115 deletions

File tree

‎cycode/cli/apps/ai_guardrails/scan/mcp_server_status.py‎

Lines changed: 31 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,23 @@
55

66
import json
77
import time
8-
from dataclasses import InitVar, dataclass, field
9-
from enum import Enum
8+
from dataclasses import dataclass, field
109
from pathlib import Path
11-
from typing import Optional
10+
from typing import Any, Optional
11+
12+
from marshmallow import EXCLUDE, Schema, fields, post_load
1213

1314
from cycode.cli.apps.ai_guardrails.scan.guardrail_config import DEFAULT_TTL_SECONDS
1415
from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY
1516
from cycode.cli.utils.path_utils import atomic_write_text, quarantine_corrupt_file
17+
from cycode.cyclient.models import McpServerAuthorizationStatus, McpServerStatus, McpServerStatusSchema
1618
from cycode.logger import get_logger
1719

1820
logger = get_logger('AI Guardrails')
1921

2022
MCP_SERVER_STATUSES_FILE_NAME = 'ai-guardrails-mcp-servers.json'
2123

2224

23-
class McpServerAuthorizationStatus(str, Enum):
24-
AUTHORIZED = 'Authorized'
25-
UNREVIEWED = 'Unreviewed'
26-
UNAUTHORIZED = 'Unauthorized'
27-
28-
2925
# An alias shared by several servers (e.g. configured differently per device) gets the most restrictive status.
3026
_RESTRICTIVENESS = {
3127
McpServerAuthorizationStatus.AUTHORIZED: 0,
@@ -38,61 +34,57 @@ def get_mcp_server_statuses_cache_path() -> Path:
3834
return Path.home() / CYCODE_CONFIGURATION_DIRECTORY / MCP_SERVER_STATUSES_FILE_NAME
3935

4036

41-
def parse_status(raw_status: object) -> McpServerAuthorizationStatus:
42-
"""An unknown status reads as Unreviewed: no decision was made on the server."""
43-
lowered = str(raw_status or '').lower()
44-
for status in McpServerAuthorizationStatus:
45-
if status.value.lower() == lowered:
46-
return status
47-
return McpServerAuthorizationStatus.UNREVIEWED
48-
49-
5037
def is_enforced(status: Optional[McpServerAuthorizationStatus]) -> bool:
5138
"""``status`` is None when the platform never saw the server."""
5239
return status == McpServerAuthorizationStatus.UNAUTHORIZED
5340

5441

5542
@dataclass
5643
class McpServerStatuses:
57-
servers: InitVar[list]
44+
servers: list[McpServerStatus]
5845
fetched_at: float
5946
tenant_id: Optional[str] = None
6047
ttl_seconds: float = DEFAULT_TTL_SECONDS
6148
_by_alias: dict = field(init=False, repr=False)
6249

63-
def __post_init__(self, servers: list) -> None:
50+
def __post_init__(self) -> None:
6451
# The platform matches aliases case-insensitively, so the CLI does too.
6552
self._by_alias = {}
66-
for server in servers:
67-
if not isinstance(server, dict) or not server.get('alias'):
53+
for server in self.servers:
54+
if not server.alias:
6855
continue
69-
key = str(server['alias']).lower()
70-
status = parse_status(server.get('status'))
56+
key = server.alias.lower()
7157
known = self._by_alias.get(key)
72-
if known is None or _RESTRICTIVENESS[status] > _RESTRICTIVENESS[known]:
73-
self._by_alias[key] = status
58+
if known is None or _RESTRICTIVENESS[server.status] > _RESTRICTIVENESS[known]:
59+
self._by_alias[key] = server.status
7460

7561
def status_of(self, alias: str) -> Optional[McpServerAuthorizationStatus]:
7662
return self._by_alias.get(alias.lower())
7763

78-
def is_expired(self) -> bool:
79-
return time.time() - self.fetched_at > self.ttl_seconds
8064

81-
def needs_refresh(self, tenant_id: Optional[str]) -> bool:
82-
return self.is_expired() or self.tenant_id != tenant_id
65+
class McpServerStatusesSchema(Schema):
66+
class Meta:
67+
unknown = EXCLUDE
68+
69+
servers = fields.List(fields.Nested(McpServerStatusSchema), required=True)
70+
fetched_at = fields.Float(required=True)
71+
tenant_id = fields.String(allow_none=True, load_default=None)
72+
ttl_seconds = fields.Float(allow_none=True, load_default=None)
73+
74+
@post_load
75+
def build_dto(self, data: dict[str, Any], **_) -> McpServerStatuses:
76+
data['ttl_seconds'] = data['ttl_seconds'] or DEFAULT_TTL_SECONDS
77+
return McpServerStatuses(**data)
8378

8479

85-
def save_mcp_server_statuses(servers: list, tenant_id: Optional[str], ttl_seconds: float = DEFAULT_TTL_SECONDS) -> None:
80+
def save_mcp_server_statuses(
81+
servers: list[McpServerStatus], tenant_id: Optional[str], ttl_seconds: float = DEFAULT_TTL_SECONDS
82+
) -> None:
8683
path = get_mcp_server_statuses_cache_path()
87-
content = {
88-
'fetched_at': time.time(),
89-
'tenant_id': tenant_id,
90-
'ttl_seconds': ttl_seconds,
91-
'servers': servers,
92-
}
84+
statuses = McpServerStatuses(servers=servers, fetched_at=time.time(), tenant_id=tenant_id, ttl_seconds=ttl_seconds)
9385
try:
9486
path.parent.mkdir(parents=True, exist_ok=True)
95-
atomic_write_text(str(path), json.dumps(content))
87+
atomic_write_text(str(path), json.dumps(McpServerStatusesSchema().dump(statuses)))
9688
except Exception as e:
9789
logger.debug('Failed to save MCP server statuses cache', exc_info=e)
9890

@@ -104,16 +96,7 @@ def load_mcp_server_statuses() -> Optional[McpServerStatuses]:
10496

10597
try:
10698
with open(path, encoding='UTF-8') as file:
107-
content = json.load(file)
108-
servers = content['servers']
109-
if not isinstance(servers, list):
110-
raise ValueError('servers is not a list')
111-
return McpServerStatuses(
112-
servers=servers,
113-
fetched_at=float(content['fetched_at']),
114-
tenant_id=content.get('tenant_id'),
115-
ttl_seconds=float(content.get('ttl_seconds') or DEFAULT_TTL_SECONDS),
116-
)
99+
return McpServerStatusesSchema().load(json.load(file))
117100
except Exception as e:
118101
logger.warning('MCP server statuses cache is corrupt and will be moved aside', exc_info=e)
119102
quarantine_corrupt_file(str(path))

‎cycode/cli/apps/ai_guardrails/session_start_command.py‎

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,7 @@
2121
load_guardrail_config,
2222
save_guardrail_config,
2323
)
24-
from cycode.cli.apps.ai_guardrails.scan.mcp_server_status import (
25-
load_mcp_server_statuses,
26-
save_mcp_server_statuses,
27-
)
24+
from cycode.cli.apps.ai_guardrails.scan.mcp_server_status import save_mcp_server_statuses
2825
from cycode.cli.apps.ai_guardrails.scan.types import BlockReason
2926
from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse
3027
from cycode.cli.apps.auth.auth_common import get_authorization_info
@@ -59,14 +56,21 @@ def _session_context_digest(report: dict) -> str:
5956
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
6057

6158

62-
def _should_skip_report(digest: str, tenant_id: Optional[str]) -> bool:
59+
def _report_ttl_seconds(config: Optional[GuardrailConfig]) -> float:
60+
"""The response carries the MCP server statuses, so the guardrail's TTL applies while it is enabled."""
61+
if config is None or config.is_off_for_every_agent(BlockReason.UNAUTHORIZED_MCP_SERVER):
62+
return _SESSION_CONTEXT_TTL_SECONDS
63+
return config.ttl_seconds
64+
65+
66+
def _should_skip_report(digest: str, tenant_id: Optional[str], ttl_seconds: float) -> bool:
6367
"""Skip when the same payload was already sent for this tenant and the TTL hasn't expired."""
6468
try:
6569
cache = json.loads(_session_context_cache_path().read_text(encoding='utf-8'))
6670
return (
6771
cache.get('hash') == digest
6872
and cache.get('tenant_id') == tenant_id
69-
and time.time() - float(cache.get('sent_at', 0)) < _SESSION_CONTEXT_TTL_SECONDS
73+
and time.time() - float(cache.get('sent_at', 0)) < ttl_seconds
7074
)
7175
except Exception:
7276
# Missing/corrupt cache reads as a miss - over-sending is harmless
@@ -84,13 +88,6 @@ def _save_report_cache(digest: str, tenant_id: Optional[str]) -> None:
8488
logger.debug('Failed to write session context cache', exc_info=e)
8589

8690

87-
def _needs_mcp_server_statuses(config: Optional[GuardrailConfig], tenant_id: Optional[str]) -> bool:
88-
if config is None or config.is_off_for_every_agent(BlockReason.UNAUTHORIZED_MCP_SERVER):
89-
return False
90-
cached = load_mcp_server_statuses()
91-
return cached is None or cached.needs_refresh(tenant_id)
92-
93-
9491
def _report_session_context(
9592
ai_client: 'AISecurityManagerClient',
9693
user_email: Optional[str],
@@ -101,7 +98,7 @@ def _report_session_context(
10198
10299
The device context is always reported. MCP configs and skills are collected from every
103100
registered IDE, not just the triggering one. Unchanged payloads are skipped via a hash cache
104-
until the TTL expires, unless the MCP server statuses the response carries are needed.
101+
until the TTL expires.
105102
"""
106103
try:
107104
config_files, enabled_plugins = collect_all_session_contexts()
@@ -121,7 +118,7 @@ def _report_session_context(
121118
}
122119

123120
digest = _session_context_digest(report)
124-
if _should_skip_report(digest, tenant_id) and not _needs_mcp_server_statuses(config, tenant_id):
121+
if _should_skip_report(digest, tenant_id, _report_ttl_seconds(config)):
125122
logger.debug('Session context unchanged; skipping report')
126123
return
127124

‎cycode/cyclient/ai_security_manager_client.py‎

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
"""Client for AI Security Manager service."""
22

3-
from typing import TYPE_CHECKING, NamedTuple, Optional
3+
from typing import TYPE_CHECKING, Optional
44

55
from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError
66
from cycode.cyclient.cycode_client_base import CycodeClientBase
77
from cycode.cyclient.logger import logger
8+
from cycode.cyclient.models import SessionContextResponse, SessionContextResponseSchema
89

910
if TYPE_CHECKING:
1011
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
1112
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason
1213
from cycode.cyclient.ai_security_manager_service_config import AISecurityManagerServiceConfigBase
1314

1415

15-
class SessionContextResponse(NamedTuple):
16-
mcp_servers: Optional[list] # [{alias, normalized_id, status}]; None when missing or malformed
17-
18-
1916
class AISecurityManagerClient:
2017
"""Client for interacting with AI Security Manager service."""
2118

@@ -140,10 +137,7 @@ def report_session_context(
140137
return None
141138

142139
try:
143-
mcp_servers = response.json().get('mcp_servers')
144-
if not isinstance(mcp_servers, list):
145-
raise ValueError('mcp_servers is not a list')
140+
return SessionContextResponseSchema().load(response.json())
146141
except Exception as e:
147142
logger.debug('Failed to parse the session context response', exc_info=e)
148-
mcp_servers = None
149-
return SessionContextResponse(mcp_servers=mcp_servers)
143+
return SessionContextResponse()

‎cycode/cyclient/models.py‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from dataclasses import dataclass
2+
from enum import Enum
23
from typing import Any, Optional
34

4-
from marshmallow import EXCLUDE, Schema, fields, post_load
5+
from marshmallow import EXCLUDE, Schema, fields, missing, post_load
56

67

78
class Detection(Schema):
@@ -578,3 +579,60 @@ class Meta:
578579
@post_load
579580
def build_dto(self, data: dict[str, Any], **_) -> 'ScanConfiguration':
580581
return ScanConfiguration(**data)
582+
583+
584+
class McpServerAuthorizationStatus(str, Enum):
585+
AUTHORIZED = 'Authorized'
586+
UNREVIEWED = 'Unreviewed'
587+
UNAUTHORIZED = 'Unauthorized'
588+
589+
@classmethod
590+
def parse(cls, raw_status: object) -> 'McpServerAuthorizationStatus':
591+
"""An unknown status reads as Unreviewed: no decision was made on the server."""
592+
lowered = str(raw_status or '').lower()
593+
return next((status for status in cls if status.value.lower() == lowered), cls.UNREVIEWED)
594+
595+
596+
class McpServerAuthorizationStatusField(fields.Field):
597+
def _serialize(self, value: Optional[McpServerAuthorizationStatus], *_, **__) -> Optional[str]:
598+
return value.value if value is not None else None
599+
600+
def deserialize(self, value: object, *_, **__) -> McpServerAuthorizationStatus:
601+
return McpServerAuthorizationStatus.parse(None if value is missing else value)
602+
603+
604+
@dataclass
605+
class McpServerStatus:
606+
alias: Optional[str] = None
607+
normalized_id: Optional[str] = None
608+
status: McpServerAuthorizationStatus = McpServerAuthorizationStatus.UNREVIEWED
609+
610+
611+
class McpServerStatusSchema(Schema):
612+
class Meta:
613+
unknown = EXCLUDE
614+
615+
alias = fields.String(allow_none=True, load_default=None)
616+
normalized_id = fields.String(allow_none=True, load_default=None)
617+
status = McpServerAuthorizationStatusField()
618+
619+
@post_load
620+
def build_dto(self, data: dict[str, Any], **_) -> McpServerStatus:
621+
return McpServerStatus(**data)
622+
623+
624+
@dataclass
625+
class SessionContextResponse:
626+
# None when the response carries no (or malformed) MCP server statuses.
627+
mcp_servers: Optional[list[McpServerStatus]] = None
628+
629+
630+
class SessionContextResponseSchema(Schema):
631+
class Meta:
632+
unknown = EXCLUDE
633+
634+
mcp_servers = fields.List(fields.Nested(McpServerStatusSchema), allow_none=True, load_default=None)
635+
636+
@post_load
637+
def build_dto(self, data: dict[str, Any], **_) -> SessionContextResponse:
638+
return SessionContextResponse(**data)

‎tests/cli/commands/ai_guardrails/scan/test_handlers.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason
2929
from cycode.cli.apps.ai_guardrails.scan.utils import MAX_VIOLATION_DETAIL_LINES, build_violation_summary
3030
from cycode.cli.models import Document, DocumentDetections, LocalScanResult
31-
from cycode.cyclient.models import Detection
31+
from cycode.cyclient.models import Detection, McpServerAuthorizationStatus, McpServerStatus
3232

3333

3434
@pytest.fixture
@@ -604,7 +604,8 @@ def _mcp_payload(server: Optional[str] = 'github') -> AIHookPayload:
604604

605605

606606
def _cached_statuses(*rows: tuple[str, str]) -> McpServerStatuses:
607-
return McpServerStatuses(servers=[{'alias': a, 'status': s} for a, s in rows], fetched_at=time.time())
607+
servers = [McpServerStatus(alias=a, status=McpServerAuthorizationStatus.parse(s)) for a, s in rows]
608+
return McpServerStatuses(servers=servers, fetched_at=time.time())
608609

609610

610611
def _reported_event(mock_ctx: MagicMock) -> tuple[AIHookOutcome, Optional[BlockReason]]:

0 commit comments

Comments
 (0)