scc-cli 1.5.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +960 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +383 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
scc_cli/profiles.py
ADDED
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Profile resolution and marketplace URL logic.
|
|
3
|
+
|
|
4
|
+
Renamed from teams.py to better reflect profile resolution responsibilities.
|
|
5
|
+
Support new multi-marketplace architecture while maintaining backward compatibility
|
|
6
|
+
with legacy single-marketplace config format.
|
|
7
|
+
|
|
8
|
+
Key features:
|
|
9
|
+
- HTTPS-only enforcement: All marketplace URLs must use HTTPS protocol.
|
|
10
|
+
- Config inheritance: 3-layer merge (org defaults -> team -> project)
|
|
11
|
+
- Security boundaries: Blocked items (fnmatch patterns) never allowed
|
|
12
|
+
- Delegation control: Org controls whether teams can delegate to projects
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from fnmatch import fnmatch
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
21
|
+
from urllib.parse import urlparse, urlunparse
|
|
22
|
+
|
|
23
|
+
from . import config as config_module
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
30
|
+
# Data Classes for Effective Config (v2 schema)
|
|
31
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ConfigDecision:
|
|
36
|
+
"""Tracks where a config value came from (for scc config explain)."""
|
|
37
|
+
|
|
38
|
+
field: str
|
|
39
|
+
value: Any
|
|
40
|
+
reason: str
|
|
41
|
+
source: str # "org.security" | "org.defaults" | "team.X" | "project"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class BlockedItem:
|
|
46
|
+
"""Tracks an item blocked by security pattern."""
|
|
47
|
+
|
|
48
|
+
item: str
|
|
49
|
+
blocked_by: str # The pattern that matched
|
|
50
|
+
source: str # Always "org.security"
|
|
51
|
+
target_type: str = "plugin" # "plugin" | "mcp_server" | "base_image"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DelegationDenied:
|
|
56
|
+
"""Tracks an addition denied due to delegation rules."""
|
|
57
|
+
|
|
58
|
+
item: str
|
|
59
|
+
requested_by: str # "team" | "project"
|
|
60
|
+
reason: str
|
|
61
|
+
target_type: str = "plugin" # "plugin" | "mcp_server" | "base_image"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class MCPServer:
|
|
66
|
+
"""Represents an MCP server configuration.
|
|
67
|
+
|
|
68
|
+
Supports three transport types:
|
|
69
|
+
- sse: Server-Sent Events (requires url)
|
|
70
|
+
- stdio: Standard I/O (requires command, optional args and env)
|
|
71
|
+
- http: HTTP transport (requires url, optional headers)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
name: str
|
|
75
|
+
type: str # "sse" | "stdio" | "http"
|
|
76
|
+
url: str | None = None
|
|
77
|
+
command: str | None = None
|
|
78
|
+
args: list[str] | None = None
|
|
79
|
+
env: dict[str, str] | None = None
|
|
80
|
+
headers: dict[str, str] | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class SessionConfig:
|
|
85
|
+
"""Session configuration."""
|
|
86
|
+
|
|
87
|
+
timeout_hours: int | None = None
|
|
88
|
+
auto_resume: bool | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class EffectiveConfig:
|
|
93
|
+
"""The computed effective configuration after 3-layer merge.
|
|
94
|
+
|
|
95
|
+
Contains:
|
|
96
|
+
- Final resolved values (plugins, mcp_servers, etc.)
|
|
97
|
+
- Tracking information for debugging (decisions, blocked_items, denied_additions)
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
plugins: set[str] = field(default_factory=set)
|
|
101
|
+
mcp_servers: list[MCPServer] = field(default_factory=list)
|
|
102
|
+
network_policy: str | None = None
|
|
103
|
+
session_config: SessionConfig = field(default_factory=SessionConfig)
|
|
104
|
+
|
|
105
|
+
# For scc config explain
|
|
106
|
+
decisions: list[ConfigDecision] = field(default_factory=list)
|
|
107
|
+
blocked_items: list[BlockedItem] = field(default_factory=list)
|
|
108
|
+
denied_additions: list[DelegationDenied] = field(default_factory=list)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class StdioValidationResult:
|
|
113
|
+
"""Result of validating a stdio MCP server configuration.
|
|
114
|
+
|
|
115
|
+
stdio servers are the "sharpest knife" - they have elevated privileges:
|
|
116
|
+
- Mounted workspace (write access)
|
|
117
|
+
- Network access (required for some tools)
|
|
118
|
+
- Tokens in environment variables
|
|
119
|
+
|
|
120
|
+
This validation implements layered defense:
|
|
121
|
+
- Gate 1: Feature gate (org must explicitly enable)
|
|
122
|
+
- Gate 2: Absolute path required (prevents ./evil injection)
|
|
123
|
+
- Gate 3: Prefix allowlist + commonpath (prevents path traversal)
|
|
124
|
+
- Warnings for host-side checks (command runs in container, not host)
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
blocked: bool
|
|
128
|
+
reason: str = ""
|
|
129
|
+
warnings: list[str] = field(default_factory=list)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
133
|
+
# Config Inheritance Functions (3-layer merge)
|
|
134
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def matches_blocked(item: str, blocked_patterns: list[str]) -> str | None:
|
|
138
|
+
"""
|
|
139
|
+
Check whether item matches any blocked pattern using fnmatch.
|
|
140
|
+
|
|
141
|
+
Use casefold() for case-insensitive matching. This is important because:
|
|
142
|
+
- casefold() handles Unicode edge cases (e.g., German ss -> ss)
|
|
143
|
+
- Pattern "Malicious-*" should block "malicious-tool"
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
item: The item to check (plugin name, MCP server name/URL, etc.)
|
|
147
|
+
blocked_patterns: List of fnmatch patterns
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The pattern that matched, or None if no match
|
|
151
|
+
"""
|
|
152
|
+
# Normalize item: strip whitespace and casefold for case-insensitive matching
|
|
153
|
+
normalized_item = item.strip().casefold()
|
|
154
|
+
|
|
155
|
+
for pattern in blocked_patterns:
|
|
156
|
+
# Normalize pattern the same way
|
|
157
|
+
normalized_pattern = pattern.strip().casefold()
|
|
158
|
+
if fnmatch(normalized_item, normalized_pattern):
|
|
159
|
+
return pattern # Return original pattern for error messages
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def normalize_image_for_policy(ref: str) -> str:
|
|
164
|
+
"""
|
|
165
|
+
Normalize Docker image reference for policy matching.
|
|
166
|
+
|
|
167
|
+
Handle implicit :latest tag - this is crucial for blocking unpinned images.
|
|
168
|
+
For example, blocking "*:latest" should catch "ubuntu" (which implicitly uses :latest).
|
|
169
|
+
|
|
170
|
+
Phase 1 scope: Only handle implicit :latest normalization.
|
|
171
|
+
NOT full OCI canonicalization (docker.io/library etc) - that's Phase 2.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
ref: Docker image reference (e.g., "ubuntu", "python:3.11", "nginx@sha256:...")
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Normalized reference, casefolded for matching.
|
|
178
|
+
Empty strings remain empty.
|
|
179
|
+
"""
|
|
180
|
+
r = ref.strip()
|
|
181
|
+
if not r:
|
|
182
|
+
return r
|
|
183
|
+
|
|
184
|
+
# If image has a digest (@sha256:...), don't add :latest
|
|
185
|
+
# Digests are immutable and take precedence over tags
|
|
186
|
+
if "@" in r:
|
|
187
|
+
return r.casefold()
|
|
188
|
+
|
|
189
|
+
# Check if the last component (after the last /) has an explicit tag
|
|
190
|
+
# We need to handle registry:port/path:tag correctly
|
|
191
|
+
last_segment = r.rsplit("/", 1)[-1]
|
|
192
|
+
|
|
193
|
+
# If no ":" in the last segment, there's no explicit tag → add :latest
|
|
194
|
+
# This handles:
|
|
195
|
+
# - "ubuntu" → "ubuntu:latest"
|
|
196
|
+
# - "ghcr.io/owner/repo" → "ghcr.io/owner/repo:latest"
|
|
197
|
+
# - "registry:5000/ns/img" → "registry:5000/ns/img:latest" (port is before /)
|
|
198
|
+
if ":" not in last_segment:
|
|
199
|
+
r = f"{r}:latest"
|
|
200
|
+
|
|
201
|
+
return r.casefold()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def validate_stdio_server(
|
|
205
|
+
server: dict[str, Any],
|
|
206
|
+
org_config: dict[str, Any],
|
|
207
|
+
) -> StdioValidationResult:
|
|
208
|
+
"""
|
|
209
|
+
Validate a stdio MCP server configuration against org security policy.
|
|
210
|
+
|
|
211
|
+
stdio servers are the "sharpest knife" - they have elevated privileges:
|
|
212
|
+
- Mounted workspace (write access)
|
|
213
|
+
- Network access (required for some tools)
|
|
214
|
+
- Tokens in environment variables
|
|
215
|
+
|
|
216
|
+
Validation gates (in order):
|
|
217
|
+
1. Feature gate: security.allow_stdio_mcp must be true (default: false)
|
|
218
|
+
2. Absolute path: command must be an absolute path (not relative)
|
|
219
|
+
3. Prefix allowlist: if allowed_stdio_prefixes is set, command must be under one
|
|
220
|
+
|
|
221
|
+
Host-side checks (existence, executable) generate warnings only because
|
|
222
|
+
the command runs inside the container, not on the host.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
server: MCP server dict with 'name', 'type', 'command' fields
|
|
226
|
+
org_config: Organization config dict
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
StdioValidationResult with blocked=True/False, reason, and warnings
|
|
230
|
+
"""
|
|
231
|
+
import os
|
|
232
|
+
|
|
233
|
+
command = server.get("command", "")
|
|
234
|
+
warnings: list[str] = []
|
|
235
|
+
security = org_config.get("security", {})
|
|
236
|
+
|
|
237
|
+
# Gate 1: Feature gate - stdio must be explicitly enabled by org
|
|
238
|
+
# Default is False because stdio servers have elevated privileges
|
|
239
|
+
if not security.get("allow_stdio_mcp", False):
|
|
240
|
+
return StdioValidationResult(
|
|
241
|
+
blocked=True,
|
|
242
|
+
reason="stdio MCP disabled by org policy",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Gate 2: Absolute path required - prevents "./evil" injection attacks
|
|
246
|
+
if not os.path.isabs(command):
|
|
247
|
+
return StdioValidationResult(
|
|
248
|
+
blocked=True,
|
|
249
|
+
reason="stdio command must be absolute path",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Gate 3: Prefix allowlist with commonpath enforcement
|
|
253
|
+
# Uses realpath to resolve symlinks and ".." traversal attempts
|
|
254
|
+
prefixes = security.get("allowed_stdio_prefixes", [])
|
|
255
|
+
if prefixes:
|
|
256
|
+
# Resolve the actual path (handles symlinks and ..)
|
|
257
|
+
try:
|
|
258
|
+
resolved = os.path.realpath(command)
|
|
259
|
+
except OSError:
|
|
260
|
+
# If we can't resolve, use the original command
|
|
261
|
+
resolved = command
|
|
262
|
+
|
|
263
|
+
# Normalize prefixes the same way
|
|
264
|
+
normalized_prefixes = []
|
|
265
|
+
for p in prefixes:
|
|
266
|
+
try:
|
|
267
|
+
# Remove trailing slash for consistent commonpath comparison
|
|
268
|
+
normalized_prefixes.append(os.path.realpath(p.rstrip("/")))
|
|
269
|
+
except OSError:
|
|
270
|
+
normalized_prefixes.append(p.rstrip("/"))
|
|
271
|
+
|
|
272
|
+
# Check if resolved path is under any allowed prefix
|
|
273
|
+
allowed = False
|
|
274
|
+
for prefix in normalized_prefixes:
|
|
275
|
+
try:
|
|
276
|
+
# commonpath returns the longest common sub-path
|
|
277
|
+
# If it equals the prefix, command is under that prefix
|
|
278
|
+
common = os.path.commonpath([resolved, prefix])
|
|
279
|
+
if common == prefix:
|
|
280
|
+
allowed = True
|
|
281
|
+
break
|
|
282
|
+
except ValueError:
|
|
283
|
+
# Different drives on Windows, or empty sequence
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
if not allowed:
|
|
287
|
+
return StdioValidationResult(
|
|
288
|
+
blocked=True,
|
|
289
|
+
reason=f"Resolved path {resolved} not in allowed prefixes",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Host-side checks: WARN only (command runs in container, not host)
|
|
293
|
+
# These are informational because filesystem differs between host and container
|
|
294
|
+
if not os.path.exists(command):
|
|
295
|
+
warnings.append(f"Command not found on host: {command}")
|
|
296
|
+
elif not os.access(command, os.X_OK):
|
|
297
|
+
warnings.append(f"Command not executable on host: {command}")
|
|
298
|
+
|
|
299
|
+
return StdioValidationResult(
|
|
300
|
+
blocked=False,
|
|
301
|
+
warnings=warnings,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _extract_domain(url: str) -> str:
|
|
306
|
+
"""Extract domain from URL for pattern matching."""
|
|
307
|
+
parsed = urlparse(url)
|
|
308
|
+
return parsed.netloc or url
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def is_team_delegated_for_plugins(org_config: dict[str, Any], team_name: str | None) -> bool:
|
|
312
|
+
"""
|
|
313
|
+
Check whether team is allowed to add additional plugins.
|
|
314
|
+
|
|
315
|
+
Use fnmatch patterns from delegation.teams.allow_additional_plugins.
|
|
316
|
+
"""
|
|
317
|
+
if not team_name:
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
delegation = org_config.get("delegation", {})
|
|
321
|
+
teams_delegation = delegation.get("teams", {})
|
|
322
|
+
allowed_patterns = teams_delegation.get("allow_additional_plugins", [])
|
|
323
|
+
|
|
324
|
+
# Check if team name matches any allowed pattern
|
|
325
|
+
for pattern in allowed_patterns:
|
|
326
|
+
if pattern == "*" or fnmatch(team_name, pattern):
|
|
327
|
+
return True
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def is_team_delegated_for_mcp(org_config: dict[str, Any], team_name: str | None) -> bool:
|
|
332
|
+
"""
|
|
333
|
+
Check whether team is allowed to add MCP servers.
|
|
334
|
+
|
|
335
|
+
Use fnmatch patterns from delegation.teams.allow_additional_mcp_servers.
|
|
336
|
+
"""
|
|
337
|
+
if not team_name:
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
delegation = org_config.get("delegation", {})
|
|
341
|
+
teams_delegation = delegation.get("teams", {})
|
|
342
|
+
allowed_patterns = teams_delegation.get("allow_additional_mcp_servers", [])
|
|
343
|
+
|
|
344
|
+
# Check if team name matches any allowed pattern
|
|
345
|
+
for pattern in allowed_patterns:
|
|
346
|
+
if pattern == "*" or fnmatch(team_name, pattern):
|
|
347
|
+
return True
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def is_project_delegated(org_config: dict[str, Any], team_name: str | None) -> tuple[bool, str]:
|
|
352
|
+
"""
|
|
353
|
+
Check whether project-level additions are allowed.
|
|
354
|
+
|
|
355
|
+
TWO-LEVEL CHECK:
|
|
356
|
+
1. Org-level: delegation.projects.inherit_team_delegation must be true
|
|
357
|
+
2. Team-level: profiles.<team>.delegation.allow_project_overrides must be true
|
|
358
|
+
|
|
359
|
+
If org disables inheritance (inherit_team_delegation: false), team-level
|
|
360
|
+
settings are ignored - this is the master switch.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Tuple of (allowed: bool, reason: str)
|
|
364
|
+
Reason explains why delegation was denied if allowed is False
|
|
365
|
+
"""
|
|
366
|
+
if not team_name:
|
|
367
|
+
return (False, "No team specified")
|
|
368
|
+
|
|
369
|
+
# First check: org-level master switch
|
|
370
|
+
delegation = org_config.get("delegation", {})
|
|
371
|
+
projects_delegation = delegation.get("projects", {})
|
|
372
|
+
org_allows = projects_delegation.get("inherit_team_delegation", False)
|
|
373
|
+
|
|
374
|
+
if not org_allows:
|
|
375
|
+
# Org-level master switch is OFF - team settings are ignored
|
|
376
|
+
return (False, "Org disabled project delegation (inherit_team_delegation: false)")
|
|
377
|
+
|
|
378
|
+
# Second check: team-level setting
|
|
379
|
+
profiles = org_config.get("profiles", {})
|
|
380
|
+
team_config = profiles.get(team_name, {})
|
|
381
|
+
team_delegation = team_config.get("delegation", {})
|
|
382
|
+
team_allows = team_delegation.get("allow_project_overrides", False)
|
|
383
|
+
|
|
384
|
+
if not team_allows:
|
|
385
|
+
return (
|
|
386
|
+
False,
|
|
387
|
+
f"Team '{team_name}' disabled project overrides (allow_project_overrides: false)",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
return (True, "")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def compute_effective_config(
|
|
394
|
+
org_config: dict[str, Any],
|
|
395
|
+
team_name: str,
|
|
396
|
+
project_config: dict[str, Any] | None = None,
|
|
397
|
+
workspace_path: str | Path | None = None,
|
|
398
|
+
) -> EffectiveConfig:
|
|
399
|
+
"""
|
|
400
|
+
Compute effective configuration by merging org defaults → team → project.
|
|
401
|
+
|
|
402
|
+
The merge follows these rules:
|
|
403
|
+
1. Start with org defaults
|
|
404
|
+
2. Apply team additions (if delegated)
|
|
405
|
+
3. Apply project additions (if delegated)
|
|
406
|
+
4. Security blocks are NEVER overridable - checked at every layer
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
org_config: Organization config (v2 schema)
|
|
410
|
+
team_name: Name of the team profile to apply
|
|
411
|
+
project_config: Optional project-level config (.scc.yaml content)
|
|
412
|
+
workspace_path: Optional path to workspace directory containing .scc.yaml.
|
|
413
|
+
If provided, takes precedence over project_config.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
EffectiveConfig with merged values and tracking information
|
|
417
|
+
"""
|
|
418
|
+
# Load project config from file if workspace_path provided
|
|
419
|
+
if workspace_path is not None:
|
|
420
|
+
project_config = config_module.read_project_config(workspace_path)
|
|
421
|
+
|
|
422
|
+
result = EffectiveConfig()
|
|
423
|
+
|
|
424
|
+
# Get security blocks (never overridable)
|
|
425
|
+
security = org_config.get("security", {})
|
|
426
|
+
blocked_plugins = security.get("blocked_plugins", [])
|
|
427
|
+
blocked_mcp_servers = security.get("blocked_mcp_servers", [])
|
|
428
|
+
|
|
429
|
+
# Get org defaults
|
|
430
|
+
defaults = org_config.get("defaults", {})
|
|
431
|
+
default_plugins = defaults.get("allowed_plugins", [])
|
|
432
|
+
default_network_policy = defaults.get("network_policy")
|
|
433
|
+
default_session = defaults.get("session", {})
|
|
434
|
+
|
|
435
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
436
|
+
# Layer 1: Apply org defaults
|
|
437
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
# Add default plugins (checking against security blocks)
|
|
440
|
+
for plugin in default_plugins:
|
|
441
|
+
blocked_by = matches_blocked(plugin, blocked_plugins)
|
|
442
|
+
if blocked_by:
|
|
443
|
+
result.blocked_items.append(
|
|
444
|
+
BlockedItem(item=plugin, blocked_by=blocked_by, source="org.security")
|
|
445
|
+
)
|
|
446
|
+
else:
|
|
447
|
+
result.plugins.add(plugin)
|
|
448
|
+
result.decisions.append(
|
|
449
|
+
ConfigDecision(
|
|
450
|
+
field="plugins",
|
|
451
|
+
value=plugin,
|
|
452
|
+
reason="Included in organization defaults",
|
|
453
|
+
source="org.defaults",
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# Set network policy from defaults
|
|
458
|
+
if default_network_policy:
|
|
459
|
+
result.network_policy = default_network_policy
|
|
460
|
+
result.decisions.append(
|
|
461
|
+
ConfigDecision(
|
|
462
|
+
field="network_policy",
|
|
463
|
+
value=default_network_policy,
|
|
464
|
+
reason="Organization default network policy",
|
|
465
|
+
source="org.defaults",
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# Set session config from defaults
|
|
470
|
+
if default_session.get("timeout_hours") is not None:
|
|
471
|
+
result.session_config.timeout_hours = default_session["timeout_hours"]
|
|
472
|
+
result.decisions.append(
|
|
473
|
+
ConfigDecision(
|
|
474
|
+
field="session.timeout_hours",
|
|
475
|
+
value=default_session["timeout_hours"],
|
|
476
|
+
reason="Organization default session timeout",
|
|
477
|
+
source="org.defaults",
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
if default_session.get("auto_resume") is not None:
|
|
481
|
+
result.session_config.auto_resume = default_session["auto_resume"]
|
|
482
|
+
|
|
483
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
484
|
+
# Layer 2: Apply team profile additions
|
|
485
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
profiles = org_config.get("profiles", {})
|
|
488
|
+
team_config = profiles.get(team_name, {})
|
|
489
|
+
|
|
490
|
+
# Add team plugins (if delegated)
|
|
491
|
+
team_plugins = team_config.get("additional_plugins", [])
|
|
492
|
+
team_delegated_plugins = is_team_delegated_for_plugins(org_config, team_name)
|
|
493
|
+
|
|
494
|
+
for plugin in team_plugins:
|
|
495
|
+
# Security check first
|
|
496
|
+
blocked_by = matches_blocked(plugin, blocked_plugins)
|
|
497
|
+
if blocked_by:
|
|
498
|
+
result.blocked_items.append(
|
|
499
|
+
BlockedItem(item=plugin, blocked_by=blocked_by, source="org.security")
|
|
500
|
+
)
|
|
501
|
+
continue
|
|
502
|
+
|
|
503
|
+
# Delegation check
|
|
504
|
+
if not team_delegated_plugins:
|
|
505
|
+
result.denied_additions.append(
|
|
506
|
+
DelegationDenied(
|
|
507
|
+
item=plugin,
|
|
508
|
+
requested_by="team",
|
|
509
|
+
reason=f"Team '{team_name}' not allowed to add plugins",
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
result.plugins.add(plugin)
|
|
515
|
+
result.decisions.append(
|
|
516
|
+
ConfigDecision(
|
|
517
|
+
field="plugins",
|
|
518
|
+
value=plugin,
|
|
519
|
+
reason=f"Added by team profile '{team_name}'",
|
|
520
|
+
source=f"team.{team_name}",
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
# Add team MCP servers (if delegated)
|
|
525
|
+
team_mcp_servers = team_config.get("additional_mcp_servers", [])
|
|
526
|
+
team_delegated_mcp = is_team_delegated_for_mcp(org_config, team_name)
|
|
527
|
+
|
|
528
|
+
for server_dict in team_mcp_servers:
|
|
529
|
+
server_name = server_dict.get("name", "")
|
|
530
|
+
server_url = server_dict.get("url", "")
|
|
531
|
+
|
|
532
|
+
# Security check - check both name and URL domain
|
|
533
|
+
blocked_by = matches_blocked(server_name, blocked_mcp_servers)
|
|
534
|
+
if not blocked_by and server_url:
|
|
535
|
+
domain = _extract_domain(server_url)
|
|
536
|
+
blocked_by = matches_blocked(domain, blocked_mcp_servers)
|
|
537
|
+
|
|
538
|
+
if blocked_by:
|
|
539
|
+
result.blocked_items.append(
|
|
540
|
+
BlockedItem(
|
|
541
|
+
item=server_name or server_url,
|
|
542
|
+
blocked_by=blocked_by,
|
|
543
|
+
source="org.security",
|
|
544
|
+
target_type="mcp_server",
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
continue
|
|
548
|
+
|
|
549
|
+
# Delegation check
|
|
550
|
+
if not team_delegated_mcp:
|
|
551
|
+
result.denied_additions.append(
|
|
552
|
+
DelegationDenied(
|
|
553
|
+
item=server_name,
|
|
554
|
+
requested_by="team",
|
|
555
|
+
reason=f"Team '{team_name}' not allowed to add MCP servers",
|
|
556
|
+
target_type="mcp_server",
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
continue
|
|
560
|
+
|
|
561
|
+
# stdio-type servers require additional security validation
|
|
562
|
+
if server_dict.get("type") == "stdio":
|
|
563
|
+
stdio_result = validate_stdio_server(server_dict, org_config)
|
|
564
|
+
if stdio_result.blocked:
|
|
565
|
+
result.blocked_items.append(
|
|
566
|
+
BlockedItem(
|
|
567
|
+
item=server_name,
|
|
568
|
+
blocked_by=stdio_result.reason,
|
|
569
|
+
source="org.security",
|
|
570
|
+
target_type="mcp_server",
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
continue
|
|
574
|
+
# Warnings are logged inside validate_stdio_server
|
|
575
|
+
|
|
576
|
+
mcp_server = MCPServer(
|
|
577
|
+
name=server_name,
|
|
578
|
+
type=server_dict.get("type", "sse"),
|
|
579
|
+
url=server_url or None,
|
|
580
|
+
command=server_dict.get("command"),
|
|
581
|
+
args=server_dict.get("args"),
|
|
582
|
+
)
|
|
583
|
+
result.mcp_servers.append(mcp_server)
|
|
584
|
+
result.decisions.append(
|
|
585
|
+
ConfigDecision(
|
|
586
|
+
field="mcp_servers",
|
|
587
|
+
value=server_name,
|
|
588
|
+
reason=f"Added by team profile '{team_name}'",
|
|
589
|
+
source=f"team.{team_name}",
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
# Team session override
|
|
594
|
+
team_session = team_config.get("session", {})
|
|
595
|
+
if team_session.get("timeout_hours") is not None:
|
|
596
|
+
result.session_config.timeout_hours = team_session["timeout_hours"]
|
|
597
|
+
result.decisions.append(
|
|
598
|
+
ConfigDecision(
|
|
599
|
+
field="session.timeout_hours",
|
|
600
|
+
value=team_session["timeout_hours"],
|
|
601
|
+
reason=f"Overridden by team profile '{team_name}'",
|
|
602
|
+
source=f"team.{team_name}",
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
607
|
+
# Layer 3: Apply project additions (if delegated)
|
|
608
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
609
|
+
|
|
610
|
+
if project_config:
|
|
611
|
+
project_delegated, delegation_reason = is_project_delegated(org_config, team_name)
|
|
612
|
+
|
|
613
|
+
# Add project plugins
|
|
614
|
+
project_plugins = project_config.get("additional_plugins", [])
|
|
615
|
+
for plugin in project_plugins:
|
|
616
|
+
# Security check first
|
|
617
|
+
blocked_by = matches_blocked(plugin, blocked_plugins)
|
|
618
|
+
if blocked_by:
|
|
619
|
+
result.blocked_items.append(
|
|
620
|
+
BlockedItem(item=plugin, blocked_by=blocked_by, source="org.security")
|
|
621
|
+
)
|
|
622
|
+
continue
|
|
623
|
+
|
|
624
|
+
# Delegation check
|
|
625
|
+
if not project_delegated:
|
|
626
|
+
result.denied_additions.append(
|
|
627
|
+
DelegationDenied(
|
|
628
|
+
item=plugin,
|
|
629
|
+
requested_by="project",
|
|
630
|
+
reason=delegation_reason,
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
continue
|
|
634
|
+
|
|
635
|
+
result.plugins.add(plugin)
|
|
636
|
+
result.decisions.append(
|
|
637
|
+
ConfigDecision(
|
|
638
|
+
field="plugins",
|
|
639
|
+
value=plugin,
|
|
640
|
+
reason="Added by project config",
|
|
641
|
+
source="project",
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
# Add project MCP servers
|
|
646
|
+
project_mcp_servers = project_config.get("additional_mcp_servers", [])
|
|
647
|
+
for server_dict in project_mcp_servers:
|
|
648
|
+
server_name = server_dict.get("name", "")
|
|
649
|
+
server_url = server_dict.get("url", "")
|
|
650
|
+
|
|
651
|
+
# Security check
|
|
652
|
+
blocked_by = matches_blocked(server_name, blocked_mcp_servers)
|
|
653
|
+
if not blocked_by and server_url:
|
|
654
|
+
domain = _extract_domain(server_url)
|
|
655
|
+
blocked_by = matches_blocked(domain, blocked_mcp_servers)
|
|
656
|
+
|
|
657
|
+
if blocked_by:
|
|
658
|
+
result.blocked_items.append(
|
|
659
|
+
BlockedItem(
|
|
660
|
+
item=server_name or server_url,
|
|
661
|
+
blocked_by=blocked_by,
|
|
662
|
+
source="org.security",
|
|
663
|
+
target_type="mcp_server",
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
continue
|
|
667
|
+
|
|
668
|
+
# Delegation check
|
|
669
|
+
if not project_delegated:
|
|
670
|
+
result.denied_additions.append(
|
|
671
|
+
DelegationDenied(
|
|
672
|
+
item=server_name,
|
|
673
|
+
requested_by="project",
|
|
674
|
+
reason=delegation_reason,
|
|
675
|
+
target_type="mcp_server",
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
continue
|
|
679
|
+
|
|
680
|
+
# stdio-type servers require additional security validation
|
|
681
|
+
if server_dict.get("type") == "stdio":
|
|
682
|
+
stdio_result = validate_stdio_server(server_dict, org_config)
|
|
683
|
+
if stdio_result.blocked:
|
|
684
|
+
result.blocked_items.append(
|
|
685
|
+
BlockedItem(
|
|
686
|
+
item=server_name,
|
|
687
|
+
blocked_by=stdio_result.reason,
|
|
688
|
+
source="org.security",
|
|
689
|
+
target_type="mcp_server",
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
continue
|
|
693
|
+
# Warnings are logged inside validate_stdio_server
|
|
694
|
+
|
|
695
|
+
mcp_server = MCPServer(
|
|
696
|
+
name=server_name,
|
|
697
|
+
type=server_dict.get("type", "sse"),
|
|
698
|
+
url=server_url or None,
|
|
699
|
+
command=server_dict.get("command"),
|
|
700
|
+
args=server_dict.get("args"),
|
|
701
|
+
)
|
|
702
|
+
result.mcp_servers.append(mcp_server)
|
|
703
|
+
result.decisions.append(
|
|
704
|
+
ConfigDecision(
|
|
705
|
+
field="mcp_servers",
|
|
706
|
+
value=server_name,
|
|
707
|
+
reason="Added by project config",
|
|
708
|
+
source="project",
|
|
709
|
+
)
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
# Project session override
|
|
713
|
+
project_session = project_config.get("session", {})
|
|
714
|
+
if project_session.get("timeout_hours") is not None:
|
|
715
|
+
if project_delegated:
|
|
716
|
+
result.session_config.timeout_hours = project_session["timeout_hours"]
|
|
717
|
+
result.decisions.append(
|
|
718
|
+
ConfigDecision(
|
|
719
|
+
field="session.timeout_hours",
|
|
720
|
+
value=project_session["timeout_hours"],
|
|
721
|
+
reason="Overridden by project config",
|
|
722
|
+
source="project",
|
|
723
|
+
)
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
return result
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
730
|
+
# Core Profile Resolution Functions (New Architecture)
|
|
731
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def list_profiles(org_config: dict[str, Any]) -> list[dict[str, Any]]:
|
|
735
|
+
"""
|
|
736
|
+
List all available profiles from org config.
|
|
737
|
+
|
|
738
|
+
Return list of profile dicts with name, description, plugin, and marketplace.
|
|
739
|
+
"""
|
|
740
|
+
profiles = org_config.get("profiles", {})
|
|
741
|
+
result = []
|
|
742
|
+
|
|
743
|
+
for name, info in profiles.items():
|
|
744
|
+
result.append(
|
|
745
|
+
{
|
|
746
|
+
"name": name,
|
|
747
|
+
"description": info.get("description", ""),
|
|
748
|
+
"plugin": info.get("plugin"),
|
|
749
|
+
"marketplace": info.get("marketplace"),
|
|
750
|
+
}
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
return result
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def resolve_profile(org_config: dict[str, Any], profile_name: str) -> dict[str, Any]:
|
|
757
|
+
"""
|
|
758
|
+
Resolve profile by name, raise ValueError if not found.
|
|
759
|
+
|
|
760
|
+
Return profile dict with name and all profile fields.
|
|
761
|
+
"""
|
|
762
|
+
profiles = org_config.get("profiles", {})
|
|
763
|
+
|
|
764
|
+
if profile_name not in profiles:
|
|
765
|
+
available = ", ".join(sorted(profiles.keys())) or "(none)"
|
|
766
|
+
raise ValueError(f"Profile '{profile_name}' not found. Available: {available}")
|
|
767
|
+
|
|
768
|
+
profile_info = profiles[profile_name]
|
|
769
|
+
return {"name": profile_name, **profile_info}
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def resolve_marketplace(org_config: dict[Any, Any], profile: dict[Any, Any]) -> dict[Any, Any]:
|
|
773
|
+
"""
|
|
774
|
+
Resolve marketplace for a profile and translate to claude_adapter format.
|
|
775
|
+
|
|
776
|
+
This is the SINGLE translation layer between org-config schema and
|
|
777
|
+
claude_adapter expected format. All schema changes should be handled here.
|
|
778
|
+
|
|
779
|
+
Schema Translation:
|
|
780
|
+
org-config (source/owner/repo) → claude_adapter (type/repo combined)
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
org_config: Organization config with marketplaces dict
|
|
784
|
+
profile: Profile dict with a "marketplace" field
|
|
785
|
+
|
|
786
|
+
Returns:
|
|
787
|
+
Marketplace dict normalized for claude_adapter:
|
|
788
|
+
- name: marketplace name (from dict key)
|
|
789
|
+
- type: "github" | "gitlab" | "https"
|
|
790
|
+
- repo: combined "owner/repo" for github
|
|
791
|
+
- url: for git/url sources
|
|
792
|
+
- ref: translated from "branch"
|
|
793
|
+
|
|
794
|
+
Raises:
|
|
795
|
+
ValueError: If marketplace not found, invalid source, or missing fields
|
|
796
|
+
"""
|
|
797
|
+
marketplace_name = profile.get("marketplace")
|
|
798
|
+
if not marketplace_name:
|
|
799
|
+
raise ValueError(f"Profile '{profile.get('name')}' has no marketplace field")
|
|
800
|
+
|
|
801
|
+
# Dict-based lookup
|
|
802
|
+
marketplaces: dict[str, dict[Any, Any]] = org_config.get("marketplaces", {})
|
|
803
|
+
marketplace_config = marketplaces.get(marketplace_name)
|
|
804
|
+
|
|
805
|
+
if not marketplace_config:
|
|
806
|
+
raise ValueError(
|
|
807
|
+
f"Marketplace '{marketplace_name}' not found for profile '{profile.get('name')}'"
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
# Validate and translate source type
|
|
811
|
+
source = marketplace_config.get("source", "")
|
|
812
|
+
valid_sources = {"github", "git", "url"}
|
|
813
|
+
if source not in valid_sources:
|
|
814
|
+
raise ValueError(
|
|
815
|
+
f"Marketplace '{marketplace_name}' has invalid source '{source}'. "
|
|
816
|
+
f"Valid sources: {', '.join(sorted(valid_sources))}"
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
result: dict[str, Any] = {"name": marketplace_name}
|
|
820
|
+
|
|
821
|
+
if source == "github":
|
|
822
|
+
# GitHub: requires owner + repo, combine into single repo field
|
|
823
|
+
owner = marketplace_config.get("owner", "")
|
|
824
|
+
repo = marketplace_config.get("repo", "")
|
|
825
|
+
if not owner or not repo:
|
|
826
|
+
raise ValueError(
|
|
827
|
+
f"GitHub marketplace '{marketplace_name}' requires 'owner' and 'repo' fields"
|
|
828
|
+
)
|
|
829
|
+
result["type"] = "github"
|
|
830
|
+
result["repo"] = f"{owner}/{repo}"
|
|
831
|
+
|
|
832
|
+
elif source == "git":
|
|
833
|
+
# Generic git: maps to gitlab type
|
|
834
|
+
# Supports two patterns:
|
|
835
|
+
# 1. Direct URL: {"source": "git", "url": "https://..."}
|
|
836
|
+
# 2. Host + owner + repo: {"source": "git", "host": "gitlab.example.org", "owner": "group", "repo": "name"}
|
|
837
|
+
url = marketplace_config.get("url", "")
|
|
838
|
+
host = marketplace_config.get("host", "")
|
|
839
|
+
owner = marketplace_config.get("owner", "")
|
|
840
|
+
repo = marketplace_config.get("repo", "")
|
|
841
|
+
|
|
842
|
+
result["type"] = "gitlab"
|
|
843
|
+
|
|
844
|
+
if url:
|
|
845
|
+
# Pattern 1: Direct URL provided
|
|
846
|
+
result["url"] = url
|
|
847
|
+
elif host and owner and repo:
|
|
848
|
+
# Pattern 2: Construct from host/owner/repo
|
|
849
|
+
result["host"] = host
|
|
850
|
+
result["repo"] = f"{owner}/{repo}"
|
|
851
|
+
else:
|
|
852
|
+
raise ValueError(
|
|
853
|
+
f"Git marketplace '{marketplace_name}' requires either 'url' field "
|
|
854
|
+
f"or 'host', 'owner', 'repo' fields"
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
elif source == "url":
|
|
858
|
+
# HTTPS URL: requires url
|
|
859
|
+
url = marketplace_config.get("url", "")
|
|
860
|
+
if not url:
|
|
861
|
+
raise ValueError(f"URL marketplace '{marketplace_name}' requires 'url' field")
|
|
862
|
+
result["type"] = "https"
|
|
863
|
+
result["url"] = url
|
|
864
|
+
|
|
865
|
+
# Translate branch -> ref (optional)
|
|
866
|
+
if marketplace_config.get("branch"):
|
|
867
|
+
result["ref"] = marketplace_config["branch"]
|
|
868
|
+
|
|
869
|
+
# Preserve optional fields
|
|
870
|
+
for field_name in ("host", "auth", "headers", "path"):
|
|
871
|
+
if marketplace_config.get(field_name):
|
|
872
|
+
result[field_name] = marketplace_config[field_name]
|
|
873
|
+
|
|
874
|
+
return result
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
878
|
+
# Marketplace URL Resolution (HTTPS-only enforcement)
|
|
879
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _normalize_repo_path(repo: str) -> str:
|
|
883
|
+
"""
|
|
884
|
+
Normalize repo path: strip whitespace, leading slashes, .git suffix.
|
|
885
|
+
"""
|
|
886
|
+
repo = repo.strip().lstrip("/")
|
|
887
|
+
if repo.endswith(".git"):
|
|
888
|
+
repo = repo[:-4]
|
|
889
|
+
return repo
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def get_marketplace_url(marketplace: dict[str, Any]) -> str:
|
|
893
|
+
"""
|
|
894
|
+
Resolve marketplace to HTTPS URL.
|
|
895
|
+
|
|
896
|
+
SECURITY: Rejects SSH URLs (git@, ssh://) and HTTP URLs.
|
|
897
|
+
Only HTTPS is allowed for marketplace access.
|
|
898
|
+
|
|
899
|
+
URL Resolution Logic:
|
|
900
|
+
1. If 'url' is provided, validate and normalize it
|
|
901
|
+
2. Otherwise, construct from 'host' + 'repo'
|
|
902
|
+
3. For github/gitlab types, use default hosts if not specified
|
|
903
|
+
|
|
904
|
+
Args:
|
|
905
|
+
marketplace: Marketplace config dict with type, url/host, repo
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
Normalized HTTPS URL string
|
|
909
|
+
|
|
910
|
+
Raises:
|
|
911
|
+
ValueError: For SSH URLs, HTTP URLs, unsupported schemes, or missing config
|
|
912
|
+
"""
|
|
913
|
+
# Check for direct URL first
|
|
914
|
+
if raw := marketplace.get("url"):
|
|
915
|
+
raw = raw.strip()
|
|
916
|
+
|
|
917
|
+
# Reject SSH URLs early (git@ format)
|
|
918
|
+
if raw.startswith("git@"):
|
|
919
|
+
raise ValueError(f"SSH URL not supported: {raw}")
|
|
920
|
+
|
|
921
|
+
# Reject ssh:// protocol
|
|
922
|
+
if raw.startswith("ssh://"):
|
|
923
|
+
raise ValueError(f"SSH URL not supported: {raw}")
|
|
924
|
+
|
|
925
|
+
parsed = urlparse(raw)
|
|
926
|
+
|
|
927
|
+
# HTTPS only - reject http:// for security
|
|
928
|
+
if parsed.scheme == "http":
|
|
929
|
+
raise ValueError(f"HTTP not allowed (use HTTPS): {raw}")
|
|
930
|
+
|
|
931
|
+
if parsed.scheme != "https":
|
|
932
|
+
raise ValueError(f"Unsupported URL scheme: {parsed.scheme!r}")
|
|
933
|
+
|
|
934
|
+
# Normalize: remove trailing slash, drop fragments
|
|
935
|
+
normalized_path = parsed.path.rstrip("/")
|
|
936
|
+
normalized = parsed._replace(path=normalized_path, fragment="")
|
|
937
|
+
return cast(str, urlunparse(normalized))
|
|
938
|
+
|
|
939
|
+
# No URL provided - construct from host + repo
|
|
940
|
+
host = (marketplace.get("host") or "").strip()
|
|
941
|
+
|
|
942
|
+
if not host:
|
|
943
|
+
# Use default hosts for known types
|
|
944
|
+
defaults = {"github": "github.com", "gitlab": "gitlab.com"}
|
|
945
|
+
host = defaults.get(marketplace.get("type") or "")
|
|
946
|
+
|
|
947
|
+
if not host:
|
|
948
|
+
raise ValueError(
|
|
949
|
+
f"Marketplace type '{marketplace.get('type')}' requires 'url' or 'host'"
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
# Reject host with path components (ambiguous config)
|
|
953
|
+
if "/" in host:
|
|
954
|
+
raise ValueError(f"'host' must not include path: {host!r}")
|
|
955
|
+
|
|
956
|
+
# Get and normalize repo path
|
|
957
|
+
repo = marketplace.get("repo", "")
|
|
958
|
+
repo = _normalize_repo_path(repo)
|
|
959
|
+
|
|
960
|
+
return f"https://{host}/{repo}"
|