diff --git a/examples/11-release-readiness-org.json b/examples/11-release-readiness-org.json new file mode 100644 index 0000000..c4bbd05 --- /dev/null +++ b/examples/11-release-readiness-org.json @@ -0,0 +1,227 @@ +{ + "$schema": "../src/scc_cli/schemas/org-v1.schema.json", + "schema_version": "1.0.0", + "min_cli_version": "1.7.0", + "organization": { + "name": "Release Readiness Org", + "id": "release-readiness-org", + "contact": "platform@example.com" + }, + "marketplaces": { + "official-plugins": { + "source": "github", + "owner": "CCimen", + "repo": "sandboxed-code-plugins", + "branch": "main", + "path": "/" + } + }, + "security": { + "blocked_plugins": [ + "*-experimental", + "*-deprecated", + "malicious-*", + "untrusted-*" + ], + "blocked_mcp_servers": [ + "*.untrusted.com", + "evil-*", + "localhost:*" + ], + "allow_stdio_mcp": false, + "allowed_stdio_prefixes": [ + "/usr/local/bin", + "/opt/approved-tools" + ], + "safety_net": { + "action": "block", + "block_force_push": true, + "block_reset_hard": true, + "block_branch_force_delete": true, + "block_checkout_restore": true, + "block_clean": true, + "block_stash_destructive": true + } + }, + "defaults": { + "enabled_plugins": [ + "scc-safety-net@official-plugins" + ], + "disabled_plugins": [ + "legacy-tools@official-plugins" + ], + "allowed_plugins": [ + "*@official-plugins" + ], + "allowed_mcp_servers": [ + "https://mcp.context7.com/*", + "https://mcp.mermaid.ai/*", + "https://www.shadcn.io/*", + "https://mcp.internal.example.com/*", + "mcp.context7.com", + "mcp.mermaid.ai", + "www.shadcn.io", + "mcp.internal.example.com", + "/usr/local/bin/*" + ], + "extra_marketplaces": [ + "official-plugins" + ], + "cache_ttl_hours": 24, + "network_policy": "corp-proxy-only", + "session": { + "timeout_hours": 12, + "auto_resume": true + } + }, + "delegation": { + "teams": { + "allow_additional_plugins": [ + "*" + ], + "allow_additional_mcp_servers": [ + "*" + ] + }, + "projects": { + "inherit_team_delegation": true + } + }, + "profiles": { + "platform": { + "description": "Platform engineering tools and core MCP access", + "additional_plugins": [ + "platform-tooling@official-plugins", + "infra-helper@official-plugins" + ], + "additional_mcp_servers": [ + { + "name": "context7", + "type": "http", + "url": "https://mcp.context7.com/mcp", + "headers": { + "X-API-Key": "${CONTEXT7_API_KEY}" + } + } + ], + "network_policy": "corp-proxy-only", + "delegation": { + "allow_project_overrides": false + } + }, + "frontend": { + "description": "Frontend team with design and UI MCP servers", + "additional_plugins": [ + "frontend-toolkit@official-plugins", + "ui-kit@official-plugins" + ], + "additional_mcp_servers": [ + { + "name": "shadcn", + "type": "http", + "url": "https://www.shadcn.io/api/mcp" + }, + { + "name": "mermaid-mcp", + "type": "sse", + "url": "https://mcp.mermaid.ai/sse" + } + ], + "network_policy": "unrestricted", + "delegation": { + "allow_project_overrides": true + } + }, + "backend": { + "description": "Backend services team with internal API MCP", + "additional_plugins": [ + "backend-tooling@official-plugins" + ], + "additional_mcp_servers": [ + { + "name": "internal-api", + "type": "http", + "url": "https://mcp.internal.example.com/backend", + "headers": { + "Authorization": "Bearer ${INTERNAL_API_TOKEN}" + } + } + ], + "network_policy": "corp-proxy-only", + "session": { + "timeout_hours": 10, + "auto_resume": false + }, + "delegation": { + "allow_project_overrides": true + } + }, + "data": { + "description": "Data team with catalog MCP and longer sessions", + "additional_plugins": [ + "data-tooling@official-plugins", + "notebook-helper@official-plugins" + ], + "additional_mcp_servers": [ + { + "name": "data-catalog", + "type": "http", + "url": "https://mcp.internal.example.com/data" + } + ], + "network_policy": "corp-proxy-only", + "session": { + "timeout_hours": 24, + "auto_resume": true + }, + "delegation": { + "allow_project_overrides": true + } + }, + "browser-automation": { + "description": "Browser automation team using Playwright MCP", + "additional_plugins": [ + "browser-tools@official-plugins" + ], + "additional_mcp_servers": [ + { + "name": "playwright", + "type": "stdio", + "command": "/usr/local/bin/npx", + "args": [ + "@playwright/mcp@latest" + ], + "env": { + "PLAYWRIGHT_BROWSERS_PATH": "${HOME}/.cache/ms-playwright" + } + } + ], + "network_policy": "isolated", + "delegation": { + "allow_project_overrides": false + } + }, + "security": { + "description": "Security team with scanning tools and isolated network policy", + "additional_plugins": [ + "security-audit@official-plugins" + ], + "additional_mcp_servers": [ + { + "name": "sec-scan", + "type": "http", + "url": "https://mcp.internal.example.com/security" + } + ], + "network_policy": "isolated", + "delegation": { + "allow_project_overrides": false + } + } + }, + "stats": { + "enabled": true, + "user_identity_mode": "hash", + "retention_days": 90 + } +} diff --git a/pyproject.toml b/pyproject.toml index 0e2ba13..ac9ea90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "scc-cli" -version = "1.7.0" +version = "1.7.1" description = "Run Claude Code in Docker sandboxes with team configs and git worktree support" readme = "README.md" license = "MIT" diff --git a/src/scc_cli/application/launch/apply_personal_profile.py b/src/scc_cli/application/launch/apply_personal_profile.py index c85a2ed..f5d12fe 100644 --- a/src/scc_cli/application/launch/apply_personal_profile.py +++ b/src/scc_cli/application/launch/apply_personal_profile.py @@ -2,10 +2,16 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path +from typing import Any from scc_cli.application.interaction_requests import ConfirmRequest +from scc_cli.application.personal_profile_policy import ( + ProfilePolicySkip, + filter_personal_profile_mcp, + filter_personal_profile_settings, +) from scc_cli.ports.personal_profile_service import PersonalProfileService @@ -35,11 +41,13 @@ class ApplyPersonalProfileRequest: workspace_path: Path to the workspace. interactive_allowed: Whether the UI may prompt for confirmation. confirm_apply: Optional confirmation response when prompted. + org_config: Optional org config for security enforcement. """ workspace_path: Path interactive_allowed: bool confirm_apply: bool | None = None + org_config: dict[str, Any] | None = None @dataclass(frozen=True) @@ -74,11 +82,13 @@ class ApplyPersonalProfileResult: profile_id: Identifier for the profile, if one exists. applied: Whether the profile was applied. message: Optional message to render at the edge. + skipped_items: Items skipped due to org security policy. """ profile_id: str | None applied: bool message: str | None = None + skipped_items: list[ProfilePolicySkip] = field(default_factory=list) ApplyPersonalProfileOutcome = ApplyPersonalProfileConfirmation | ApplyPersonalProfileResult @@ -162,15 +172,30 @@ def apply_personal_profile( existing_settings = existing_settings or {} existing_mcp = existing_mcp or {} + profile_settings = profile.settings or {} + profile_mcp = profile.mcp or {} + policy_skips: list[ProfilePolicySkip] = [] + if request.org_config: + profile_settings, skipped_plugins = filter_personal_profile_settings( + profile_settings, + request.org_config, + ) + profile_mcp, skipped_mcps = filter_personal_profile_mcp( + profile_mcp, + request.org_config, + ) + policy_skips.extend(skipped_plugins) + policy_skips.extend(skipped_mcps) + merged_settings = dependencies.profile_service.merge_personal_settings( request.workspace_path, existing_settings, - profile.settings or {}, + profile_settings, ) - merged_mcp = dependencies.profile_service.merge_personal_mcp(existing_mcp, profile.mcp or {}) + merged_mcp = dependencies.profile_service.merge_personal_mcp(existing_mcp, profile_mcp) dependencies.profile_service.write_workspace_settings(request.workspace_path, merged_settings) - if profile.mcp: + if profile_mcp: dependencies.profile_service.write_workspace_mcp(request.workspace_path, merged_mcp) dependencies.profile_service.save_applied_state( @@ -183,4 +208,5 @@ def apply_personal_profile( profile_id=profile.profile_id, applied=True, message="[green]Applied personal profile.[/green]", + skipped_items=policy_skips, ) diff --git a/src/scc_cli/application/personal_profile_policy.py b/src/scc_cli/application/personal_profile_policy.py new file mode 100644 index 0000000..7f6dd49 --- /dev/null +++ b/src/scc_cli/application/personal_profile_policy.py @@ -0,0 +1,124 @@ +"""Policy helpers for filtering personal profiles before applying.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from scc_cli.application.compute_effective_config import ( + match_blocked_mcp, + matches_blocked_plugin, + validate_stdio_server, +) +from scc_cli.core.enums import MCPServerType, TargetType + + +@dataclass(frozen=True) +class ProfilePolicySkip: + """Represents a profile item skipped due to org security policy.""" + + item: str + reason: str + target_type: str + + +def filter_personal_profile_settings( + personal_settings: dict[str, Any], + org_config: dict[str, Any], +) -> tuple[dict[str, Any], list[ProfilePolicySkip]]: + """Filter personal profile settings against org security blocks.""" + security = org_config.get("security", {}) + blocked_plugins = security.get("blocked_plugins", []) + if not personal_settings or not blocked_plugins: + return personal_settings, [] + + skips: list[ProfilePolicySkip] = [] + filtered = dict(personal_settings) + + plugins_raw = personal_settings.get("enabledPlugins") + if isinstance(plugins_raw, list): + filtered_plugin_list: list[str] = [] + for plugin in plugins_raw: + plugin_ref = str(plugin) + blocked_by = matches_blocked_plugin(plugin_ref, blocked_plugins) + if blocked_by: + skips.append( + ProfilePolicySkip( + item=plugin_ref, + reason=f"blocked by org policy ({blocked_by})", + target_type=TargetType.PLUGIN, + ) + ) + continue + filtered_plugin_list.append(plugin_ref) + filtered["enabledPlugins"] = filtered_plugin_list + elif isinstance(plugins_raw, dict): + filtered_plugin_map: dict[str, bool] = {} + for plugin, enabled in plugins_raw.items(): + plugin_ref = str(plugin) + blocked_by = matches_blocked_plugin(plugin_ref, blocked_plugins) + if blocked_by: + skips.append( + ProfilePolicySkip( + item=plugin_ref, + reason=f"blocked by org policy ({blocked_by})", + target_type=TargetType.PLUGIN, + ) + ) + continue + filtered_plugin_map[plugin_ref] = bool(enabled) + filtered["enabledPlugins"] = filtered_plugin_map + + return filtered, skips + + +def filter_personal_profile_mcp( + personal_mcp: dict[str, Any], + org_config: dict[str, Any], +) -> tuple[dict[str, Any], list[ProfilePolicySkip]]: + """Filter personal profile MCP servers against org security blocks.""" + if not personal_mcp: + return personal_mcp, [] + + security = org_config.get("security", {}) + blocked_mcp_servers = security.get("blocked_mcp_servers", []) + servers_raw = personal_mcp.get("mcpServers") + if not isinstance(servers_raw, dict): + return personal_mcp, [] + + skips: list[ProfilePolicySkip] = [] + filtered_servers: dict[str, Any] = {} + + for name, server in servers_raw.items(): + server_dict = dict(server) if isinstance(server, dict) else {} + server_dict.setdefault("name", str(name)) + blocked_by = match_blocked_mcp(server_dict, blocked_mcp_servers) + if blocked_by: + skips.append( + ProfilePolicySkip( + item=str(name), + reason=f"blocked by org policy ({blocked_by})", + target_type=TargetType.MCP_SERVER, + ) + ) + continue + + if server_dict.get("type") == MCPServerType.STDIO: + stdio_result = validate_stdio_server(server_dict, org_config) + if stdio_result.blocked: + skips.append( + ProfilePolicySkip( + item=str(name), + reason=stdio_result.reason, + target_type=TargetType.MCP_SERVER, + ) + ) + continue + + filtered_servers[str(name)] = server + + filtered = dict(personal_mcp) + filtered["mcpServers"] = filtered_servers + if not filtered_servers and set(filtered.keys()) == {"mcpServers"}: + return {}, skips + return filtered, skips diff --git a/src/scc_cli/commands/launch/flow.py b/src/scc_cli/commands/launch/flow.py index 89a6918..e39adcd 100644 --- a/src/scc_cli/commands/launch/flow.py +++ b/src/scc_cli/commands/launch/flow.py @@ -70,6 +70,7 @@ from ...bootstrap import get_default_adapters from ...cli_common import console, err_console from ...contexts import WorkContext, load_recent_contexts, normalize_path, record_context +from ...core.enums import TargetType from ...core.errors import WorkspaceNotFoundError from ...core.exit_codes import EXIT_CANCELLED, EXIT_CONFIG, EXIT_ERROR, EXIT_USAGE from ...marketplace.materialize import materialize_marketplace @@ -79,6 +80,7 @@ from ...ports.git_client import GitClient from ...ports.personal_profile_service import PersonalProfileService from ...presentation.json.launch_json import build_start_dry_run_envelope +from ...presentation.json.profile_json import build_profile_apply_envelope from ...presentation.launch_presenter import build_sync_output_view_model, render_launch_output from ...services.workspace import has_project_markers, is_suspicious_directory from ...theme import Colors, Spinners, get_brand_header @@ -327,6 +329,7 @@ def _resolve_session_selection( def _apply_personal_profile( workspace_path: Path, *, + org_config: dict[str, Any] | None, json_mode: bool, non_interactive: bool, profile_service: PersonalProfileService, @@ -340,6 +343,7 @@ def _apply_personal_profile( json_mode=json_mode, non_interactive=non_interactive, confirm_apply=None, + org_config=org_config, ) dependencies = ApplyPersonalProfileDependencies(profile_service=profile_service) @@ -357,6 +361,7 @@ def _apply_personal_profile( json_mode=json_mode, non_interactive=non_interactive, confirm_apply=confirm, + org_config=org_config, ) continue @@ -373,6 +378,7 @@ def _build_personal_profile_request( json_mode: bool, non_interactive: bool, confirm_apply: bool | None, + org_config: dict[str, Any] | None, ) -> ApplyPersonalProfileRequest: return ApplyPersonalProfileRequest( workspace_path=workspace_path, @@ -381,6 +387,7 @@ def _build_personal_profile_request( no_interactive_flag=non_interactive, ), confirm_apply=confirm_apply, + org_config=org_config, ) @@ -397,7 +404,13 @@ def _render_personal_profile_result( outcome: ApplyPersonalProfileResult, *, json_mode: bool ) -> None: if json_mode: + envelope = build_profile_apply_envelope(outcome) + print_json(envelope) return + if outcome.skipped_items: + for skipped in outcome.skipped_items: + label = "plugin" if skipped.target_type == TargetType.PLUGIN else "MCP server" + console.print(f"[yellow]Skipped {label} '{skipped.item}': {skipped.reason}[/yellow]") if outcome.message: console.print(outcome.message) @@ -692,6 +705,7 @@ def start( if not dry_run and workspace_path is not None: personal_profile_id, personal_applied = _apply_personal_profile( workspace_path, + org_config=org_config, json_mode=(json_output or pretty), non_interactive=non_interactive, profile_service=adapters.personal_profile_service, diff --git a/src/scc_cli/commands/launch/sandbox.py b/src/scc_cli/commands/launch/sandbox.py index 1be8240..1ddde9b 100644 --- a/src/scc_cli/commands/launch/sandbox.py +++ b/src/scc_cli/commands/launch/sandbox.py @@ -82,7 +82,7 @@ def launch_sandbox( profile=team, force_new=fresh, continue_session=should_continue_session, - env_vars=None, + env_vars=env_vars, ) # Extract container name for session tracking diff --git a/src/scc_cli/commands/profile.py b/src/scc_cli/commands/profile.py index 9c3de97..c16fb17 100644 --- a/src/scc_cli/commands/profile.py +++ b/src/scc_cli/commands/profile.py @@ -13,8 +13,14 @@ from .. import config as config_module from .. import docker as docker_module +from ..application.personal_profile_policy import ( + ProfilePolicySkip, + filter_personal_profile_mcp, + filter_personal_profile_settings, +) from ..cli_common import console, handle_errors from ..confirm import Confirm +from ..core.enums import TargetType from ..core.exit_codes import EXIT_USAGE from ..core.personal_profiles import ( build_diff_text, @@ -61,6 +67,14 @@ def _print_stack_summary(workspace: Path, profile_id: str | None) -> None: ) +def _render_policy_skips(skips: list[ProfilePolicySkip]) -> None: + if not skips: + return + for skipped in skips: + label = "plugin" if skipped.target_type == TargetType.PLUGIN else "MCP server" + console.print(f"[yellow]Skipped {label} '{skipped.item}': {skipped.reason}[/yellow]") + + def _format_preview(items: list[str], limit: int = 5) -> str: if len(items) <= limit: return ", ".join(items) @@ -309,6 +323,24 @@ def apply_cmd( existing_settings = existing_settings or {} existing_mcp = existing_mcp or {} + org_config = config_module.load_cached_org_config() + profile_settings = profile.settings or {} + profile_mcp = profile.mcp or {} + policy_skips: list[ProfilePolicySkip] = [] + if org_config: + profile_settings, skipped_plugins = filter_personal_profile_settings( + profile_settings, + org_config, + ) + profile_mcp, skipped_mcps = filter_personal_profile_mcp( + profile_mcp, + org_config, + ) + policy_skips.extend(skipped_plugins) + policy_skips.extend(skipped_mcps) + + _render_policy_skips(policy_skips) + missing_plugins, missing_marketplaces = compute_sandbox_import_candidates( existing_settings, docker_module.get_sandbox_settings() ) @@ -331,15 +363,15 @@ def apply_cmd( diff_settings = build_diff_text( f"settings.local.json ({profile.repo_id})", existing_settings, - merge_personal_settings(ws_path, existing_settings, profile.settings or {}), + merge_personal_settings(ws_path, existing_settings, profile_settings), ) if diff_settings: console.print(diff_settings) - if profile.mcp: + if profile_mcp: diff_mcp = build_diff_text( f".mcp.json ({profile.repo_id})", existing_mcp, - merge_personal_mcp(existing_mcp, profile.mcp or {}), + merge_personal_mcp(existing_mcp, profile_mcp), ) if diff_mcp: console.print(diff_mcp) @@ -351,8 +383,8 @@ def apply_cmd( ) raise typer.Exit(EXIT_USAGE) - merged_settings = merge_personal_settings(ws_path, existing_settings, profile.settings or {}) - merged_mcp = merge_personal_mcp(existing_mcp, profile.mcp or {}) + merged_settings = merge_personal_settings(ws_path, existing_settings, profile_settings) + merged_mcp = merge_personal_mcp(existing_mcp, profile_mcp) if merged_settings == existing_settings and merged_mcp == existing_mcp: _print_stack_summary(ws_path, profile.repo_id) @@ -367,7 +399,7 @@ def apply_cmd( console.print(diff_settings) any_diff = True - if profile.mcp: + if profile_mcp: diff_mcp = build_diff_text(".mcp.json", existing_mcp, merged_mcp) if diff_mcp: console.print(diff_mcp) @@ -377,7 +409,7 @@ def apply_cmd( return write_workspace_settings(ws_path, merged_settings) - if profile.mcp: + if profile_mcp: write_workspace_mcp(ws_path, merged_mcp) save_applied_state(ws_path, profile.profile_id, compute_fingerprints(ws_path)) diff --git a/src/scc_cli/kinds.py b/src/scc_cli/kinds.py index 0681ac4..6f43dc0 100644 --- a/src/scc_cli/kinds.py +++ b/src/scc_cli/kinds.py @@ -58,6 +58,9 @@ class Kind(str, Enum): # Start START_DRY_RUN = "StartDryRun" + # Profiles + PROFILE_APPLY = "ProfileApply" + # Init INIT_RESULT = "InitResult" diff --git a/src/scc_cli/presentation/json/profile_json.py b/src/scc_cli/presentation/json/profile_json.py new file mode 100644 index 0000000..9a35450 --- /dev/null +++ b/src/scc_cli/presentation/json/profile_json.py @@ -0,0 +1,29 @@ +"""JSON mapping helpers for personal profile operations.""" + +from __future__ import annotations + +from typing import Any + +from scc_cli.application.launch import ApplyPersonalProfileResult +from scc_cli.application.personal_profile_policy import ProfilePolicySkip +from scc_cli.json_output import build_envelope +from scc_cli.kinds import Kind + + +def _serialize_policy_skips(skips: list[ProfilePolicySkip]) -> list[dict[str, str]]: + return [ + {"item": skip.item, "reason": skip.reason, "target_type": skip.target_type} + for skip in skips + ] + + +def build_profile_apply_envelope( + result: ApplyPersonalProfileResult, +) -> dict[str, Any]: + """Build JSON envelope for personal profile apply events.""" + data = { + "profile_id": result.profile_id, + "applied": result.applied, + "skipped_items": _serialize_policy_skips(result.skipped_items), + } + return build_envelope(Kind.PROFILE_APPLY, data=data) diff --git a/tests/test_launch_proxy_env.py b/tests/test_launch_proxy_env.py new file mode 100644 index 0000000..ce71c7a --- /dev/null +++ b/tests/test_launch_proxy_env.py @@ -0,0 +1,43 @@ +"""Tests for proxy env propagation in legacy launch flow.""" + +from pathlib import Path +from unittest.mock import patch + + +def test_launch_sandbox_passes_proxy_env(tmp_path: Path, monkeypatch) -> None: + from scc_cli.commands.launch import launch_sandbox + + workspace = tmp_path / "repo" + workspace.mkdir() + org_config = {"defaults": {"network_policy": "corp-proxy-only"}} + + monkeypatch.setenv("HTTP_PROXY", "http://proxy.example.com:8080") + + with ( + patch( + "scc_cli.commands.launch.sandbox.config.load_cached_org_config", return_value=org_config + ), + patch("scc_cli.commands.launch.sandbox.docker.prepare_sandbox_volume_for_credentials"), + patch( + "scc_cli.commands.launch.sandbox.docker.get_or_create_container", + return_value=(["docker", "run"], False), + ) as mock_get_container, + patch("scc_cli.commands.launch.sandbox.sessions.record_session"), + patch("scc_cli.commands.launch.sandbox.git.get_worktree_main_repo", return_value=workspace), + patch("scc_cli.commands.launch.sandbox.record_context"), + patch("scc_cli.commands.launch.sandbox.show_launch_panel"), + patch("scc_cli.commands.launch.sandbox.docker.run"), + patch("scc_cli.commands.launch.sandbox.config.set_workspace_team"), + ): + launch_sandbox( + workspace_path=workspace, + mount_path=workspace, + team="platform", + session_name="session", + current_branch="main", + should_continue_session=False, + fresh=False, + ) + + _, kwargs = mock_get_container.call_args + assert kwargs["env_vars"]["HTTP_PROXY"] == "http://proxy.example.com:8080" diff --git a/tests/test_start_personal_profile.py b/tests/test_start_personal_profile.py index a4cfdc4..2c1a7ad 100644 --- a/tests/test_start_personal_profile.py +++ b/tests/test_start_personal_profile.py @@ -13,6 +13,7 @@ apply_personal_profile, ) from scc_cli.core import personal_profiles +from scc_cli.core.enums import TargetType from scc_cli.core.personal_profiles import PersonalProfile from scc_cli.marketplace.managed import ManagedState, save_managed_state @@ -264,3 +265,105 @@ def test_personal_profile_invalid_json_mcp(tmp_path: Path) -> None: assert isinstance(outcome, ApplyPersonalProfileResult) assert outcome.applied is False assert "Invalid JSON" in (outcome.message or "") + + +def test_personal_profile_blocks_org_blocked_plugins(tmp_path: Path) -> None: + personal_profiles.save_personal_profile( + tmp_path, + {"enabledPlugins": {"blocked@market": True, "allowed@market": True}}, + {}, + ) + org_config = {"security": {"blocked_plugins": ["blocked@market"]}} + + request = ApplyPersonalProfileRequest( + workspace_path=tmp_path, + interactive_allowed=False, + confirm_apply=None, + org_config=org_config, + ) + outcome = apply_personal_profile( + request, + dependencies=ApplyPersonalProfileDependencies( + profile_service=LocalPersonalProfileService(), + ), + ) + + assert isinstance(outcome, ApplyPersonalProfileResult) + updated = personal_profiles.load_workspace_settings(tmp_path) or {} + plugins = updated.get("enabledPlugins", {}) + assert "blocked@market" not in plugins + assert plugins.get("allowed@market") is True + assert any( + skipped.item == "blocked@market" and skipped.target_type == TargetType.PLUGIN + for skipped in outcome.skipped_items + ) + + +def test_personal_profile_blocks_org_blocked_mcp(tmp_path: Path) -> None: + personal_profiles.save_personal_profile( + tmp_path, + {}, + { + "mcpServers": { + "blocked": {"type": "http", "url": "https://blocked.example.com"}, + "allowed": {"type": "http", "url": "https://good.example.com"}, + } + }, + ) + org_config = {"security": {"blocked_mcp_servers": ["blocked.example.com"]}} + + request = ApplyPersonalProfileRequest( + workspace_path=tmp_path, + interactive_allowed=False, + confirm_apply=None, + org_config=org_config, + ) + outcome = apply_personal_profile( + request, + dependencies=ApplyPersonalProfileDependencies( + profile_service=LocalPersonalProfileService(), + ), + ) + + assert isinstance(outcome, ApplyPersonalProfileResult) + updated_mcp = personal_profiles.load_workspace_mcp(tmp_path) or {} + servers = updated_mcp.get("mcpServers", {}) + assert "blocked" not in servers + assert "allowed" in servers + assert any( + skipped.item == "blocked" and skipped.target_type == TargetType.MCP_SERVER + for skipped in outcome.skipped_items + ) + + +def test_personal_profile_blocks_stdio_mcp_when_disabled(tmp_path: Path) -> None: + personal_profiles.save_personal_profile( + tmp_path, + {}, + { + "mcpServers": { + "stdio-server": {"type": "stdio", "command": "/usr/bin/stdio"}, + } + }, + ) + org_config = {"security": {"allow_stdio_mcp": False}} + + request = ApplyPersonalProfileRequest( + workspace_path=tmp_path, + interactive_allowed=False, + confirm_apply=None, + org_config=org_config, + ) + outcome = apply_personal_profile( + request, + dependencies=ApplyPersonalProfileDependencies( + profile_service=LocalPersonalProfileService(), + ), + ) + + assert isinstance(outcome, ApplyPersonalProfileResult) + assert any( + skipped.item == "stdio-server" and skipped.target_type == TargetType.MCP_SERVER + for skipped in outcome.skipped_items + ) + assert personal_profiles.load_workspace_mcp(tmp_path) is None