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.

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. 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}"