Skip to content

Commit 3d2a602

Browse files
Altruistusclaude
andcommitted
CM-74352: Treat not-reviewed AI entities as unauthorized
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 2b6ff48 commit 3d2a602

8 files changed

Lines changed: 106 additions & 31 deletions

File tree

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,12 +331,13 @@ def _check_mcp_server_authorization(payload: AIHookPayload, policy: dict) -> Opt
331331
payload.mcp_server_name = server.alias
332332

333333
status = server.status if server is not None else None
334-
if not is_enforced(status):
334+
treat_unreviewed = statuses.treat_unreviewed_as_unauthorized
335+
if not is_enforced(status, treat_unreviewed):
335336
return None
336337

337338
logger.debug(
338339
'MCP server is not authorized, %s',
339-
{'mcp_server_name': payload.mcp_server_name, 'status': status},
340+
{'mcp_server_name': payload.mcp_server_name, 'status': status, 'treat_unreviewed': treat_unreviewed},
340341
)
341342
server_name = payload.mcp_server_name
342343
return _PreScanFinding(

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
MCP_SERVER_STATUSES_FILE_NAME = 'ai-guardrails-mcp-servers.json'
2222

23+
TREAT_UNREVIEWED_AS_UNAUTHORIZED_KEY = 'should_treat_unreviewed_as_unauthorized'
24+
2325
# Claude Code names plugin servers `plugin_<plugin>_<server>` in tool names; the platform stores `<server>`.
2426
_PLUGIN_ALIAS_PREFIX = 'plugin_'
2527

@@ -56,9 +58,11 @@ def _normalize_alias(alias: str) -> str:
5658
return re.sub(r'[^a-z0-9_-]', '_', alias.lower())
5759

5860

59-
def is_enforced(status: Optional[McpServerAuthorizationStatus]) -> bool:
61+
def is_enforced(status: Optional[McpServerAuthorizationStatus], treat_unreviewed: bool) -> bool:
6062
"""``status`` is None when the platform never saw the server."""
61-
return status == McpServerAuthorizationStatus.UNAUTHORIZED
63+
if status == McpServerAuthorizationStatus.UNAUTHORIZED:
64+
return True
65+
return treat_unreviewed and status != McpServerAuthorizationStatus.AUTHORIZED
6266

6367

6468
class McpServerMatch(NamedTuple):
@@ -72,6 +76,7 @@ class McpServerStatuses:
7276
fetched_at: float
7377
tenant_id: Optional[str] = None
7478
ttl_seconds: float = DEFAULT_TTL_SECONDS
79+
treat_unreviewed_as_unauthorized: bool = False
7580
_by_alias: dict = field(init=False, repr=False)
7681

7782
def __post_init__(self) -> None:
@@ -110,12 +115,18 @@ def needs_refresh(self, tenant_id: Optional[str]) -> bool:
110115
return self.is_expired() or self.tenant_id != tenant_id
111116

112117

113-
def save_mcp_server_statuses(servers: list, tenant_id: Optional[str], ttl_seconds: float = DEFAULT_TTL_SECONDS) -> None:
118+
def save_mcp_server_statuses(
119+
servers: list,
120+
tenant_id: Optional[str],
121+
ttl_seconds: float = DEFAULT_TTL_SECONDS,
122+
treat_unreviewed_as_unauthorized: bool = False,
123+
) -> None:
114124
path = get_mcp_server_statuses_cache_path()
115125
content = {
116126
'fetched_at': time.time(),
117127
'tenant_id': tenant_id,
118128
'ttl_seconds': ttl_seconds,
129+
TREAT_UNREVIEWED_AS_UNAUTHORIZED_KEY: treat_unreviewed_as_unauthorized,
119130
'servers': servers,
120131
}
121132
try:
@@ -141,6 +152,7 @@ def load_mcp_server_statuses() -> Optional[McpServerStatuses]:
141152
fetched_at=float(content['fetched_at']),
142153
tenant_id=content.get('tenant_id'),
143154
ttl_seconds=float(content.get('ttl_seconds') or DEFAULT_TTL_SECONDS),
155+
treat_unreviewed_as_unauthorized=content.get(TREAT_UNREVIEWED_AS_UNAUTHORIZED_KEY) is True,
144156
)
145157
except Exception as e:
146158
logger.warning('MCP server statuses cache is corrupt and will be moved aside', exc_info=e)

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,5 +222,10 @@ def _sync_mcp_server_statuses(
222222

223223
response = ai_client.get_mcp_server_statuses()
224224
if response is not None:
225-
save_mcp_server_statuses(response.servers, tenant_id, config.ttl_seconds)
225+
save_mcp_server_statuses(
226+
response.servers,
227+
tenant_id,
228+
config.ttl_seconds,
229+
response.should_treat_unreviewed_as_unauthorized,
230+
)
226231
logger.debug('MCP server statuses cache updated')

‎cycode/cyclient/ai_security_manager_client.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
class McpServerStatusesResponse(NamedTuple):
1717
servers: list # [{alias, normalized_id, status}]
18+
# Strict mode: Unreviewed servers count as Unauthorized.
19+
should_treat_unreviewed_as_unauthorized: bool
1820

1921

2022
class AISecurityManagerClient:
@@ -118,7 +120,10 @@ def get_mcp_server_statuses(self) -> Optional[McpServerStatusesResponse]:
118120
servers = body.get('servers')
119121
if not isinstance(servers, list):
120122
raise ValueError('servers is not a list')
121-
return McpServerStatusesResponse(servers=servers)
123+
return McpServerStatusesResponse(
124+
servers=servers,
125+
should_treat_unreviewed_as_unauthorized=body.get('should_treat_unreviewed_as_unauthorized') is True,
126+
)
122127
except Exception as e:
123128
logger.debug('Failed to fetch MCP server statuses', exc_info=e)
124129
return None

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

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -603,8 +603,12 @@ def _mcp_payload(server: Optional[str] = 'github') -> AIHookPayload:
603603
)
604604

605605

606-
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())
606+
def _cached_statuses(*rows: tuple[str, str], treat_unreviewed: bool = False) -> McpServerStatuses:
607+
return McpServerStatuses(
608+
servers=[{'alias': a, 'status': s} for a, s in rows],
609+
fetched_at=time.time(),
610+
treat_unreviewed_as_unauthorized=treat_unreviewed,
611+
)
608612

609613

610614
def _reported_event(mock_ctx: MagicMock) -> tuple[AIHookOutcome, Optional[BlockReason]]:
@@ -662,16 +666,20 @@ def test_unauthorized_mcp_server_report_then_secret_block_takes_over(
662666

663667

664668
@pytest.mark.parametrize(
665-
('rows', 'server', 'expected_action'),
669+
('rows', 'treat_unreviewed', 'server', 'expected_action'),
666670
[
667-
# Only explicitly Unauthorized servers are enforced.
668-
((('github', 'Unreviewed'),), 'github', DecisionAction.ALLOW),
669-
((('github', 'Authorized'),), 'github', DecisionAction.ALLOW),
670-
((), 'github', DecisionAction.ALLOW),
671+
# Default policy: only explicitly Unauthorized servers.
672+
((('github', 'Unreviewed'),), False, 'github', DecisionAction.ALLOW),
673+
((('github', 'Authorized'),), False, 'github', DecisionAction.ALLOW),
674+
((), False, 'github', DecisionAction.ALLOW),
675+
# Strict: anything not Authorized, a server the platform never saw included.
676+
((('github', 'Unreviewed'),), True, 'github', DecisionAction.DENY),
677+
((), True, 'github', DecisionAction.DENY),
678+
((('github', 'Authorized'),), True, 'github', DecisionAction.ALLOW),
671679
# One alias, several servers: the most restrictive status wins.
672-
((('github', 'Authorized'), ('GitHub', 'Unauthorized')), 'github', DecisionAction.DENY),
673-
# No server name to check: fail open.
674-
((('github', 'Unauthorized'),), None, DecisionAction.ALLOW),
680+
((('github', 'Authorized'), ('GitHub', 'Unauthorized')), False, 'github', DecisionAction.DENY),
681+
# No server name to check: fail open, even in strict mode.
682+
((), True, None, DecisionAction.ALLOW),
675683
],
676684
)
677685
@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets', return_value=ScanOutcome())
@@ -682,10 +690,11 @@ def test_unauthorized_mcp_server_enforcement(
682690
mock_ctx: MagicMock,
683691
default_policy: dict[str, Any],
684692
rows: tuple,
693+
treat_unreviewed: bool,
685694
server: Optional[str],
686695
expected_action: DecisionAction,
687696
) -> None:
688-
mock_statuses.return_value = _cached_statuses(*rows)
697+
mock_statuses.return_value = _cached_statuses(*rows, treat_unreviewed=treat_unreviewed)
689698
policy = _server_check_policy(default_policy)
690699

691700
result = handle_before_mcp_execution(mock_ctx, _mcp_payload(server), policy)

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

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the MCP server authorization status cache."""
22

3+
import json
34
import time
45
from pathlib import Path
56
from typing import Optional
@@ -56,16 +57,20 @@ def test_parse_status_is_case_insensitive_and_unknown_reads_unreviewed(
5657

5758

5859
@pytest.mark.parametrize(
59-
('status', 'expected'),
60+
('status', 'treat_unreviewed', 'expected'),
6061
[
61-
(_UNAUTHORIZED, True),
62-
(_UNREVIEWED, False),
63-
(_AUTHORIZED, False),
64-
(None, False),
62+
(_UNAUTHORIZED, False, True),
63+
(_UNREVIEWED, False, False),
64+
(_AUTHORIZED, False, False),
65+
(None, False, False),
66+
(_UNAUTHORIZED, True, True),
67+
(_UNREVIEWED, True, True),
68+
(None, True, True),
69+
(_AUTHORIZED, True, False),
6570
],
6671
)
67-
def test_is_enforced(status: Optional[McpServerAuthorizationStatus], expected: bool) -> None:
68-
assert is_enforced(status) is expected
72+
def test_is_enforced(status: Optional[McpServerAuthorizationStatus], treat_unreviewed: bool, expected: bool) -> None:
73+
assert is_enforced(status, treat_unreviewed) is expected
6974

7075

7176
def test_match_is_case_insensitive_and_returns_the_stored_alias() -> None:
@@ -125,6 +130,28 @@ def test_save_and_load_round_trip() -> None:
125130
assert statuses.ttl_seconds == 60
126131
assert statuses.needs_refresh('tenant-a') is False
127132
assert statuses.needs_refresh('tenant-b') is True
133+
assert statuses.treat_unreviewed_as_unauthorized is False
134+
135+
136+
def test_save_and_load_round_trip_keeps_the_strict_mode_flag() -> None:
137+
save_mcp_server_statuses([], 'tenant-a', ttl_seconds=60, treat_unreviewed_as_unauthorized=True)
138+
139+
statuses = load_mcp_server_statuses()
140+
141+
assert statuses is not None
142+
assert statuses.treat_unreviewed_as_unauthorized is True
143+
content = json.loads(get_mcp_server_statuses_cache_path().read_text())
144+
assert content['should_treat_unreviewed_as_unauthorized'] is True
145+
146+
147+
def test_cache_written_before_the_strict_mode_flag_loads_as_not_strict(fs: FakeFilesystem) -> None:
148+
content = {'fetched_at': time.time(), 'tenant_id': 'tenant-a', 'ttl_seconds': 60, 'servers': []}
149+
fs.create_file(str(get_mcp_server_statuses_cache_path()), contents=json.dumps(content))
150+
151+
statuses = load_mcp_server_statuses()
152+
153+
assert statuses is not None
154+
assert statuses.treat_unreviewed_as_unauthorized is False
128155

129156

130157
def test_expired_cache_needs_refresh() -> None:

‎tests/cli/commands/ai_guardrails/test_session_start_command.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,7 @@ def _guardrail_config_with_mcp_server(agents: dict) -> GuardrailConfig:
732732

733733

734734
_SERVERS = [{'alias': 'github', 'normalized_id': 'pkg:gh', 'status': 'Unauthorized'}]
735-
_STATUSES_RESPONSE = McpServerStatusesResponse(servers=_SERVERS)
735+
_STATUSES_RESPONSE = McpServerStatusesResponse(servers=_SERVERS, should_treat_unreviewed_as_unauthorized=True)
736736

737737

738738
@pytest.mark.parametrize(
@@ -761,8 +761,8 @@ def test_mcp_server_statuses_fetched_when_on_for_some_agent(mock_save_mcp_server
761761

762762
_session_start_mod._sync_mcp_server_statuses(ai_client, config, 'tenant-a', force=False)
763763

764-
# Cached with the guardrail config's TTL.
765-
mock_save_mcp_server_statuses.assert_called_once_with(_SERVERS, 'tenant-a', 600)
764+
# Cached with the guardrail config's TTL and the policy's strict-mode flag.
765+
mock_save_mcp_server_statuses.assert_called_once_with(_SERVERS, 'tenant-a', 600, True)
766766

767767

768768
def test_fresh_mcp_server_statuses_skip_the_fetch_unless_forced(
@@ -824,7 +824,7 @@ def test_session_start_fetches_mcp_server_statuses_after_reporting_the_context(
824824
session_start_command(mock_ctx, ide='cursor')
825825

826826
assert calls == ['report', 'statuses']
827-
mock_save_mcp_server_statuses.assert_called_once_with(_SERVERS, 'tenant-a', 600)
827+
mock_save_mcp_server_statuses.assert_called_once_with(_SERVERS, 'tenant-a', 600, True)
828828

829829
# The unchanged context is not re-reported, so the fresh cache is kept.
830830
with patch('sys.stdin', new=StringIO(json.dumps({'conversation_id': 'conv-2'}))):

‎tests/cyclient/test_ai_security_manager_client.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome
88
from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient
99

10+
_MISSING = object()
11+
1012

1113
def _build_client() -> tuple[AISecurityManagerClient, MagicMock]:
1214
http_client = MagicMock()
@@ -113,15 +115,29 @@ def test_create_event_without_a_conversation_posts_nothing() -> None:
113115
http_client.post.assert_not_called()
114116

115117

116-
def test_get_mcp_server_statuses_returns_the_rows() -> None:
118+
@pytest.mark.parametrize(
119+
('flag', 'expected'),
120+
[
121+
(True, True),
122+
(False, False),
123+
(None, False),
124+
('true', False),
125+
(_MISSING, False),
126+
],
127+
)
128+
def test_get_mcp_server_statuses_returns_the_rows_and_the_strict_mode_flag(flag: object, expected: bool) -> None:
117129
client, http_client = _build_client()
118130
servers = [{'alias': 'github', 'normalized_id': 'pkg:gh', 'status': 'Unauthorized'}]
119-
http_client.get.return_value.json.return_value = {'servers': servers}
131+
body: dict = {'servers': servers}
132+
if flag is not _MISSING:
133+
body['should_treat_unreviewed_as_unauthorized'] = flag
134+
http_client.get.return_value.json.return_value = body
120135

121136
response = client.get_mcp_server_statuses()
122137

123138
assert response is not None
124139
assert response.servers == servers
140+
assert response.should_treat_unreviewed_as_unauthorized is expected
125141
http_client.get.assert_called_once_with('v4/ai-security/authorization/mcp/servers')
126142

127143

0 commit comments

Comments
 (0)