Skip to content

Commit 5fd3f5b

Browse files
Altruistusclaude
andcommitted
CM-73403: Add the unauthorized MCP server guardrail
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 367b0c3 commit 5fd3f5b

15 files changed

Lines changed: 1016 additions & 30 deletions

‎cycode/cli/apps/ai_guardrails/ides/cursor.py‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,44 @@ def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict
6464
return None
6565

6666

67+
def _cursor_mcp_config_paths(workspace_roots: object) -> list[Path]:
68+
roots = workspace_roots if isinstance(workspace_roots, list) else []
69+
paths = [Path(root) / _REPO_SUBDIR / _MCP_CONFIG_FILENAME for root in roots if isinstance(root, str) and root]
70+
paths.append(_cursor_mcp_config_path())
71+
return paths
72+
73+
74+
def _server_command_line(server: dict) -> Optional[str]:
75+
command = server.get('command')
76+
if not isinstance(command, str) or not command:
77+
return None
78+
args = server.get('args')
79+
return ' '.join([command, *(str(arg) for arg in args)]) if isinstance(args, list) else command
80+
81+
82+
def _resolve_mcp_server_name(raw_payload: dict) -> Optional[str]:
83+
"""Cursor sends the server's url or command, but the platform knows it by its mcp.json entry name."""
84+
url = raw_payload.get('url')
85+
command = raw_payload.get('command')
86+
if not url and not command:
87+
return None
88+
89+
lowered_command = command.lower() if command else None
90+
for config_path in _cursor_mcp_config_paths(raw_payload.get('workspace_roots')):
91+
servers = (_load_cursor_mcp_config(config_path) or {}).get('mcpServers')
92+
if not isinstance(servers, dict):
93+
continue
94+
for name, server in servers.items():
95+
if not isinstance(server, dict):
96+
continue
97+
if url and server.get('url') == url:
98+
return name
99+
if command and (command == _server_command_line(server) or lowered_command == name.lower()):
100+
return name
101+
102+
return command
103+
104+
67105
class Cursor(IDE):
68106
name: ClassVar[str] = 'cursor'
69107
display_name: ClassVar[str] = 'Cursor'
@@ -95,7 +133,9 @@ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
95133
ide_version=raw_payload.get('cursor_version'),
96134
prompt=raw_payload.get('prompt', ''),
97135
file_path=raw_payload.get('file_path') or raw_payload.get('path'),
98-
mcp_server_name=raw_payload.get('command'),
136+
mcp_server_name=(
137+
_resolve_mcp_server_name(raw_payload) if canonical_event == AiHookEventType.MCP_EXECUTION else None
138+
),
99139
mcp_tool_name=raw_payload.get('tool_name') or raw_payload.get('tool'),
100140
mcp_arguments=(raw_payload.get('arguments') or raw_payload.get('tool_input') or raw_payload.get('input')),
101141
)

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

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
session-start fetches the tenant's resolved guardrail config from the platform and writes it
44
here; scans only read. Per-agent modes and sensitive-path globs are platform-owned - local
55
policy files never carry them. An absent or corrupt cache means built-in defaults (Report
6-
everywhere + the default globs), always synchronous.
6+
everywhere + the default globs, with the unauthorized MCP server guardrail Off), always synchronous.
77
"""
88

99
import json
@@ -23,7 +23,7 @@
2323

2424
GUARDRAILS_CONFIG_FILE_NAME = 'ai-guardrails-config.json'
2525

26-
_DEFAULT_TTL_SECONDS = 900
26+
DEFAULT_TTL_SECONDS = 900
2727

2828
# Guardrail keys are the CLI's block-reason vocabulary. Anything else in the payload (a future
2929
# guardrail this CLI doesn't implement) is ignored - unknown config must never fail closed.
@@ -34,14 +34,24 @@
3434
BlockReason.SECRETS_IN_FILE,
3535
BlockReason.SENSITIVE_PATH,
3636
BlockReason.SECRETS_IN_MCP_ARGS,
37+
BlockReason.UNAUTHORIZED_MCP_SERVER,
3738
)
3839
)
3940

41+
# Off by default (not Report), so they never start enforcing on a tenant that hasn't configured them.
42+
_DEFAULT_OFF_GUARDRAIL_KEYS = frozenset((BlockReason.UNAUTHORIZED_MCP_SERVER.value,))
43+
4044

4145
def get_config_cache_path() -> Path:
4246
return Path.home() / CYCODE_CONFIGURATION_DIRECTORY / GUARDRAILS_CONFIG_FILE_NAME
4347

4448

49+
def default_mode_for(guardrail_key: str) -> str:
50+
if guardrail_key in _DEFAULT_OFF_GUARDRAIL_KEYS:
51+
return GuardrailCellMode.OFF.value
52+
return GuardrailCellMode.REPORT.value
53+
54+
4555
def _default_sensitive_globs() -> list:
4656
return list(DEFAULT_SENSITIVE_PATH_GLOBS)
4757

@@ -60,10 +70,20 @@ def __post_init__(self) -> None:
6070
if guardrail.get('key') in _KNOWN_GUARDRAIL_KEYS
6171
}
6272

73+
@property
74+
def ttl_seconds(self) -> float:
75+
return self.payload.get('ttl_seconds') or DEFAULT_TTL_SECONDS
76+
77+
def _agents(self, guardrail_key: str) -> dict:
78+
return (self._guardrails.get(guardrail_key) or {}).get('agents') or {}
79+
6380
def mode_for(self, guardrail_key: str, ide_name: Optional[str]) -> str:
6481
"""The platform keys the cells by our --ide names, so the lookup is direct."""
65-
agents = (self._guardrails.get(guardrail_key) or {}).get('agents') or {}
66-
return str(agents.get((ide_name or '').lower(), GuardrailCellMode.REPORT.value)).lower()
82+
agents = self._agents(guardrail_key)
83+
return str(agents.get((ide_name or '').lower(), default_mode_for(guardrail_key))).lower()
84+
85+
def is_off_for_every_agent(self, guardrail_key: str) -> bool:
86+
return all(str(mode).lower() == GuardrailCellMode.OFF for mode in self._agents(guardrail_key).values())
6787

6888
def _modes_for_event(self, event_name: str, ide_name: Optional[str]) -> list:
6989
return [
@@ -87,8 +107,7 @@ def sensitive_globs(self) -> list:
87107
return globs if isinstance(globs, list) and globs else _default_sensitive_globs()
88108

89109
def is_expired(self) -> bool:
90-
ttl = self.payload.get('ttl_seconds') or _DEFAULT_TTL_SECONDS
91-
return time.time() - self.fetched_at > ttl
110+
return time.time() - self.fetched_at > self.ttl_seconds
92111

93112
def needs_refresh(self, tenant_id: Optional[str]) -> bool:
94113
"""Expired, or fetched for another tenant (the user switched tenants since)."""
@@ -99,14 +118,14 @@ def apply_platform_config(policy: dict, config: Optional[GuardrailConfig], ide_n
99118
"""Overlay the platform-owned enforcement config onto the local knobs-only policy.
100119
101120
The platform is the only mode source: no cache (cold start) means the built-in defaults -
102-
Report everywhere with the default globs - which equal an unconfigured tenant's platform
103-
config, so behaviour is uniform either way. Each matrix cell lands on its own per-feature
104-
action, so the two FileRead guardrails (content scan vs. sensitive path) keep independent modes.
121+
Report everywhere (unauthorized MCP server Off) with the default globs - which equal an unconfigured
122+
tenant's platform config, so behaviour is uniform either way. Each matrix cell lands on its own
123+
per-feature action, so guardrails sharing an event (e.g. the two FileRead ones) keep independent modes.
105124
An all-Off event never reaches here at all: scan_command skips it.
106125
"""
107126

108127
def cell(guardrail_key: str) -> str:
109-
return config.mode_for(guardrail_key, ide_name) if config is not None else GuardrailCellMode.REPORT.value
128+
return config.mode_for(guardrail_key, ide_name) if config is not None else default_mode_for(guardrail_key)
110129

111130
def action(guardrail_key: str) -> str:
112131
return PolicyMode.BLOCK.value if cell(guardrail_key) == GuardrailCellMode.BLOCK else PolicyMode.WARN.value
@@ -123,7 +142,11 @@ def action(guardrail_key: str) -> str:
123142
)
124143
file_read['path_action'] = action(BlockReason.SENSITIVE_PATH)
125144

126-
policy.setdefault('mcp', {})['action'] = action(BlockReason.SECRETS_IN_MCP_ARGS)
145+
mcp = policy.setdefault('mcp', {})
146+
mcp['scan_args'] = cell(BlockReason.SECRETS_IN_MCP_ARGS) != GuardrailCellMode.OFF
147+
mcp['action'] = action(BlockReason.SECRETS_IN_MCP_ARGS)
148+
mcp['check_server'] = cell(BlockReason.UNAUTHORIZED_MCP_SERVER) != GuardrailCellMode.OFF
149+
mcp['server_action'] = action(BlockReason.UNAUTHORIZED_MCP_SERVER)
127150

128151

129152
def save_guardrail_config(payload: dict, tenant_id: Optional[str]) -> None:

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

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode
2323
from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
24+
from cycode.cli.apps.ai_guardrails.scan.mcp_server_status import is_enforced, load_mcp_server_statuses
2425
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
2526
from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
2627
from cycode.cli.apps.ai_guardrails.scan.types import (
@@ -217,6 +218,16 @@ class _ArgScanFeature:
217218
deny_agent_message: str
218219
ask_message: Callable[[str], str]
219220
ask_agent_message: str
221+
scan_enabled: bool = True
222+
223+
224+
class _PreScanFinding(NamedTuple):
225+
"""A finding on the call itself (e.g. an unauthorized MCP server), made before the text scan."""
226+
227+
block_reason: BlockReason
228+
mode: GuardrailsMode
229+
deny_message: str
230+
deny_agent_message: str
220231

221232

222233
def _handle_arg_scan(
@@ -225,8 +236,12 @@ def _handle_arg_scan(
225236
policy: dict,
226237
feature: _ArgScanFeature,
227238
scan_text: str,
239+
pre_scan_finding: Optional[_PreScanFinding] = None,
228240
) -> HookDecision:
229-
"""Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events."""
241+
"""Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events.
242+
243+
A Report-mode pre-scan finding still scans, and a secret found then takes over the response.
244+
"""
230245
ai_client = ctx.obj['ai_security_client']
231246

232247
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
@@ -239,6 +254,18 @@ def _handle_arg_scan(
239254
error_message = None
240255

241256
try:
257+
if pre_scan_finding is not None:
258+
block_reason = pre_scan_finding.block_reason
259+
if pre_scan_finding.mode == GuardrailsMode.BLOCK:
260+
outcome = AIHookOutcome.BLOCKED
261+
return HookDecision.deny(
262+
feature.event_type, pre_scan_finding.deny_message, pre_scan_finding.deny_agent_message
263+
)
264+
outcome = AIHookOutcome.WARNED
265+
266+
if not feature.scan_enabled:
267+
return HookDecision.allow(feature.event_type)
268+
242269
scan_outcome = _scan_text_for_secrets(
243270
ctx,
244271
clipped,
@@ -282,8 +309,52 @@ def _handle_arg_scan(
282309
)
283310

284311

312+
def _check_mcp_server_authorization(payload: AIHookPayload, policy: dict) -> Optional[_PreScanFinding]:
313+
"""Fails open: no server name or no cached statuses lets the call through."""
314+
mcp_config = get_policy_value(policy, 'mcp', default={})
315+
if not get_policy_value(mcp_config, 'check_server', default=False):
316+
return None
317+
318+
alias = payload.mcp_server_name
319+
if not alias:
320+
logger.debug('No MCP server name in the payload; skipping the server authorization check')
321+
return None
322+
323+
statuses = load_mcp_server_statuses()
324+
if statuses is None:
325+
logger.debug('No cached MCP server statuses; skipping the server authorization check')
326+
return None
327+
328+
server = statuses.match(alias)
329+
if server is not None and server.alias.lower() != alias.lower():
330+
# Report the alias the platform stores, not the normalized or plugin-namespaced one.
331+
payload.mcp_server_name = server.alias
332+
333+
status = server.status if server is not None else None
334+
if not is_enforced(status):
335+
return None
336+
337+
logger.debug(
338+
'MCP server is not authorized, %s',
339+
{'mcp_server_name': payload.mcp_server_name, 'status': status},
340+
)
341+
server_name = payload.mcp_server_name
342+
return _PreScanFinding(
343+
block_reason=BlockReason.UNAUTHORIZED_MCP_SERVER,
344+
mode=get_effective_mode(mcp_config, action_key='server_action'),
345+
deny_message=(
346+
f"Cycode blocked MCP server '{server_name}': it is not authorized in your organization. "
347+
'Contact your admin to authorize it.'
348+
),
349+
deny_agent_message=(
350+
f"The MCP server '{server_name}' is not authorized in this organization. "
351+
'Do not retry its tools or reach it another way.'
352+
),
353+
)
354+
355+
285356
def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
286-
"""Scan MCP tool arguments for secrets before execution."""
357+
"""Check the MCP server is authorized, then scan the tool arguments for secrets."""
287358
tool = payload.mcp_tool_name or 'unknown'
288359
args = payload.mcp_arguments or {}
289360
args_text = args if isinstance(args, str) else json.dumps(args)
@@ -297,8 +368,10 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
297368
deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.',
298369
ask_message=lambda v: f'Allow MCP tool call "{tool}"? {v}',
299370
ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.',
371+
scan_enabled=get_policy_value(policy, 'mcp', 'scan_args', default=True),
300372
),
301373
scan_text=args_text,
374+
pre_scan_finding=_check_mcp_server_authorization(payload, policy),
302375
)
303376

304377

0 commit comments

Comments
 (0)