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/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""SCC - Sandboxed Claude CLI.
|
|
2
|
+
|
|
3
|
+
Provide a command-line tool for safely running Claude Code in Docker sandboxes
|
|
4
|
+
with team-specific configurations and worktree management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("scc-cli")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
# Package not installed (e.g., running from source without install)
|
|
13
|
+
__version__ = "0.0.0-dev"
|
|
14
|
+
|
|
15
|
+
__author__ = "Cagri Cimen"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Provide plugin audit module for SCC.
|
|
2
|
+
|
|
3
|
+
Expose functionality for auditing Claude Code plugins,
|
|
4
|
+
including manifest parsing, file reading, and plugin discovery.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from scc_cli.audit.parser import (
|
|
8
|
+
create_missing_result,
|
|
9
|
+
create_parsed_result,
|
|
10
|
+
create_plugin_manifests,
|
|
11
|
+
create_unreadable_result,
|
|
12
|
+
parse_hooks_content,
|
|
13
|
+
parse_json_content,
|
|
14
|
+
parse_mcp_content,
|
|
15
|
+
)
|
|
16
|
+
from scc_cli.audit.reader import (
|
|
17
|
+
audit_all_plugins,
|
|
18
|
+
audit_plugin,
|
|
19
|
+
discover_installed_plugins,
|
|
20
|
+
read_plugin_manifests,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
# Parser functions
|
|
25
|
+
"create_missing_result",
|
|
26
|
+
"create_parsed_result",
|
|
27
|
+
"create_plugin_manifests",
|
|
28
|
+
"create_unreadable_result",
|
|
29
|
+
"parse_hooks_content",
|
|
30
|
+
"parse_json_content",
|
|
31
|
+
"parse_mcp_content",
|
|
32
|
+
# Reader functions
|
|
33
|
+
"audit_all_plugins",
|
|
34
|
+
"audit_plugin",
|
|
35
|
+
"discover_installed_plugins",
|
|
36
|
+
"read_plugin_manifests",
|
|
37
|
+
]
|
scc_cli/audit/parser.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Provide pure functions for parsing plugin manifest files.
|
|
2
|
+
|
|
3
|
+
Implement parsing logic for:
|
|
4
|
+
- .mcp.json files (MCP server definitions)
|
|
5
|
+
- hooks/hooks.json files (hook definitions)
|
|
6
|
+
|
|
7
|
+
All functions are pure (no I/O) - file reading is handled elsewhere.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from scc_cli.models.plugin_audit import (
|
|
17
|
+
HookInfo,
|
|
18
|
+
ManifestResult,
|
|
19
|
+
ManifestStatus,
|
|
20
|
+
MCPServerInfo,
|
|
21
|
+
ParseError,
|
|
22
|
+
PluginManifests,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_json_content(content: str) -> ManifestResult:
|
|
27
|
+
"""Parse JSON content string into a ManifestResult.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
content: Raw JSON string to parse.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
ManifestResult with PARSED status if valid JSON,
|
|
34
|
+
or MALFORMED status with error details if invalid.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
parsed = json.loads(content)
|
|
38
|
+
return ManifestResult(
|
|
39
|
+
status=ManifestStatus.PARSED,
|
|
40
|
+
content=parsed,
|
|
41
|
+
)
|
|
42
|
+
except json.JSONDecodeError as e:
|
|
43
|
+
return ManifestResult(
|
|
44
|
+
status=ManifestStatus.MALFORMED,
|
|
45
|
+
error=ParseError.from_json_error(e),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_mcp_content(content: dict[str, Any]) -> list[MCPServerInfo]:
|
|
50
|
+
"""Extract MCP server information from parsed .mcp.json content.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
content: Parsed JSON content from .mcp.json file.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
List of MCPServerInfo objects for each declared server.
|
|
57
|
+
"""
|
|
58
|
+
servers: list[MCPServerInfo] = []
|
|
59
|
+
mcp_servers = content.get("mcpServers", {})
|
|
60
|
+
|
|
61
|
+
for name, config in mcp_servers.items():
|
|
62
|
+
if not isinstance(config, dict):
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
# Default transport is 'stdio' when not specified
|
|
66
|
+
transport = config.get("transport", "stdio")
|
|
67
|
+
|
|
68
|
+
servers.append(
|
|
69
|
+
MCPServerInfo(
|
|
70
|
+
name=name,
|
|
71
|
+
transport=transport,
|
|
72
|
+
command=config.get("command"),
|
|
73
|
+
url=config.get("url"),
|
|
74
|
+
description=config.get("description"),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return servers
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_hooks_content(content: dict[str, Any]) -> list[HookInfo]:
|
|
82
|
+
"""Extract hook information from parsed hooks.json content.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
content: Parsed JSON content from hooks.json file.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of HookInfo objects for each declared hook.
|
|
89
|
+
"""
|
|
90
|
+
hooks_list: list[HookInfo] = []
|
|
91
|
+
hooks_config = content.get("hooks", {})
|
|
92
|
+
|
|
93
|
+
for event, event_hooks in hooks_config.items():
|
|
94
|
+
if not isinstance(event_hooks, list):
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
for hook_group in event_hooks:
|
|
98
|
+
if not isinstance(hook_group, dict):
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
matcher = hook_group.get("matcher")
|
|
102
|
+
hooks = hook_group.get("hooks", [])
|
|
103
|
+
|
|
104
|
+
if not isinstance(hooks, list):
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
for hook in hooks:
|
|
108
|
+
if not isinstance(hook, dict):
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
hook_type = hook.get("type", "unknown")
|
|
112
|
+
hooks_list.append(
|
|
113
|
+
HookInfo(
|
|
114
|
+
event=event,
|
|
115
|
+
hook_type=hook_type,
|
|
116
|
+
matcher=matcher,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return hooks_list
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def create_missing_result(path: Path) -> ManifestResult:
|
|
124
|
+
"""Create a ManifestResult for a missing manifest file.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
path: Relative path where manifest was expected.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
ManifestResult with MISSING status.
|
|
131
|
+
"""
|
|
132
|
+
return ManifestResult(
|
|
133
|
+
status=ManifestStatus.MISSING,
|
|
134
|
+
path=path,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def create_unreadable_result(path: Path, error_message: str) -> ManifestResult:
|
|
139
|
+
"""Create a ManifestResult for an unreadable manifest file.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
path: Relative path to the manifest file.
|
|
143
|
+
error_message: Description of the read error.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
ManifestResult with UNREADABLE status.
|
|
147
|
+
"""
|
|
148
|
+
return ManifestResult(
|
|
149
|
+
status=ManifestStatus.UNREADABLE,
|
|
150
|
+
path=path,
|
|
151
|
+
error_message=error_message,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def create_parsed_result(path: Path, content: dict[str, Any]) -> ManifestResult:
|
|
156
|
+
"""Create a ManifestResult for a successfully parsed manifest.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
path: Relative path to the manifest file.
|
|
160
|
+
content: Parsed JSON content.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
ManifestResult with PARSED status.
|
|
164
|
+
"""
|
|
165
|
+
return ManifestResult(
|
|
166
|
+
status=ManifestStatus.PARSED,
|
|
167
|
+
path=path,
|
|
168
|
+
content=content,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def create_plugin_manifests(
|
|
173
|
+
mcp_result: ManifestResult,
|
|
174
|
+
hooks_result: ManifestResult,
|
|
175
|
+
plugin_json_result: ManifestResult | None = None,
|
|
176
|
+
) -> PluginManifests:
|
|
177
|
+
"""Create a PluginManifests aggregate from individual manifest results.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
mcp_result: Result of parsing .mcp.json.
|
|
181
|
+
hooks_result: Result of parsing hooks/hooks.json.
|
|
182
|
+
plugin_json_result: Optional result of parsing .claude-plugin/plugin.json.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
PluginManifests containing all manifest results.
|
|
186
|
+
"""
|
|
187
|
+
return PluginManifests(
|
|
188
|
+
mcp=mcp_result,
|
|
189
|
+
hooks=hooks_result,
|
|
190
|
+
plugin_json=plugin_json_result,
|
|
191
|
+
)
|
scc_cli/audit/reader.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Provide I/O layer for reading plugin manifests and discovering installed plugins.
|
|
2
|
+
|
|
3
|
+
Implement file system operations for:
|
|
4
|
+
- Reading manifest files from plugin directories
|
|
5
|
+
- Discovering installed plugins from the Claude Code registry
|
|
6
|
+
- Creating audit results for plugins
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from scc_cli.audit.parser import (
|
|
17
|
+
create_missing_result,
|
|
18
|
+
create_parsed_result,
|
|
19
|
+
create_plugin_manifests,
|
|
20
|
+
create_unreadable_result,
|
|
21
|
+
parse_json_content,
|
|
22
|
+
)
|
|
23
|
+
from scc_cli.models.plugin_audit import (
|
|
24
|
+
AuditOutput,
|
|
25
|
+
ManifestResult,
|
|
26
|
+
ManifestStatus,
|
|
27
|
+
PluginAuditResult,
|
|
28
|
+
PluginManifests,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_manifest_file(plugin_dir: Path, relative_path: Path) -> ManifestResult:
|
|
35
|
+
"""Read and parse a manifest file from a plugin directory.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
plugin_dir: Absolute path to the plugin directory.
|
|
39
|
+
relative_path: Relative path to the manifest file within the plugin.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
ManifestResult with appropriate status based on file existence,
|
|
43
|
+
readability, and JSON validity.
|
|
44
|
+
"""
|
|
45
|
+
full_path = plugin_dir / relative_path
|
|
46
|
+
|
|
47
|
+
if not full_path.exists():
|
|
48
|
+
return create_missing_result(relative_path)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
content = full_path.read_text(encoding="utf-8")
|
|
52
|
+
except PermissionError as e:
|
|
53
|
+
return create_unreadable_result(relative_path, str(e))
|
|
54
|
+
except UnicodeDecodeError as e:
|
|
55
|
+
return create_unreadable_result(relative_path, f"invalid encoding: {e}")
|
|
56
|
+
except OSError as e:
|
|
57
|
+
return create_unreadable_result(relative_path, str(e))
|
|
58
|
+
|
|
59
|
+
# Parse the JSON content
|
|
60
|
+
result = parse_json_content(content)
|
|
61
|
+
|
|
62
|
+
# Add the path to the result
|
|
63
|
+
if result.status == ManifestStatus.PARSED:
|
|
64
|
+
return create_parsed_result(relative_path, result.content) # type: ignore[arg-type]
|
|
65
|
+
elif result.status == ManifestStatus.MALFORMED:
|
|
66
|
+
return ManifestResult(
|
|
67
|
+
status=ManifestStatus.MALFORMED,
|
|
68
|
+
path=relative_path,
|
|
69
|
+
error=result.error,
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_plugin_manifests(plugin_dir: Path) -> PluginManifests:
|
|
76
|
+
"""Read all manifest files from a plugin directory.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
plugin_dir: Absolute path to the plugin directory.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
PluginManifests containing results for all manifest files.
|
|
83
|
+
"""
|
|
84
|
+
mcp_result = read_manifest_file(plugin_dir, Path(".mcp.json"))
|
|
85
|
+
hooks_result = read_manifest_file(plugin_dir, Path("hooks/hooks.json"))
|
|
86
|
+
|
|
87
|
+
return create_plugin_manifests(mcp_result, hooks_result)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def discover_installed_plugins(claude_dir: Path) -> list[dict[str, Any]]:
|
|
91
|
+
"""Discover installed plugins from the Claude Code registry.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
claude_dir: Path to the .claude directory (typically ~/.claude).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of plugin info dictionaries from installed_plugins.json.
|
|
98
|
+
Returns empty list if registry doesn't exist or is malformed.
|
|
99
|
+
"""
|
|
100
|
+
registry_path = claude_dir / "plugins" / "installed_plugins.json"
|
|
101
|
+
|
|
102
|
+
if not registry_path.exists():
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
content = registry_path.read_text(encoding="utf-8")
|
|
107
|
+
data = json.loads(content)
|
|
108
|
+
items: list[dict[str, Any]] = data.get("items", [])
|
|
109
|
+
return items
|
|
110
|
+
except (PermissionError, OSError) as e:
|
|
111
|
+
logger.warning("Cannot read plugin registry: %s", e)
|
|
112
|
+
return []
|
|
113
|
+
except json.JSONDecodeError as e:
|
|
114
|
+
logger.warning("Plugin registry is malformed: %s", e)
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def audit_plugin(plugin_info: dict[str, Any]) -> PluginAuditResult:
|
|
119
|
+
"""Audit a single plugin based on its registry information.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
plugin_info: Plugin info dictionary from installed_plugins.json.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
PluginAuditResult with manifest parsing results.
|
|
126
|
+
"""
|
|
127
|
+
name = plugin_info.get("name", "unknown")
|
|
128
|
+
marketplace = plugin_info.get("marketplace", "unknown")
|
|
129
|
+
version = plugin_info.get("version", "unknown")
|
|
130
|
+
install_path_str = plugin_info.get("installPath", "")
|
|
131
|
+
|
|
132
|
+
plugin_id = f"{name}@{marketplace}"
|
|
133
|
+
# Type-safe path creation: only convert if it's a non-empty string
|
|
134
|
+
install_path = (
|
|
135
|
+
Path(install_path_str) if isinstance(install_path_str, str) and install_path_str else None
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Check if plugin directory exists
|
|
139
|
+
if install_path is None or not install_path.exists():
|
|
140
|
+
return PluginAuditResult(
|
|
141
|
+
plugin_id=plugin_id,
|
|
142
|
+
plugin_name=name,
|
|
143
|
+
marketplace=marketplace,
|
|
144
|
+
version=version,
|
|
145
|
+
install_path=install_path,
|
|
146
|
+
installed=False,
|
|
147
|
+
manifests=None,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Read manifests from the plugin directory
|
|
151
|
+
manifests = read_plugin_manifests(install_path)
|
|
152
|
+
|
|
153
|
+
return PluginAuditResult(
|
|
154
|
+
plugin_id=plugin_id,
|
|
155
|
+
plugin_name=name,
|
|
156
|
+
marketplace=marketplace,
|
|
157
|
+
version=version,
|
|
158
|
+
install_path=install_path,
|
|
159
|
+
installed=True,
|
|
160
|
+
manifests=manifests,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def audit_all_plugins(claude_dir: Path) -> AuditOutput:
|
|
165
|
+
"""Audit all installed plugins.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
claude_dir: Path to the .claude directory.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
AuditOutput with results for all plugins.
|
|
172
|
+
"""
|
|
173
|
+
plugins = discover_installed_plugins(claude_dir)
|
|
174
|
+
results = [audit_plugin(plugin) for plugin in plugins]
|
|
175
|
+
|
|
176
|
+
return AuditOutput(
|
|
177
|
+
schema_version=1,
|
|
178
|
+
plugins=results,
|
|
179
|
+
warnings=[],
|
|
180
|
+
)
|
scc_cli/auth.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resolve authentication from environment variables and commands.
|
|
3
|
+
|
|
4
|
+
Consolidate auth logic from remote.py and claude_adapter.py.
|
|
5
|
+
Use safe command execution (no shell=True).
|
|
6
|
+
|
|
7
|
+
Trust Model:
|
|
8
|
+
- User config auth: Trusted (local file, user controls)
|
|
9
|
+
- Remote org config auth: Less trusted (org admin controls)
|
|
10
|
+
- command: requires explicit opt-in for remote sources
|
|
11
|
+
|
|
12
|
+
Security Features:
|
|
13
|
+
- shell=False to prevent shell injection
|
|
14
|
+
- Platform-aware shlex.split() for command parsing
|
|
15
|
+
- Binary validation with shutil.which()
|
|
16
|
+
- Sanitized error messages (no secrets in errors)
|
|
17
|
+
- Reduced timeout (10s instead of 30s)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import shlex
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class AuthResult:
|
|
30
|
+
"""Result of resolving auth spec.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
token: The resolved authentication token
|
|
34
|
+
source: Where the token came from ('env' or 'command')
|
|
35
|
+
env_name: The environment variable name (if source='env')
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
token: str
|
|
39
|
+
source: str # 'env' or 'command'
|
|
40
|
+
env_name: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_auth(
|
|
44
|
+
auth_spec: str | None,
|
|
45
|
+
allow_command: bool = True,
|
|
46
|
+
) -> AuthResult | None:
|
|
47
|
+
"""Resolve auth spec to token.
|
|
48
|
+
|
|
49
|
+
Supports:
|
|
50
|
+
- env:VAR_NAME - read from environment variable (always allowed)
|
|
51
|
+
- command:CMD - execute command (requires allow_command=True)
|
|
52
|
+
- None or empty - no auth needed
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
auth_spec: Auth specification string
|
|
56
|
+
allow_command: Whether to allow command: specs (default True for
|
|
57
|
+
user config, should be False for remote org config unless
|
|
58
|
+
explicitly opted in)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
AuthResult with token and source, or None if no auth
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: Invalid auth spec format or command not allowed
|
|
65
|
+
RuntimeError: Auth command failed
|
|
66
|
+
"""
|
|
67
|
+
if not auth_spec or not auth_spec.strip():
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
auth_spec = auth_spec.strip()
|
|
71
|
+
|
|
72
|
+
if auth_spec.startswith("env:"):
|
|
73
|
+
return _resolve_env_auth(auth_spec)
|
|
74
|
+
|
|
75
|
+
if auth_spec.startswith("command:"):
|
|
76
|
+
if not allow_command:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"command: auth not allowed from remote config. "
|
|
79
|
+
"Use --allow-remote-commands or SCC_ALLOW_REMOTE_COMMANDS=1"
|
|
80
|
+
)
|
|
81
|
+
return _execute_auth_command(auth_spec[8:].strip())
|
|
82
|
+
|
|
83
|
+
raise ValueError(f"Invalid auth spec format: {auth_spec}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve_env_auth(auth_spec: str) -> AuthResult | None:
|
|
87
|
+
"""Resolve env:VAR_NAME auth spec."""
|
|
88
|
+
var_name = auth_spec[4:].strip()
|
|
89
|
+
if not var_name:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
token = os.environ.get(var_name)
|
|
93
|
+
if token and token.strip():
|
|
94
|
+
return AuthResult(token=token.strip(), source="env", env_name=var_name)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _execute_auth_command(cmd: str) -> AuthResult | None:
|
|
99
|
+
"""Execute auth command safely (no shell injection).
|
|
100
|
+
|
|
101
|
+
SECURITY: Uses shell=False and shlex.split() to prevent shell injection.
|
|
102
|
+
"""
|
|
103
|
+
if not cmd:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
# Platform-aware splitting (Windows uses different escaping)
|
|
108
|
+
use_posix = sys.platform != "win32"
|
|
109
|
+
args = shlex.split(cmd, posix=use_posix)
|
|
110
|
+
except ValueError:
|
|
111
|
+
raise RuntimeError("Invalid auth command syntax") from None
|
|
112
|
+
|
|
113
|
+
if not args:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
# Validate executable exists (prevents PATH hijacking, clearer errors)
|
|
117
|
+
executable = shutil.which(args[0])
|
|
118
|
+
if not executable:
|
|
119
|
+
raise RuntimeError(f"Auth command executable not found: {args[0]}")
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
result = subprocess.run(
|
|
123
|
+
[executable] + args[1:], # Use resolved absolute path
|
|
124
|
+
shell=False, # CRITICAL: No shell interpretation
|
|
125
|
+
capture_output=True,
|
|
126
|
+
text=True,
|
|
127
|
+
timeout=10, # Reduced from 30s
|
|
128
|
+
)
|
|
129
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
130
|
+
return AuthResult(token=result.stdout.strip(), source="command")
|
|
131
|
+
return None
|
|
132
|
+
except subprocess.TimeoutExpired:
|
|
133
|
+
raise RuntimeError("Auth command timed out after 10s")
|
|
134
|
+
except OSError:
|
|
135
|
+
# Don't leak command in error (might contain secrets)
|
|
136
|
+
raise RuntimeError("Auth command failed")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def is_remote_command_allowed() -> bool:
|
|
140
|
+
"""Check if command: auth is allowed from remote org config.
|
|
141
|
+
|
|
142
|
+
Returns True if SCC_ALLOW_REMOTE_COMMANDS is set to 1, true, or yes.
|
|
143
|
+
"""
|
|
144
|
+
value = os.environ.get("SCC_ALLOW_REMOTE_COMMANDS", "").lower()
|
|
145
|
+
return value in ("1", "true", "yes")
|