scc-cli 1.4.1__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 +259 -0
- scc_cli/cli_admin.py +706 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1454 -0
- scc_cli/cli_org.py +1428 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +892 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -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 +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +604 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1074 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -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/exit_codes.py +55 -0
- scc_cli/git.py +1521 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -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 +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +257 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +260 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -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/sessions.py +425 -0
- scc_cli/setup.py +588 -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 +382 -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 +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +677 -0
- scc_cli/ui/dashboard/loaders.py +395 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +390 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +538 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +675 -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 +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.1.dist-info/METADATA +369 -0
- scc_cli-1.4.1.dist-info/RECORD +113 -0
- scc_cli-1.4.1.dist-info/WHEEL +4 -0
- scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
- scc_cli-1.4.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Trust validation for federated team configurations (Phase 2).
|
|
2
|
+
|
|
3
|
+
This module provides:
|
|
4
|
+
- TrustViolationError: Raised when a team violates org trust grants
|
|
5
|
+
- SecurityViolationError: Raised when org security policies are violated
|
|
6
|
+
- validate_marketplace_source(): Validate marketplace URL against patterns
|
|
7
|
+
- validate_team_config_trust(): Two-layer validation for team configs
|
|
8
|
+
- get_source_url(): Extract URL from any MarketplaceSource type
|
|
9
|
+
|
|
10
|
+
Trust Model:
|
|
11
|
+
Orgs define trust grants that control what teams can do:
|
|
12
|
+
- inherit_org_marketplaces: Can team use org's marketplaces?
|
|
13
|
+
- allow_additional_marketplaces: Can team define their own?
|
|
14
|
+
- marketplace_source_patterns: Which URLs are allowed for team marketplaces?
|
|
15
|
+
|
|
16
|
+
Security Model:
|
|
17
|
+
Org security rules (blocked_plugins, etc.) are ALWAYS enforced,
|
|
18
|
+
regardless of team configuration or trust grants.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from scc_cli.marketplace.constants import IMPLICIT_MARKETPLACES
|
|
26
|
+
from scc_cli.marketplace.normalize import (
|
|
27
|
+
matches_any_url_pattern,
|
|
28
|
+
normalize_url_for_matching,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from scc_cli.marketplace.schema import (
|
|
33
|
+
MarketplaceSource,
|
|
34
|
+
TeamConfig,
|
|
35
|
+
TrustGrant,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
# Exceptions
|
|
41
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TrustViolationError(ValueError):
|
|
45
|
+
"""Raised when a federated team violates org trust grants.
|
|
46
|
+
|
|
47
|
+
This occurs when a team:
|
|
48
|
+
- Defines marketplaces without allow_additional_marketplaces
|
|
49
|
+
- Uses marketplace sources not matching allowed patterns
|
|
50
|
+
- Defines a marketplace name that conflicts with org/implicit marketplaces
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, team_name: str, violation: str) -> None:
|
|
54
|
+
self.team_name = team_name
|
|
55
|
+
self.violation = violation
|
|
56
|
+
super().__init__(f"Trust violation for team '{team_name}': {violation}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SecurityViolationError(ValueError):
|
|
60
|
+
"""Raised when org security policies are violated.
|
|
61
|
+
|
|
62
|
+
This occurs when:
|
|
63
|
+
- A plugin matches security.blocked_plugins pattern
|
|
64
|
+
- A plugin references a blocked marketplace
|
|
65
|
+
- Other org-level security rules are violated
|
|
66
|
+
|
|
67
|
+
Security rules are ALWAYS enforced, regardless of trust grants.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, plugin_ref: str, reason: str) -> None:
|
|
71
|
+
self.plugin_ref = plugin_ref
|
|
72
|
+
self.reason = reason
|
|
73
|
+
super().__init__(f"Security violation: Plugin '{plugin_ref}' blocked - {reason}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
# Helper Functions
|
|
78
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_source_url(source: MarketplaceSource) -> str | None:
|
|
82
|
+
"""Extract URL from any MarketplaceSource type for pattern matching.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
source: Any MarketplaceSource variant (GitHub, Git, URL, Directory)
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Normalized URL string for remote sources, None for directory sources
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
>>> get_source_url(MarketplaceSourceGitHub(source="github", owner="org", repo="plugins"))
|
|
92
|
+
'github.com/org/plugins'
|
|
93
|
+
|
|
94
|
+
>>> get_source_url(MarketplaceSourceDirectory(source="directory", path="/local"))
|
|
95
|
+
None
|
|
96
|
+
"""
|
|
97
|
+
# Import here to avoid circular imports
|
|
98
|
+
from scc_cli.marketplace.schema import (
|
|
99
|
+
MarketplaceSourceDirectory,
|
|
100
|
+
MarketplaceSourceGit,
|
|
101
|
+
MarketplaceSourceGitHub,
|
|
102
|
+
MarketplaceSourceURL,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if isinstance(source, MarketplaceSourceGitHub):
|
|
106
|
+
# Construct GitHub URL: github.com/owner/repo
|
|
107
|
+
return f"github.com/{source.owner}/{source.repo}"
|
|
108
|
+
|
|
109
|
+
if isinstance(source, MarketplaceSourceGit):
|
|
110
|
+
# Normalize the git clone URL
|
|
111
|
+
return normalize_url_for_matching(source.url)
|
|
112
|
+
|
|
113
|
+
if isinstance(source, MarketplaceSourceURL):
|
|
114
|
+
# Normalize the HTTPS URL
|
|
115
|
+
return normalize_url_for_matching(source.url)
|
|
116
|
+
|
|
117
|
+
if isinstance(source, MarketplaceSourceDirectory):
|
|
118
|
+
# Directory sources are local, no URL to match
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
# Unknown source type - shouldn't happen with proper typing
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
126
|
+
# Validation Functions
|
|
127
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def validate_marketplace_source(
|
|
131
|
+
source: MarketplaceSource,
|
|
132
|
+
allowed_patterns: list[str],
|
|
133
|
+
team_name: str,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Validate a marketplace source against allowed URL patterns.
|
|
136
|
+
|
|
137
|
+
Directory sources are always allowed (org-local).
|
|
138
|
+
Remote sources must match at least one allowed pattern.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
source: MarketplaceSource to validate
|
|
142
|
+
allowed_patterns: List of URL glob patterns (with ** support)
|
|
143
|
+
team_name: Team name for error messages
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
TrustViolationError: If source doesn't match any allowed pattern
|
|
147
|
+
"""
|
|
148
|
+
url = get_source_url(source)
|
|
149
|
+
|
|
150
|
+
# Directory sources are org-local, always allowed
|
|
151
|
+
if url is None:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# Remote sources must match an allowed pattern
|
|
155
|
+
if not allowed_patterns:
|
|
156
|
+
raise TrustViolationError(
|
|
157
|
+
team_name=team_name,
|
|
158
|
+
violation=(
|
|
159
|
+
f"Marketplace source '{url}' not allowed (no patterns defined). "
|
|
160
|
+
"Ask org admin to add marketplace_source_patterns to the team's trust grant."
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
matched = matches_any_url_pattern(url, allowed_patterns)
|
|
165
|
+
if matched is None:
|
|
166
|
+
patterns_str = ", ".join(allowed_patterns)
|
|
167
|
+
raise TrustViolationError(
|
|
168
|
+
team_name=team_name,
|
|
169
|
+
violation=(
|
|
170
|
+
f"Marketplace source '{url}' doesn't match any allowed pattern. "
|
|
171
|
+
f"Allowed patterns: [{patterns_str}]. "
|
|
172
|
+
"Ask org admin to add a matching pattern, or use an allowed source."
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def validate_team_config_trust(
|
|
178
|
+
team_config: TeamConfig,
|
|
179
|
+
trust: TrustGrant,
|
|
180
|
+
team_name: str,
|
|
181
|
+
org_marketplaces: dict[str, Any],
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Validate team config against trust grants (two-layer validation).
|
|
184
|
+
|
|
185
|
+
Layer 1: Check if team is allowed to define marketplaces at all
|
|
186
|
+
Layer 2: Validate each marketplace source against allowed patterns
|
|
187
|
+
|
|
188
|
+
Also checks for marketplace name collisions with:
|
|
189
|
+
- Org-defined marketplaces (keys in org_marketplaces)
|
|
190
|
+
- Implicit marketplaces (claude-plugins-official, etc.)
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
team_config: External team configuration to validate
|
|
194
|
+
trust: Trust grant from org to this team
|
|
195
|
+
team_name: Team name for error messages
|
|
196
|
+
org_marketplaces: Dict of org-defined marketplaces (for collision check)
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
TrustViolationError: If team violates any trust constraint
|
|
200
|
+
"""
|
|
201
|
+
# No marketplaces defined - nothing to validate
|
|
202
|
+
if not team_config.marketplaces:
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
# Layer 1: Check if team is allowed to define marketplaces
|
|
206
|
+
if not trust.allow_additional_marketplaces:
|
|
207
|
+
names = list(team_config.marketplaces.keys())
|
|
208
|
+
raise TrustViolationError(
|
|
209
|
+
team_name=team_name,
|
|
210
|
+
violation=(
|
|
211
|
+
f"Team defines marketplaces ({names}) but trust grant has "
|
|
212
|
+
"allow_additional_marketplaces=False. Ask org admin to enable."
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Check for marketplace name collisions
|
|
217
|
+
for mp_name in team_config.marketplaces:
|
|
218
|
+
# Collision with org marketplace?
|
|
219
|
+
if mp_name in org_marketplaces:
|
|
220
|
+
raise TrustViolationError(
|
|
221
|
+
team_name=team_name,
|
|
222
|
+
violation=(
|
|
223
|
+
f"Team marketplace '{mp_name}' conflicts with org-defined marketplace. "
|
|
224
|
+
"Choose a different name."
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Collision with implicit marketplace?
|
|
229
|
+
if mp_name in IMPLICIT_MARKETPLACES:
|
|
230
|
+
raise TrustViolationError(
|
|
231
|
+
team_name=team_name,
|
|
232
|
+
violation=(
|
|
233
|
+
f"Team marketplace '{mp_name}' conflicts with implicit marketplace. "
|
|
234
|
+
f"Reserved names: {list(IMPLICIT_MARKETPLACES)}. Choose a different name."
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Layer 2: Validate each marketplace source against allowed patterns
|
|
239
|
+
for mp_name, source in team_config.marketplaces.items():
|
|
240
|
+
validate_marketplace_source(
|
|
241
|
+
source=source,
|
|
242
|
+
allowed_patterns=trust.marketplace_source_patterns,
|
|
243
|
+
team_name=team_name,
|
|
244
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Data models for SCC exception system and plugin audit."""
|
|
2
|
+
|
|
3
|
+
from scc_cli.models.exceptions import (
|
|
4
|
+
AllowTargets,
|
|
5
|
+
BlockReason,
|
|
6
|
+
Exception,
|
|
7
|
+
ExceptionFile,
|
|
8
|
+
exception_file_from_json,
|
|
9
|
+
exception_file_to_json,
|
|
10
|
+
generate_local_id,
|
|
11
|
+
)
|
|
12
|
+
from scc_cli.models.plugin_audit import (
|
|
13
|
+
AuditOutput,
|
|
14
|
+
HookInfo,
|
|
15
|
+
ManifestResult,
|
|
16
|
+
ManifestStatus,
|
|
17
|
+
MCPServerInfo,
|
|
18
|
+
ParseError,
|
|
19
|
+
PluginAuditResult,
|
|
20
|
+
PluginManifests,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
# Exception system models
|
|
25
|
+
"AllowTargets",
|
|
26
|
+
"BlockReason",
|
|
27
|
+
"Exception",
|
|
28
|
+
"ExceptionFile",
|
|
29
|
+
"exception_file_from_json",
|
|
30
|
+
"exception_file_to_json",
|
|
31
|
+
"generate_local_id",
|
|
32
|
+
# Plugin audit models
|
|
33
|
+
"AuditOutput",
|
|
34
|
+
"HookInfo",
|
|
35
|
+
"ManifestResult",
|
|
36
|
+
"ManifestStatus",
|
|
37
|
+
"MCPServerInfo",
|
|
38
|
+
"ParseError",
|
|
39
|
+
"PluginAuditResult",
|
|
40
|
+
"PluginManifests",
|
|
41
|
+
]
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Define exception system data models for SCC Phase 2.1.
|
|
2
|
+
|
|
3
|
+
Provide the core data structures for the time-bounded exception system
|
|
4
|
+
that lets developers unblock themselves from delegation failures while
|
|
5
|
+
preserving security boundaries.
|
|
6
|
+
|
|
7
|
+
Key concepts:
|
|
8
|
+
- BlockReason: Distinguishes SECURITY (policy-only override) from
|
|
9
|
+
DELEGATION (local override allowed)
|
|
10
|
+
- AllowTargets: Specifies plugins, mcp_servers, base_images to allow
|
|
11
|
+
- Exception: A single time-bounded exception with metadata
|
|
12
|
+
- ExceptionFile: Envelope with schema versioning for forward compatibility
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import secrets
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import Any, Literal
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BlockReason(Enum):
|
|
26
|
+
"""Classifies why something was blocked.
|
|
27
|
+
|
|
28
|
+
SECURITY: Blocked by org security policy. Only policy exceptions
|
|
29
|
+
(PR-approved) can override.
|
|
30
|
+
DELEGATION: Denied because team not delegated for additions.
|
|
31
|
+
Local overrides (self-serve) can unblock.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
SECURITY = "security"
|
|
35
|
+
DELEGATION = "delegation"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class AllowTargets:
|
|
40
|
+
"""Specifies what an exception allows.
|
|
41
|
+
|
|
42
|
+
SCC-shaped targets only:
|
|
43
|
+
- plugins: Plugin IDs/names to allow
|
|
44
|
+
- mcp_servers: SCC-managed MCP server names to allow
|
|
45
|
+
- base_images: Docker image refs/patterns to allow
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
plugins: list[str] = field(default_factory=list)
|
|
49
|
+
mcp_servers: list[str] = field(default_factory=list)
|
|
50
|
+
base_images: list[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
def is_empty(self) -> bool:
|
|
53
|
+
"""Return True if no targets are specified."""
|
|
54
|
+
return not self.plugins and not self.mcp_servers and not self.base_images
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
"""Serialize to dictionary."""
|
|
58
|
+
return {
|
|
59
|
+
"plugins": self.plugins,
|
|
60
|
+
"mcp_servers": self.mcp_servers,
|
|
61
|
+
"base_images": self.base_images,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_dict(cls, d: dict[str, Any]) -> AllowTargets:
|
|
66
|
+
"""Deserialize from dictionary, handling missing keys gracefully."""
|
|
67
|
+
return cls(
|
|
68
|
+
plugins=d.get("plugins", []),
|
|
69
|
+
mcp_servers=d.get("mcp_servers", []),
|
|
70
|
+
base_images=d.get("base_images", []),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Exception:
|
|
76
|
+
"""A time-bounded exception that allows specific resources.
|
|
77
|
+
|
|
78
|
+
Required fields:
|
|
79
|
+
- id: Unique identifier (local-YYYYMMDD-XXXX for local, user-provided for policy)
|
|
80
|
+
- created_at: RFC3339 timestamp in UTC
|
|
81
|
+
- expires_at: RFC3339 timestamp in UTC
|
|
82
|
+
- reason: Required non-empty explanation
|
|
83
|
+
- scope: "policy" or "local"
|
|
84
|
+
- allow: AllowTargets specifying what to allow
|
|
85
|
+
|
|
86
|
+
Optional metadata:
|
|
87
|
+
- created_by: Username/email of creator
|
|
88
|
+
- created_on: Hostname where created
|
|
89
|
+
- source: Derived at runtime (org|team|project|repo|user)
|
|
90
|
+
|
|
91
|
+
Forward compatibility:
|
|
92
|
+
- _extra: Dict preserving unknown fields from newer schema versions
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
id: str
|
|
96
|
+
created_at: str
|
|
97
|
+
expires_at: str
|
|
98
|
+
reason: str
|
|
99
|
+
scope: Literal["policy", "local"]
|
|
100
|
+
allow: AllowTargets
|
|
101
|
+
|
|
102
|
+
# Optional metadata
|
|
103
|
+
created_by: str | None = None
|
|
104
|
+
created_on: str | None = None
|
|
105
|
+
source: str | None = None
|
|
106
|
+
|
|
107
|
+
# Forward compatibility
|
|
108
|
+
_extra: dict[str, Any] = field(default_factory=dict)
|
|
109
|
+
|
|
110
|
+
def is_expired(self) -> bool:
|
|
111
|
+
"""Return True if exception has expired."""
|
|
112
|
+
now = datetime.now(timezone.utc)
|
|
113
|
+
# Parse expires_at as RFC3339
|
|
114
|
+
expires = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
|
|
115
|
+
return now > expires
|
|
116
|
+
|
|
117
|
+
def to_dict(self) -> dict[str, Any]:
|
|
118
|
+
"""Serialize to dictionary, including _extra fields."""
|
|
119
|
+
result: dict[str, Any] = {
|
|
120
|
+
"id": self.id,
|
|
121
|
+
"created_at": self.created_at,
|
|
122
|
+
"expires_at": self.expires_at,
|
|
123
|
+
"reason": self.reason,
|
|
124
|
+
"scope": self.scope,
|
|
125
|
+
"allow": self.allow.to_dict(),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Add optional metadata if present
|
|
129
|
+
if self.created_by is not None:
|
|
130
|
+
result["created_by"] = self.created_by
|
|
131
|
+
if self.created_on is not None:
|
|
132
|
+
result["created_on"] = self.created_on
|
|
133
|
+
if self.source is not None:
|
|
134
|
+
result["source"] = self.source
|
|
135
|
+
|
|
136
|
+
# Include _extra fields at top level for roundtrip
|
|
137
|
+
result.update(self._extra)
|
|
138
|
+
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_dict(cls, d: dict[str, Any]) -> Exception:
|
|
143
|
+
"""Deserialize from dictionary, preserving unknown fields in _extra."""
|
|
144
|
+
known_keys = {
|
|
145
|
+
"id",
|
|
146
|
+
"created_at",
|
|
147
|
+
"expires_at",
|
|
148
|
+
"reason",
|
|
149
|
+
"scope",
|
|
150
|
+
"allow",
|
|
151
|
+
"created_by",
|
|
152
|
+
"created_on",
|
|
153
|
+
"source",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Extract _extra fields
|
|
157
|
+
extra = {k: v for k, v in d.items() if k not in known_keys}
|
|
158
|
+
|
|
159
|
+
return cls(
|
|
160
|
+
id=d["id"],
|
|
161
|
+
created_at=d["created_at"],
|
|
162
|
+
expires_at=d["expires_at"],
|
|
163
|
+
reason=d["reason"],
|
|
164
|
+
scope=d["scope"],
|
|
165
|
+
allow=AllowTargets.from_dict(d.get("allow", {})),
|
|
166
|
+
created_by=d.get("created_by"),
|
|
167
|
+
created_on=d.get("created_on"),
|
|
168
|
+
source=d.get("source"),
|
|
169
|
+
_extra=extra,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class ExceptionFile:
|
|
175
|
+
"""Envelope for exception storage with schema versioning.
|
|
176
|
+
|
|
177
|
+
Provides forward compatibility through:
|
|
178
|
+
- schema_version: Current schema version (1)
|
|
179
|
+
- tool_version: SCC version that wrote the file
|
|
180
|
+
- min_scc_version: Minimum SCC version required to parse
|
|
181
|
+
- _extra: Preserves unknown fields for roundtrip
|
|
182
|
+
|
|
183
|
+
When reading files:
|
|
184
|
+
- Local stores with newer schema: warn + ignore (fail-open)
|
|
185
|
+
- Policy exceptions with newer schema: warn + ignore entirely (fail-closed)
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
schema_version: int = 1
|
|
189
|
+
tool_version: str | None = None
|
|
190
|
+
min_scc_version: str | None = None
|
|
191
|
+
exceptions: list[Exception] = field(default_factory=list)
|
|
192
|
+
_extra: dict[str, Any] = field(default_factory=dict)
|
|
193
|
+
|
|
194
|
+
def to_dict(self) -> dict[str, Any]:
|
|
195
|
+
"""Serialize to dictionary with stable exception ordering."""
|
|
196
|
+
# Sort exceptions by created_at, then by id for stable ordering
|
|
197
|
+
sorted_exceptions = sorted(
|
|
198
|
+
self.exceptions,
|
|
199
|
+
key=lambda e: (e.created_at, e.id),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
result: dict[str, Any] = {
|
|
203
|
+
"schema_version": self.schema_version,
|
|
204
|
+
"exceptions": [e.to_dict() for e in sorted_exceptions],
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Add optional metadata if present
|
|
208
|
+
if self.tool_version is not None:
|
|
209
|
+
result["tool_version"] = self.tool_version
|
|
210
|
+
if self.min_scc_version is not None:
|
|
211
|
+
result["min_scc_version"] = self.min_scc_version
|
|
212
|
+
|
|
213
|
+
# Include _extra fields at top level
|
|
214
|
+
result.update(self._extra)
|
|
215
|
+
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
def to_json(self) -> str:
|
|
219
|
+
"""Serialize to JSON with sorted keys and 2-space indentation."""
|
|
220
|
+
return json.dumps(self.to_dict(), sort_keys=True, indent=2)
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def from_dict(cls, d: dict[str, Any]) -> ExceptionFile:
|
|
224
|
+
"""Deserialize from dictionary, preserving unknown fields."""
|
|
225
|
+
known_keys = {
|
|
226
|
+
"schema_version",
|
|
227
|
+
"tool_version",
|
|
228
|
+
"min_scc_version",
|
|
229
|
+
"exceptions",
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# Extract _extra fields
|
|
233
|
+
extra = {k: v for k, v in d.items() if k not in known_keys}
|
|
234
|
+
|
|
235
|
+
# Parse exceptions list
|
|
236
|
+
exceptions = [Exception.from_dict(e) for e in d.get("exceptions", [])]
|
|
237
|
+
|
|
238
|
+
return cls(
|
|
239
|
+
schema_version=d.get("schema_version", 1),
|
|
240
|
+
tool_version=d.get("tool_version"),
|
|
241
|
+
min_scc_version=d.get("min_scc_version"),
|
|
242
|
+
exceptions=exceptions,
|
|
243
|
+
_extra=extra,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def from_json(cls, json_str: str) -> ExceptionFile:
|
|
248
|
+
"""Parse JSON string into ExceptionFile."""
|
|
249
|
+
return cls.from_dict(json.loads(json_str))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def generate_local_id() -> str:
|
|
253
|
+
"""Generate a unique local exception ID.
|
|
254
|
+
|
|
255
|
+
Format: local-YYYYMMDD-XXXX
|
|
256
|
+
Where XXXX is 4 random hex characters.
|
|
257
|
+
|
|
258
|
+
Example: local-20251221-a3f2
|
|
259
|
+
"""
|
|
260
|
+
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
|
261
|
+
random_hex = secrets.token_hex(2) # 2 bytes = 4 hex chars
|
|
262
|
+
return f"local-{today}-{random_hex}"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# Convenience functions for JSON operations
|
|
266
|
+
def exception_file_to_json(ef: ExceptionFile) -> str:
|
|
267
|
+
"""Serialize ExceptionFile to JSON string."""
|
|
268
|
+
return ef.to_json()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def exception_file_from_json(json_str: str) -> ExceptionFile:
|
|
272
|
+
"""Parse JSON string into ExceptionFile."""
|
|
273
|
+
return ExceptionFile.from_json(json_str)
|