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
@@ -0,0 +1,127 @@
1
+ """
2
+ Docker sandbox operations.
3
+
4
+ This package provides Docker sandbox lifecycle management with
5
+ credential persistence across project switches.
6
+
7
+ ===============================================================================
8
+ CREDENTIAL PERSISTENCE ARCHITECTURE (DO NOT MODIFY)
9
+ ===============================================================================
10
+
11
+ PROBLEM: OAuth credentials lost when switching projects. Claude reads config
12
+ before symlinks are created (race condition).
13
+
14
+ SOLUTION (Synchronous Detached Pattern):
15
+ 1. docker sandbox run -d -w /path claude → Creates container, returns ID
16
+ 2. docker exec <id> <symlink_script> → Creates symlinks while idle
17
+ 3. docker exec -it <id> claude → Runs Claude after symlinks exist
18
+
19
+ CRITICAL - DO NOT CHANGE:
20
+ - Agent name `claude` is REQUIRED even in detached mode (-d)!
21
+ Wrong: docker sandbox run -d -w /path
22
+ Right: docker sandbox run -d -w /path claude
23
+ - Session flags (-c, --resume) passed via docker exec, NOT container creation
24
+
25
+ See run_sandbox() and build_command() for implementation.
26
+ ===============================================================================
27
+
28
+ Module Structure:
29
+ - core.py: Docker primitives (checks, commands, container lifecycle)
30
+ - credentials.py: Credential persistence subsystem
31
+ - launch.py: High-level launch orchestration and settings
32
+
33
+ All public symbols are re-exported here for backward compatibility.
34
+ Import from scc_cli.docker, not from submodules.
35
+ """
36
+
37
+ # Re-export subprocess utilities for test patching compatibility
38
+ from ..subprocess_utils import run_command, run_command_bool
39
+
40
+ # Re-export from core.py
41
+ from .core import (
42
+ LABEL_PREFIX,
43
+ MIN_DOCKER_VERSION,
44
+ ContainerInfo,
45
+ _check_docker_installed,
46
+ _list_all_sandbox_containers,
47
+ _parse_version,
48
+ build_command,
49
+ build_labels,
50
+ build_start_command,
51
+ check_docker_available,
52
+ check_docker_sandbox,
53
+ container_exists,
54
+ generate_container_name,
55
+ get_container_status,
56
+ get_docker_version,
57
+ list_running_sandboxes,
58
+ list_scc_containers,
59
+ remove_container,
60
+ resume_container,
61
+ run_detached,
62
+ start_container,
63
+ stop_container,
64
+ validate_container_filename,
65
+ )
66
+
67
+ # Re-export from credentials.py
68
+ from .credentials import (
69
+ prepare_sandbox_volume_for_credentials,
70
+ )
71
+
72
+ # Re-export from launch.py
73
+ from .launch import (
74
+ get_or_create_container,
75
+ get_sandbox_settings,
76
+ inject_file_to_sandbox_volume,
77
+ inject_settings,
78
+ reset_global_settings,
79
+ run,
80
+ run_sandbox,
81
+ )
82
+
83
+ __all__ = [
84
+ # Constants
85
+ "MIN_DOCKER_VERSION",
86
+ "LABEL_PREFIX",
87
+ # Data classes
88
+ "ContainerInfo",
89
+ # Docker checks
90
+ "check_docker_available",
91
+ "check_docker_sandbox",
92
+ "get_docker_version",
93
+ # Container lifecycle
94
+ "container_exists",
95
+ "get_container_status",
96
+ "start_container",
97
+ "stop_container",
98
+ "remove_container",
99
+ "resume_container",
100
+ "run_detached",
101
+ # Command building
102
+ "build_command",
103
+ "build_start_command",
104
+ "build_labels",
105
+ "generate_container_name",
106
+ "validate_container_filename",
107
+ # Container queries
108
+ "list_scc_containers",
109
+ "list_running_sandboxes",
110
+ # Credential management
111
+ "prepare_sandbox_volume_for_credentials",
112
+ # Settings injection
113
+ "inject_file_to_sandbox_volume",
114
+ "get_sandbox_settings",
115
+ "inject_settings",
116
+ "reset_global_settings",
117
+ # High-level launch functions
118
+ "run",
119
+ "run_sandbox",
120
+ "get_or_create_container",
121
+ # Re-exported for test patching compatibility
122
+ "run_command",
123
+ "run_command_bool",
124
+ "_check_docker_installed",
125
+ "_list_all_sandbox_containers",
126
+ "_parse_version",
127
+ ]
scc_cli/docker/core.py ADDED
@@ -0,0 +1,467 @@
1
+ """
2
+ Provide Docker core operations: checks, commands, container lifecycle, and queries.
3
+
4
+ Contain stateless Docker primitives that don't manage persistent state.
5
+ For credential persistence, see credentials.py.
6
+ """
7
+
8
+ import datetime
9
+ import hashlib
10
+ import os
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from ..core.constants import SANDBOX_IMAGE
18
+ from ..core.errors import (
19
+ ContainerNotFoundError,
20
+ DockerNotFoundError,
21
+ DockerVersionError,
22
+ SandboxNotAvailableError,
23
+ )
24
+ from ..subprocess_utils import run_command, run_command_bool
25
+
26
+ # Minimum Docker Desktop version required for sandbox feature
27
+ MIN_DOCKER_VERSION = "4.50.0"
28
+
29
+ # Label prefix for SCC containers
30
+ LABEL_PREFIX = "scc"
31
+
32
+
33
+ @dataclass
34
+ class ContainerInfo:
35
+ """Information about an SCC container."""
36
+
37
+ id: str
38
+ name: str
39
+ status: str
40
+ profile: str | None = None
41
+ workspace: str | None = None
42
+ branch: str | None = None
43
+ created: str | None = None
44
+
45
+
46
+ def _check_docker_installed() -> bool:
47
+ """Check whether Docker is installed and in PATH."""
48
+ return shutil.which("docker") is not None
49
+
50
+
51
+ def _parse_version(version_string: str) -> tuple[int, int, int]:
52
+ """Parse version string into comparable tuple."""
53
+ # Extract version number from strings like "Docker version 27.5.1, build..."
54
+ match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_string)
55
+ if match:
56
+ major, minor, patch = (int(x) for x in match.groups())
57
+ return (major, minor, patch)
58
+ return (0, 0, 0)
59
+
60
+
61
+ def check_docker_available() -> None:
62
+ """
63
+ Check if Docker is available and meets requirements.
64
+
65
+ Raises:
66
+ DockerNotFoundError: Docker is not installed
67
+ DockerVersionError: Docker version is too old
68
+ SandboxNotAvailableError: Sandbox feature not available
69
+ """
70
+ # Check Docker is installed
71
+ if not _check_docker_installed():
72
+ raise DockerNotFoundError()
73
+
74
+ # Check Docker version
75
+ version = get_docker_version()
76
+ if version:
77
+ current = _parse_version(version)
78
+ required = _parse_version(MIN_DOCKER_VERSION)
79
+ if current < required:
80
+ raise DockerVersionError(current_version=version)
81
+
82
+ # Check sandbox command exists
83
+ if not check_docker_sandbox():
84
+ raise SandboxNotAvailableError()
85
+
86
+
87
+ def check_docker_sandbox() -> bool:
88
+ """Check whether Docker sandbox feature is available (Docker Desktop 4.50+)."""
89
+ if not _check_docker_installed():
90
+ return False
91
+ return run_command_bool(["docker", "sandbox", "--help"], timeout=10)
92
+
93
+
94
+ def get_docker_version() -> str | None:
95
+ """Get Docker version string."""
96
+ return run_command(["docker", "--version"], timeout=5)
97
+
98
+
99
+ def generate_container_name(workspace: Path, branch: str | None = None) -> str:
100
+ """
101
+ Generate deterministic container name from workspace and branch.
102
+
103
+ Format: scc-<workspace_name>-<hash>
104
+ Example: scc-eneo-platform-a1b2c3
105
+ """
106
+ # Sanitize workspace name (take last component, lowercase, alphanumeric only)
107
+ workspace_name = workspace.name.lower()
108
+ workspace_name = re.sub(r"[^a-z0-9]", "-", workspace_name)
109
+ workspace_name = re.sub(r"-+", "-", workspace_name).strip("-")
110
+
111
+ # Create hash from full workspace path + branch
112
+ hash_input = str(workspace.resolve())
113
+ if branch:
114
+ hash_input += f":{branch}"
115
+ hash_suffix = hashlib.sha256(hash_input.encode()).hexdigest()[:8]
116
+
117
+ return f"scc-{workspace_name}-{hash_suffix}"
118
+
119
+
120
+ def container_exists(container_name: str) -> bool:
121
+ """Check whether a container with the given name exists (running or stopped)."""
122
+ output = run_command(
123
+ [
124
+ "docker",
125
+ "ps",
126
+ "-a",
127
+ "--filter",
128
+ f"name=^{container_name}$",
129
+ "--format",
130
+ "{{.Names}}",
131
+ ],
132
+ timeout=10,
133
+ )
134
+ return output is not None and container_name in output
135
+
136
+
137
+ def get_container_status(container_name: str) -> str | None:
138
+ """Return the status of a container (running, exited, etc.)."""
139
+ output = run_command(
140
+ [
141
+ "docker",
142
+ "ps",
143
+ "-a",
144
+ "--filter",
145
+ f"name=^{container_name}$",
146
+ "--format",
147
+ "{{.Status}}",
148
+ ],
149
+ timeout=10,
150
+ )
151
+ return output if output else None
152
+
153
+
154
+ def build_labels(
155
+ profile: str | None = None,
156
+ workspace: Path | None = None,
157
+ branch: str | None = None,
158
+ ) -> dict[str, str]:
159
+ """Build Docker labels for container metadata."""
160
+ labels = {
161
+ f"{LABEL_PREFIX}.managed": "true",
162
+ f"{LABEL_PREFIX}.created": datetime.datetime.now().isoformat(),
163
+ }
164
+
165
+ if profile:
166
+ labels[f"{LABEL_PREFIX}.profile"] = profile
167
+ if workspace:
168
+ labels[f"{LABEL_PREFIX}.workspace"] = str(workspace)
169
+ if branch:
170
+ labels[f"{LABEL_PREFIX}.branch"] = branch
171
+
172
+ return labels
173
+
174
+
175
+ def build_command(
176
+ workspace: Path | None = None,
177
+ continue_session: bool = False,
178
+ resume: bool = False,
179
+ detached: bool = False,
180
+ policy_host_path: Path | None = None,
181
+ ) -> list[str]:
182
+ """
183
+ Build the docker sandbox run command.
184
+
185
+ Structure: docker sandbox run [options] claude [claude-options]
186
+
187
+ Args:
188
+ workspace: Path to mount as workspace (-w flag)
189
+ continue_session: Pass -c flag to Claude (ignored in detached mode)
190
+ resume: Pass --resume flag to Claude (ignored in detached mode)
191
+ detached: Create container without running agent (-d flag)
192
+ policy_host_path: Host path to safety net policy file to bind-mount read-only.
193
+ If provided, mounts at /mnt/claude-data/effective_policy.json:ro
194
+ and sets SCC_POLICY_PATH env var for the plugin.
195
+
196
+ Returns:
197
+ Command as list of strings
198
+
199
+ CRITICAL (DO NOT CHANGE):
200
+ - Agent `claude` is ALWAYS included, even in detached mode
201
+ - Session flags passed via docker exec in detached mode (see run_sandbox)
202
+ """
203
+ from ..core.constants import SAFETY_NET_POLICY_FILENAME, SANDBOX_DATA_MOUNT
204
+
205
+ cmd = ["docker", "sandbox", "run"]
206
+
207
+ # Detached mode: create container without running Claude interactively
208
+ # This allows us to create symlinks BEFORE Claude starts
209
+ if detached:
210
+ cmd.append("-d")
211
+
212
+ # Add read-only bind mount for safety net policy (kernel-enforced security)
213
+ # This MUST be added before the agent name in the command
214
+ #
215
+ # Design note: We mount the FILE directly (not a directory) because:
216
+ # - Containers are ephemeral (recreated each `scc start`)
217
+ # - Policy is written before container creation, so new containers get current policy
218
+ # - If we ever support container reuse or hot-reload, switch to directory mount
219
+ # (file mounts pin to inode; atomic rename would be invisible to running container)
220
+ if policy_host_path is not None:
221
+ container_policy_path = f"{SANDBOX_DATA_MOUNT}/{SAFETY_NET_POLICY_FILENAME}"
222
+ # -v host_path:container_path:ro ← Kernel-enforced read-only
223
+ # Even sudo inside container cannot bypass `:ro` - requires CAP_SYS_ADMIN
224
+ # Use os.fspath() to reliably convert Path to string
225
+ cmd.extend(["-v", f"{os.fspath(policy_host_path)}:{container_policy_path}:ro"])
226
+ # Set SCC_POLICY_PATH env var so plugin knows where to read policy
227
+ cmd.extend(["-e", f"SCC_POLICY_PATH={container_policy_path}"])
228
+
229
+ # Add workspace mount
230
+ if workspace:
231
+ cmd.extend(["-w", str(workspace)])
232
+
233
+ # Agent name is ALWAYS required (docker sandbox run requires <agent>)
234
+ cmd.append("claude")
235
+
236
+ # In interactive mode (not detached), add Claude-specific arguments
237
+ # In detached mode, skip these - we'll pass them via docker exec later
238
+ if not detached:
239
+ if continue_session:
240
+ cmd.append("-c")
241
+ elif resume:
242
+ cmd.append("--resume")
243
+
244
+ return cmd
245
+
246
+
247
+ def build_start_command(container_name: str) -> list[str]:
248
+ """Build command to resume an existing container and return it."""
249
+ return ["docker", "start", "-ai", container_name]
250
+
251
+
252
+ def run_detached(cmd: list[str]) -> subprocess.Popen[bytes]:
253
+ """Run Docker command in background and return the process handle."""
254
+ return subprocess.Popen(
255
+ cmd,
256
+ stdout=subprocess.DEVNULL,
257
+ stderr=subprocess.DEVNULL,
258
+ start_new_session=True,
259
+ )
260
+
261
+
262
+ def start_container(container_name: str) -> int:
263
+ """
264
+ Start (resume) an existing container interactively.
265
+
266
+ Raises:
267
+ ContainerNotFoundError: If container doesn't exist
268
+ SandboxLaunchError: If start fails
269
+ """
270
+ # Import here to avoid circular dependency
271
+ from .launch import run
272
+
273
+ if not container_exists(container_name):
274
+ raise ContainerNotFoundError(container_name=container_name)
275
+
276
+ cmd = build_start_command(container_name)
277
+ return run(cmd)
278
+
279
+
280
+ def stop_container(container_id: str) -> bool:
281
+ """Stop a running container and return success status."""
282
+ return run_command_bool(["docker", "stop", container_id], timeout=30)
283
+
284
+
285
+ def resume_container(container_id: str) -> bool:
286
+ """Start a stopped container in background and return success status.
287
+
288
+ Unlike start_container() which attaches interactively, this just starts
289
+ the container and returns immediately. Suitable for batch operations.
290
+ """
291
+ return run_command_bool(["docker", "start", container_id], timeout=30)
292
+
293
+
294
+ def remove_container(container_name: str, force: bool = False) -> bool:
295
+ """Remove a container and return success status."""
296
+ cmd = ["docker", "rm"]
297
+ if force:
298
+ cmd.append("-f")
299
+ cmd.append("--")
300
+ cmd.append(container_name)
301
+ return run_command_bool(cmd, timeout=30)
302
+
303
+
304
+ def _list_all_sandbox_containers() -> list[ContainerInfo]:
305
+ """
306
+ List ALL Claude Code sandbox containers (running AND stopped).
307
+
308
+ This is critical for credential recovery - when user does /exit,
309
+ the container STOPS but still contains the OAuth credentials.
310
+
311
+ Returns list of ContainerInfo objects sorted by most recent first.
312
+ """
313
+ try:
314
+ # Get ALL containers (not just running) filtered by sandbox image
315
+ result = subprocess.run(
316
+ [
317
+ "docker",
318
+ "ps",
319
+ "-a",
320
+ "--filter",
321
+ f"ancestor={SANDBOX_IMAGE}",
322
+ "--format",
323
+ "{{.ID}}\t{{.Names}}\t{{.Status}}",
324
+ ],
325
+ capture_output=True,
326
+ text=True,
327
+ timeout=10,
328
+ )
329
+
330
+ if result.returncode != 0:
331
+ return []
332
+
333
+ containers = []
334
+ for line in result.stdout.strip().split("\n"):
335
+ if line:
336
+ parts = line.split("\t")
337
+ if len(parts) >= 3:
338
+ containers.append(
339
+ ContainerInfo(
340
+ id=parts[0],
341
+ name=parts[1],
342
+ status=parts[2],
343
+ )
344
+ )
345
+
346
+ return containers
347
+ except (subprocess.TimeoutExpired, FileNotFoundError):
348
+ return []
349
+
350
+
351
+ def list_scc_containers() -> list[ContainerInfo]:
352
+ """Return all SCC-managed containers (running and stopped)."""
353
+ try:
354
+ result = subprocess.run(
355
+ [
356
+ "docker",
357
+ "ps",
358
+ "-a",
359
+ "--filter",
360
+ f"label={LABEL_PREFIX}.managed=true",
361
+ "--format",
362
+ '{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Label "scc.profile"}}\t{{.Label "scc.workspace"}}\t{{.Label "scc.branch"}}',
363
+ ],
364
+ capture_output=True,
365
+ text=True,
366
+ timeout=10,
367
+ )
368
+
369
+ if result.returncode != 0:
370
+ return []
371
+
372
+ containers = []
373
+ for line in result.stdout.strip().split("\n"):
374
+ if line:
375
+ parts = line.split("\t")
376
+ if len(parts) >= 3:
377
+ containers.append(
378
+ ContainerInfo(
379
+ id=parts[0],
380
+ name=parts[1],
381
+ status=parts[2],
382
+ profile=parts[3] if len(parts) > 3 else None,
383
+ workspace=parts[4] if len(parts) > 4 else None,
384
+ branch=parts[5] if len(parts) > 5 else None,
385
+ )
386
+ )
387
+
388
+ return containers
389
+ except (subprocess.TimeoutExpired, FileNotFoundError):
390
+ return []
391
+
392
+
393
+ def list_running_sandboxes() -> list[ContainerInfo]:
394
+ """
395
+ Return running Claude Code sandboxes (created by Docker Desktop).
396
+
397
+ Docker sandbox containers are identified by the sandbox image
398
+ (docker/sandbox-templates:claude-code).
399
+ """
400
+ try:
401
+ # Filter by the Docker sandbox image
402
+ result = subprocess.run(
403
+ [
404
+ "docker",
405
+ "ps",
406
+ "--filter",
407
+ f"ancestor={SANDBOX_IMAGE}",
408
+ "--format",
409
+ "{{.ID}}\t{{.Names}}\t{{.Status}}",
410
+ ],
411
+ capture_output=True,
412
+ text=True,
413
+ timeout=10,
414
+ )
415
+
416
+ if result.returncode != 0:
417
+ return []
418
+
419
+ sandboxes = []
420
+ for line in result.stdout.strip().split("\n"):
421
+ if line:
422
+ parts = line.split("\t")
423
+ if len(parts) >= 3:
424
+ sandboxes.append(
425
+ ContainerInfo(
426
+ id=parts[0],
427
+ name=parts[1],
428
+ status=parts[2],
429
+ )
430
+ )
431
+
432
+ return sandboxes
433
+ except (subprocess.TimeoutExpired, FileNotFoundError):
434
+ return []
435
+
436
+
437
+ def validate_container_filename(filename: str) -> str:
438
+ """Validate filename for injection into container volume.
439
+
440
+ SECURITY: Defense-in-depth against path traversal attacks.
441
+ Although files go to a Docker volume (low risk), we validate anyway.
442
+
443
+ Args:
444
+ filename: Filename to validate
445
+
446
+ Returns:
447
+ Validated filename
448
+
449
+ Raises:
450
+ ValueError: If filename contains path traversal or unsafe characters
451
+ """
452
+ if not filename:
453
+ raise ValueError("Filename cannot be empty")
454
+
455
+ # Reject path separators (prevent ../../../etc/passwd attacks)
456
+ if "/" in filename or "\\" in filename:
457
+ raise ValueError(f"Invalid filename: path separators not allowed: {filename}")
458
+
459
+ # Reject hidden files starting with dot (e.g., .bashrc, .profile)
460
+ if filename.startswith("."):
461
+ raise ValueError(f"Invalid filename: hidden files not allowed: {filename}")
462
+
463
+ # Reject null bytes (can truncate strings in some contexts)
464
+ if "\x00" in filename:
465
+ raise ValueError("Invalid filename: null bytes not allowed")
466
+
467
+ return filename