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,696 @@
1
+ """Provide CLI commands for exception management.
2
+
3
+ Manage time-bounded exceptions that allow developers to unblock themselves
4
+ from delegation failures while respecting security boundaries.
5
+
6
+ Commands:
7
+ scc exceptions list: View active/expired exceptions
8
+ scc exceptions create: Create new exceptions
9
+ scc exceptions delete: Remove exceptions by ID
10
+ scc exceptions cleanup: Prune expired exceptions
11
+ scc exceptions reset: Clear exception stores
12
+ scc unblock: Quick command to unblock a denied target
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import secrets
19
+ import shlex
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Annotated
23
+
24
+ import typer
25
+ from rich import box
26
+ from rich.console import Console
27
+ from rich.table import Table
28
+
29
+ from .. import config, profiles
30
+ from ..cli_common import handle_errors
31
+ from ..cli_helpers import create_audit_record, require_reason_for_governance
32
+ from ..evaluation import EvaluationResult, evaluate
33
+ from ..models.exceptions import AllowTargets
34
+ from ..models.exceptions import Exception as SccException
35
+ from ..stores.exception_store import RepoStore, UserStore
36
+ from ..utils.fuzzy import find_similar
37
+ from ..utils.ttl import calculate_expiration, format_expiration, format_relative
38
+
39
+ console = Console()
40
+
41
+
42
+ def _get_repo_root() -> Path:
43
+ """Get current git repo root or current directory."""
44
+ cwd = Path.cwd()
45
+ # Walk up to find .git
46
+ current = cwd
47
+ while current != current.parent:
48
+ if (current / ".git").exists():
49
+ return current
50
+ current = current.parent
51
+ # Not in git repo, use cwd
52
+ return cwd
53
+
54
+
55
+ def _get_user_store() -> UserStore:
56
+ """Get user exception store."""
57
+ return UserStore()
58
+
59
+
60
+ def _get_repo_store() -> RepoStore:
61
+ """Get repo exception store."""
62
+ return RepoStore(_get_repo_root())
63
+
64
+
65
+ def _is_git_ignored(file_path: str) -> bool:
66
+ """Check if a file path is ignored by git.
67
+
68
+ Uses git check-ignore to determine if the file would be ignored.
69
+ Returns False if git is not available or not in a git repo (fail-open).
70
+ """
71
+ from ..services.git import is_file_ignored
72
+
73
+ repo_root = _get_repo_root()
74
+ return is_file_ignored(file_path, repo_root)
75
+
76
+
77
+ # ─────────────────────────────────────────────────────────────────────────────
78
+ # Exceptions sub-app
79
+ # ─────────────────────────────────────────────────────────────────────────────
80
+
81
+ exceptions_app = typer.Typer(
82
+ name="exceptions",
83
+ help="Manage time-bounded exceptions for blocked or denied items.",
84
+ no_args_is_help=True,
85
+ context_settings={"help_option_names": ["-h", "--help"]},
86
+ )
87
+
88
+
89
+ def _generate_local_id() -> str:
90
+ """Generate a unique local exception ID."""
91
+ date_part = datetime.now(timezone.utc).strftime("%Y%m%d")
92
+ random_part = secrets.token_hex(2)
93
+ return f"local-{date_part}-{random_part}"
94
+
95
+
96
+ def _is_expired(exc: SccException) -> bool:
97
+ """Check if an exception has expired."""
98
+ try:
99
+ expires = datetime.fromisoformat(exc.expires_at.replace("Z", "+00:00"))
100
+ return expires <= datetime.now(timezone.utc)
101
+ except (ValueError, AttributeError):
102
+ return True
103
+
104
+
105
+ def _format_targets(exc: SccException) -> str:
106
+ """Format exception targets for display."""
107
+ parts = []
108
+ if exc.allow.plugins:
109
+ parts.append(f"plugins: {', '.join(exc.allow.plugins)}")
110
+ if exc.allow.mcp_servers:
111
+ parts.append(f"mcp: {', '.join(exc.allow.mcp_servers)}")
112
+ if exc.allow.base_images:
113
+ parts.append(f"images: {', '.join(exc.allow.base_images)}")
114
+ return "; ".join(parts) if parts else "(none)"
115
+
116
+
117
+ def _format_expires_in(exc: SccException) -> str:
118
+ """Format relative expiration time."""
119
+ try:
120
+ expires = datetime.fromisoformat(exc.expires_at.replace("Z", "+00:00"))
121
+ return format_relative(expires)
122
+ except (ValueError, AttributeError):
123
+ return "unknown"
124
+
125
+
126
+ # ─────────────────────────────────────────────────────────────────────────────
127
+ # exceptions list command
128
+ # ─────────────────────────────────────────────────────────────────────────────
129
+
130
+
131
+ @exceptions_app.command("list")
132
+ @handle_errors
133
+ def exceptions_list(
134
+ active: Annotated[
135
+ bool,
136
+ typer.Option("--active", help="Show only active (non-expired) exceptions."),
137
+ ] = False,
138
+ expired: Annotated[
139
+ bool,
140
+ typer.Option("--expired", help="Show only expired exceptions."),
141
+ ] = False,
142
+ all_exceptions: Annotated[
143
+ bool,
144
+ typer.Option("--all", help="Show all exceptions (active and expired)."),
145
+ ] = False,
146
+ as_json: Annotated[
147
+ bool,
148
+ typer.Option("--json", help="Output as JSON."),
149
+ ] = False,
150
+ ) -> None:
151
+ """List exceptions from local stores."""
152
+ user_store = _get_user_store()
153
+ repo_store = _get_repo_store()
154
+
155
+ user_exceptions = user_store.read().exceptions
156
+ repo_exceptions = repo_store.read().exceptions
157
+
158
+ all_exc = user_exceptions + repo_exceptions
159
+
160
+ # Filter based on flags
161
+ if expired:
162
+ filtered = [e for e in all_exc if _is_expired(e)]
163
+ elif active or (not all_exceptions and not expired):
164
+ # Default to active
165
+ filtered = [e for e in all_exc if not _is_expired(e)]
166
+ else:
167
+ filtered = all_exc
168
+
169
+ if as_json:
170
+ output = [
171
+ {
172
+ "id": e.id,
173
+ "scope": e.scope,
174
+ "reason": e.reason,
175
+ "expires_at": e.expires_at,
176
+ "expired": _is_expired(e),
177
+ "targets": {
178
+ "plugins": e.allow.plugins or [],
179
+ "mcp_servers": e.allow.mcp_servers or [],
180
+ "base_images": e.allow.base_images or [],
181
+ },
182
+ }
183
+ for e in filtered
184
+ ]
185
+ console.print(json.dumps(output, indent=2))
186
+ return
187
+
188
+ if not filtered:
189
+ console.print("[dim]No exceptions found.[/dim]")
190
+ return
191
+
192
+ table = Table(
193
+ title="[bold cyan]Exceptions[/bold cyan]",
194
+ box=box.ROUNDED,
195
+ header_style="bold cyan",
196
+ )
197
+ table.add_column("ID", style="cyan")
198
+ table.add_column("Scope", style="dim")
199
+ table.add_column("Targets", style="green")
200
+ table.add_column("Expires In", style="yellow")
201
+ table.add_column("Reason", style="dim")
202
+
203
+ for exc in filtered:
204
+ expires_in = _format_expires_in(exc)
205
+ if _is_expired(exc):
206
+ expires_in = "[red]expired[/red]"
207
+ table.add_row(
208
+ exc.id,
209
+ exc.scope,
210
+ _format_targets(exc),
211
+ expires_in,
212
+ exc.reason[:30] + "..." if len(exc.reason) > 30 else exc.reason,
213
+ )
214
+
215
+ console.print()
216
+ console.print(table)
217
+ console.print()
218
+
219
+ # Show note about expired if viewing active
220
+ if not expired and not all_exceptions:
221
+ expired_count = sum(1 for e in all_exc if _is_expired(e))
222
+ if expired_count > 0:
223
+ console.print(
224
+ f"[dim]Note: {expired_count} expired (run `scc exceptions cleanup`)[/dim]"
225
+ )
226
+
227
+
228
+ # ─────────────────────────────────────────────────────────────────────────────
229
+ # exceptions create command
230
+ # ─────────────────────────────────────────────────────────────────────────────
231
+
232
+
233
+ @exceptions_app.command("create")
234
+ @handle_errors
235
+ def exceptions_create(
236
+ policy: Annotated[
237
+ bool,
238
+ typer.Option("--policy", help="Generate YAML snippet for policy PR."),
239
+ ] = False,
240
+ exception_id: Annotated[
241
+ str | None,
242
+ typer.Option("--id", help="Exception ID (required for --policy)."),
243
+ ] = None,
244
+ ttl: Annotated[
245
+ str | None,
246
+ typer.Option("--ttl", help="Time-to-live (e.g., 8h, 30m, 1d)."),
247
+ ] = None,
248
+ expires_at: Annotated[
249
+ str | None,
250
+ typer.Option("--expires-at", help="Expiration time (RFC3339 format)."),
251
+ ] = None,
252
+ until: Annotated[
253
+ str | None,
254
+ typer.Option("--until", help="Expire at time of day (HH:MM format)."),
255
+ ] = None,
256
+ reason: Annotated[
257
+ str | None,
258
+ typer.Option("--reason", help="Reason for exception (required)."),
259
+ ] = None,
260
+ allow_mcp: Annotated[
261
+ list[str] | None,
262
+ typer.Option("--allow-mcp", help="Allow MCP server (repeatable)."),
263
+ ] = None,
264
+ allow_plugin: Annotated[
265
+ list[str] | None,
266
+ typer.Option("--allow-plugin", help="Allow plugin (repeatable)."),
267
+ ] = None,
268
+ allow_image: Annotated[
269
+ list[str] | None,
270
+ typer.Option("--allow-image", help="Allow base image (repeatable)."),
271
+ ] = None,
272
+ shared: Annotated[
273
+ bool,
274
+ typer.Option("--shared", help="Save to repo store instead of user store."),
275
+ ] = False,
276
+ ) -> None:
277
+ """Create a new exception."""
278
+ # Validate required fields
279
+ if not reason:
280
+ console.print("[red]Error: --reason is required.[/red]")
281
+ raise typer.Exit(1)
282
+
283
+ if not any([allow_mcp, allow_plugin, allow_image]):
284
+ console.print(
285
+ "[red]Error: At least one target required "
286
+ "(--allow-mcp, --allow-plugin, or --allow-image).[/red]"
287
+ )
288
+ raise typer.Exit(1)
289
+
290
+ if policy and not exception_id:
291
+ console.print("[red]Error: --id is required when using --policy.[/red]")
292
+ raise typer.Exit(1)
293
+
294
+ # Calculate expiration
295
+ try:
296
+ expiration = calculate_expiration(ttl=ttl, expires_at=expires_at, until=until)
297
+ except ValueError as e:
298
+ console.print(f"[red]Error: {e}[/red]")
299
+ raise typer.Exit(1)
300
+
301
+ # Create exception
302
+ now = datetime.now(timezone.utc)
303
+ # Type assertion: exception_id is validated above when policy=True
304
+ exc_id = exception_id if policy and exception_id else _generate_local_id()
305
+
306
+ exception = SccException(
307
+ id=exc_id,
308
+ created_at=format_expiration(now),
309
+ expires_at=format_expiration(expiration),
310
+ reason=reason,
311
+ scope="policy" if policy else "local",
312
+ allow=AllowTargets(
313
+ plugins=allow_plugin or [],
314
+ mcp_servers=allow_mcp or [],
315
+ base_images=allow_image or [],
316
+ ),
317
+ )
318
+
319
+ # For policy exceptions, generate YAML snippet instead of saving
320
+ if policy:
321
+ console.print("\n[bold cyan]Add this to your org config exceptions:[/bold cyan]\n")
322
+ console.print(f" - id: {exception.id}")
323
+ console.print(f' reason: "{exception.reason}"')
324
+ console.print(f' expires_at: "{exception.expires_at}"')
325
+ console.print(" allow:")
326
+ if exception.allow.plugins:
327
+ console.print(f" plugins: {exception.allow.plugins}")
328
+ if exception.allow.mcp_servers:
329
+ console.print(f" mcp_servers: {exception.allow.mcp_servers}")
330
+ if exception.allow.base_images:
331
+ console.print(f" base_images: {exception.allow.base_images}")
332
+ console.print()
333
+ return
334
+
335
+ # Save to appropriate store
336
+ store: UserStore | RepoStore
337
+ if shared:
338
+ store = _get_repo_store()
339
+ store_path = ".scc/exceptions.json"
340
+ else:
341
+ store = _get_user_store()
342
+ store_path = "~/.config/scc/exceptions.json"
343
+
344
+ exc_file = store.read()
345
+ exc_file.exceptions.append(exception)
346
+
347
+ # Prune expired during write (hybrid cleanup)
348
+ pruned = store.prune_expired()
349
+ store.write(exc_file)
350
+
351
+ targets = _format_targets(exception)
352
+ expires_in = format_relative(expiration)
353
+
354
+ console.print(f"\n[green]✓[/green] Created local override for {targets}")
355
+ console.print(f" Expires: {exception.expires_at} (in {expires_in})")
356
+ console.print(f" Saved to {store_path}")
357
+ if pruned > 0:
358
+ console.print(f" [dim]Note: Pruned {pruned} expired entries.[/dim]")
359
+ if shared and _is_git_ignored(store_path):
360
+ console.print("\n[yellow]⚠️ Warning:[/yellow] .scc/exceptions.json is ignored by git.")
361
+ console.print(" Your team won't see this shared exception.")
362
+ console.print()
363
+
364
+
365
+ # ─────────────────────────────────────────────────────────────────────────────
366
+ # exceptions delete command
367
+ # ─────────────────────────────────────────────────────────────────────────────
368
+
369
+
370
+ @exceptions_app.command("delete")
371
+ @handle_errors
372
+ def exceptions_delete(
373
+ exception_id: Annotated[
374
+ str,
375
+ typer.Argument(help="Exception ID or unambiguous prefix."),
376
+ ],
377
+ yes: Annotated[
378
+ bool,
379
+ typer.Option("--yes", "-y", help="Skip confirmation."),
380
+ ] = False,
381
+ ) -> None:
382
+ """Delete an exception by ID."""
383
+ user_store = _get_user_store()
384
+ repo_store = _get_repo_store()
385
+
386
+ # Search in both stores
387
+ user_file = user_store.read()
388
+ repo_file = repo_store.read()
389
+
390
+ # Find matching exceptions
391
+ user_matches = [e for e in user_file.exceptions if e.id.startswith(exception_id)]
392
+ repo_matches = [e for e in repo_file.exceptions if e.id.startswith(exception_id)]
393
+
394
+ all_matches = user_matches + repo_matches
395
+
396
+ if not all_matches:
397
+ console.print(f"[red]Error: No exception found matching '{exception_id}'.[/red]")
398
+ raise typer.Exit(1)
399
+
400
+ if len(all_matches) > 1:
401
+ console.print(f"[red]Error: Ambiguous prefix '{exception_id}'. Matches:[/red]")
402
+ for m in all_matches:
403
+ console.print(f" - {m.id}")
404
+ raise typer.Exit(1)
405
+
406
+ match = all_matches[0]
407
+
408
+ # Determine which store contains the match
409
+ store: UserStore | RepoStore
410
+ if match in user_matches:
411
+ store = user_store
412
+ exc_file = user_file
413
+ store_name = "user"
414
+ else:
415
+ store = repo_store
416
+ exc_file = repo_file
417
+ store_name = "repo"
418
+
419
+ # Remove and save
420
+ exc_file.exceptions = [e for e in exc_file.exceptions if e.id != match.id]
421
+ store.write(exc_file)
422
+
423
+ console.print(f"[green]✓[/green] Deleted exception '{match.id}' from {store_name} store.")
424
+
425
+
426
+ # ─────────────────────────────────────────────────────────────────────────────
427
+ # exceptions cleanup command
428
+ # ─────────────────────────────────────────────────────────────────────────────
429
+
430
+
431
+ @exceptions_app.command("cleanup")
432
+ @handle_errors
433
+ def exceptions_cleanup() -> None:
434
+ """Remove expired exceptions from local stores."""
435
+ user_store = _get_user_store()
436
+ repo_store = _get_repo_store()
437
+
438
+ user_pruned = user_store.prune_expired()
439
+ repo_pruned = repo_store.prune_expired()
440
+
441
+ total = user_pruned + repo_pruned
442
+
443
+ if total == 0:
444
+ console.print("[dim]No expired exceptions to clean up.[/dim]")
445
+ else:
446
+ console.print(f"[green]✓[/green] Removed {total} expired exceptions.")
447
+ if user_pruned > 0:
448
+ console.print(f" - {user_pruned} from user store")
449
+ if repo_pruned > 0:
450
+ console.print(f" - {repo_pruned} from repo store")
451
+
452
+
453
+ # ─────────────────────────────────────────────────────────────────────────────
454
+ # exceptions reset command
455
+ # ─────────────────────────────────────────────────────────────────────────────
456
+
457
+
458
+ @exceptions_app.command("reset")
459
+ @handle_errors
460
+ def exceptions_reset(
461
+ user: Annotated[
462
+ bool,
463
+ typer.Option("--user", help="Reset user store (~/.config/scc/exceptions.json)."),
464
+ ] = False,
465
+ repo: Annotated[
466
+ bool,
467
+ typer.Option("--repo", help="Reset repo store (.scc/exceptions.json)."),
468
+ ] = False,
469
+ yes: Annotated[
470
+ bool,
471
+ typer.Option("--yes", "-y", help="Skip confirmation (required)."),
472
+ ] = False,
473
+ ) -> None:
474
+ """Reset (clear) exception stores. Destructive operation."""
475
+ if not yes:
476
+ console.print("[red]Error: --yes is required for destructive reset operation.[/red]")
477
+ raise typer.Exit(1)
478
+
479
+ if not user and not repo:
480
+ console.print("[red]Error: Specify --user or --repo (or both).[/red]")
481
+ raise typer.Exit(1)
482
+
483
+ if user:
484
+ user_store = _get_user_store()
485
+ user_store.reset()
486
+ console.print("[green]✓[/green] Reset user exception store.")
487
+
488
+ if repo:
489
+ repo_store = _get_repo_store()
490
+ repo_store.reset()
491
+ console.print("[green]✓[/green] Reset repo exception store.")
492
+
493
+
494
+ # ─────────────────────────────────────────────────────────────────────────────
495
+ # unblock command (top-level, not under exceptions)
496
+ # ─────────────────────────────────────────────────────────────────────────────
497
+
498
+
499
+ def get_current_denials() -> EvaluationResult:
500
+ """Get current evaluation result with denied items.
501
+
502
+ Connects to the config evaluation pipeline to get currently
503
+ blocked/denied items based on the user's team profile and workspace.
504
+
505
+ Returns:
506
+ EvaluationResult with blocked_items and denied_additions populated
507
+ from the effective config evaluation. Returns empty result if
508
+ in standalone mode or no team is selected.
509
+ """
510
+ org_config = config.load_cached_org_config()
511
+ if not org_config:
512
+ # Standalone mode - nothing is denied
513
+ return EvaluationResult()
514
+
515
+ team = config.get_selected_profile()
516
+ if not team:
517
+ # No team selected - nothing is denied
518
+ return EvaluationResult()
519
+
520
+ # Compute effective config for current workspace
521
+ effective = profiles.compute_effective_config(
522
+ org_config=org_config,
523
+ team_name=team,
524
+ workspace_path=Path.cwd(),
525
+ )
526
+
527
+ # Convert to evaluation result with proper types
528
+ return evaluate(effective)
529
+
530
+
531
+ @handle_errors
532
+ def unblock_cmd(
533
+ target: Annotated[
534
+ str,
535
+ typer.Argument(help="Target to unblock (MCP server, plugin, or image name)."),
536
+ ],
537
+ ttl: Annotated[
538
+ str | None,
539
+ typer.Option("--ttl", help="Time-to-live (e.g., 8h, 30m, 1d)."),
540
+ ] = None,
541
+ expires_at: Annotated[
542
+ str | None,
543
+ typer.Option("--expires-at", help="Expiration time (RFC3339 format)."),
544
+ ] = None,
545
+ until: Annotated[
546
+ str | None,
547
+ typer.Option("--until", help="Expire at time of day (HH:MM format)."),
548
+ ] = None,
549
+ reason: Annotated[
550
+ str | None,
551
+ typer.Option("--reason", help="Reason for unblocking (required with --yes)."),
552
+ ] = None,
553
+ ticket: Annotated[
554
+ str | None,
555
+ typer.Option("--ticket", help="Related ticket ID (e.g., JIRA-123) for audit trail."),
556
+ ] = None,
557
+ yes: Annotated[
558
+ bool,
559
+ typer.Option("--yes", "-y", help="Skip confirmation prompt (requires --reason)."),
560
+ ] = False,
561
+ shared: Annotated[
562
+ bool,
563
+ typer.Option("--shared", help="Save to repo store instead of user store."),
564
+ ] = False,
565
+ ) -> None:
566
+ """Unblock a currently denied target.
567
+
568
+ Creates a local override to allow a target that is currently denied by
569
+ delegation policy. This command only works for delegation denials, not
570
+ security blocks.
571
+
572
+ Governance audit: All unblock operations are logged with actor, reason,
573
+ and timestamp for compliance tracking.
574
+
575
+ Example:
576
+ scc unblock jira-api --ttl 8h --reason "Need for sprint planning"
577
+ scc unblock my-plugin --yes --reason "Emergency fix" --ticket INC-123
578
+ """
579
+ # Governance commands require --reason when using --yes (or prompt interactively)
580
+ validated_reason = require_reason_for_governance(yes=yes, reason=reason, command_name="unblock")
581
+
582
+ # Get current evaluation state
583
+ eval_result = get_current_denials()
584
+
585
+ # Check if target is security-blocked (cannot unblock locally)
586
+ for blocked in eval_result.blocked_items:
587
+ if blocked.target == target:
588
+ console.print(
589
+ f"\n[red]✗[/red] Cannot unblock '{target}': blocked by security policy.\n"
590
+ )
591
+ console.print(" To request policy exception (requires PR approval):")
592
+ quoted_target = shlex.quote(target)
593
+ console.print(
594
+ " scc exceptions create --policy --id INC-... --allow-mcp "
595
+ f'{quoted_target} --ttl 8h --reason "..."'
596
+ )
597
+ console.print()
598
+ raise typer.Exit(1)
599
+
600
+ # Check if target is actually denied
601
+ denied_match = None
602
+ for denied in eval_result.denied_additions:
603
+ if denied.target == target:
604
+ denied_match = denied
605
+ break
606
+
607
+ if not denied_match:
608
+ # Try fuzzy matching to suggest similar targets
609
+ denied_names = [d.target for d in eval_result.denied_additions]
610
+ suggestions = find_similar(target, denied_names)
611
+
612
+ console.print(f"\n[red]✗[/red] Nothing to unblock: '{target}' is not currently denied.\n")
613
+
614
+ if suggestions:
615
+ console.print("[yellow]Did you mean one of these?[/yellow]")
616
+ for suggestion in suggestions:
617
+ console.print(f" - {suggestion}")
618
+ console.print("\n[dim]Re-run with the exact name.[/dim]")
619
+ else:
620
+ console.print(" To create a preemptive exception, use:")
621
+ quoted_target = shlex.quote(target)
622
+ console.print(
623
+ f' scc exceptions create --allow-mcp {quoted_target} --ttl 8h --reason "..."'
624
+ )
625
+ console.print()
626
+ raise typer.Exit(1)
627
+
628
+ # Calculate expiration
629
+ try:
630
+ expiration = calculate_expiration(ttl=ttl, expires_at=expires_at, until=until)
631
+ except ValueError as e:
632
+ console.print(f"[red]Error: {e}[/red]")
633
+ raise typer.Exit(1)
634
+
635
+ # Create exception
636
+ now = datetime.now(timezone.utc)
637
+ exc_id = _generate_local_id()
638
+
639
+ # Determine target type and create appropriate allow targets
640
+ target_type = denied_match.target_type
641
+ allow = AllowTargets(
642
+ plugins=[target] if target_type == "plugin" else [],
643
+ mcp_servers=[target] if target_type == "mcp_server" else [],
644
+ base_images=[target] if target_type == "base_image" else [],
645
+ )
646
+
647
+ exception = SccException(
648
+ id=exc_id,
649
+ created_at=format_expiration(now),
650
+ expires_at=format_expiration(expiration),
651
+ reason=validated_reason,
652
+ scope="local",
653
+ allow=allow,
654
+ )
655
+
656
+ # Save to appropriate store
657
+ store: UserStore | RepoStore
658
+ if shared:
659
+ store = _get_repo_store()
660
+ store_path = ".scc/exceptions.json"
661
+ store_type = "shared"
662
+ else:
663
+ store = _get_user_store()
664
+ store_path = "~/.config/scc/exceptions.json"
665
+ store_type = "local"
666
+
667
+ exc_file = store.read()
668
+ exc_file.exceptions.append(exception)
669
+
670
+ # Prune expired during write
671
+ pruned = store.prune_expired()
672
+ store.write(exc_file)
673
+
674
+ expires_in = format_relative(expiration)
675
+
676
+ # Create audit record for governance tracking
677
+ _audit = create_audit_record(
678
+ command="unblock",
679
+ target=target,
680
+ reason=validated_reason,
681
+ ticket=ticket,
682
+ expires_in=expires_in,
683
+ )
684
+ # Note: audit record is created for tracking; actual logging depends on audit sink configuration
685
+
686
+ console.print(
687
+ f"\n[green]✓[/green] Created {store_type} override for "
688
+ f'{target_type} "{target}" (expires in {expires_in})'
689
+ )
690
+ console.print(f" Saved to {store_path}")
691
+ if pruned > 0:
692
+ console.print(f" [dim]Note: Pruned {pruned} expired entries.[/dim]")
693
+ if shared and _is_git_ignored(store_path):
694
+ console.print("\n[yellow]⚠️ Warning:[/yellow] .scc/exceptions.json is ignored by git.")
695
+ console.print(" Your team won't see this shared exception.")
696
+ console.print()