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