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.

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