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,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)