skill-sentry-cli 2.0.0__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.
- skill_sentry_cli/__init__.py +5 -0
- skill_sentry_cli/__main__.py +4 -0
- skill_sentry_cli/cli.py +918 -0
- skill_sentry_cli/core/__init__.py +1 -0
- skill_sentry_cli/core/anchors/__init__.py +5 -0
- skill_sentry_cli/core/anchors/credential_flow.py +440 -0
- skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
- skill_sentry_cli/core/cache/__init__.py +35 -0
- skill_sentry_cli/core/cache/hash_utils.py +206 -0
- skill_sentry_cli/core/cache/sqlite_store.py +925 -0
- skill_sentry_cli/core/config.py +299 -0
- skill_sentry_cli/core/contracts.py +551 -0
- skill_sentry_cli/core/decisions/__init__.py +44 -0
- skill_sentry_cli/core/decisions/base.py +174 -0
- skill_sentry_cli/core/decisions/calibration.py +325 -0
- skill_sentry_cli/core/decisions/laya.py +439 -0
- skill_sentry_cli/core/decisions/mapping.py +218 -0
- skill_sentry_cli/core/decisions/mock.py +118 -0
- skill_sentry_cli/core/decisions/questions.py +118 -0
- skill_sentry_cli/core/decisions/report.py +65 -0
- skill_sentry_cli/core/decisions/selector.py +151 -0
- skill_sentry_cli/core/engine.py +1412 -0
- skill_sentry_cli/core/evasion/__init__.py +27 -0
- skill_sentry_cli/core/evasion/detector.py +210 -0
- skill_sentry_cli/core/exit_codes.py +13 -0
- skill_sentry_cli/core/models/__init__.py +20 -0
- skill_sentry_cli/core/models/_json.py +147 -0
- skill_sentry_cli/core/models/base.py +50 -0
- skill_sentry_cli/core/models/gemma_cloud.py +240 -0
- skill_sentry_cli/core/models/gemma_local.py +273 -0
- skill_sentry_cli/core/models/image_utils.py +112 -0
- skill_sentry_cli/core/models/mock.py +152 -0
- skill_sentry_cli/core/models/selector.py +123 -0
- skill_sentry_cli/core/parser/__init__.py +29 -0
- skill_sentry_cli/core/parser/skill_parser.py +520 -0
- skill_sentry_cli/core/parser/typosquat.py +167 -0
- skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
- skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
- skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
- skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
- skill_sentry_cli/core/reports.py +114 -0
- skill_sentry_cli/core/sandbox/__init__.py +74 -0
- skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
- skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
- skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
- skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
- skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
- skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
- skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
- skill_sentry_cli/core/sandbox/fake_http.py +194 -0
- skill_sentry_cli/core/sandbox/instructions.py +170 -0
- skill_sentry_cli/core/sandbox/masquerade.py +265 -0
- skill_sentry_cli/core/sandbox/monitor.py +278 -0
- skill_sentry_cli/core/sandbox/runner.py +776 -0
- skill_sentry_cli/core/sandbox/sidecar.py +327 -0
- skill_sentry_cli/core/semantic/__init__.py +9 -0
- skill_sentry_cli/core/semantic/analyzer.py +443 -0
- skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
- skill_sentry_cli/core/semantic/ast_python.py +156 -0
- skill_sentry_cli/core/semantic/candidates.py +78 -0
- skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
- skill_sentry_cli/core/semantic/prompts.py +61 -0
- skill_sentry_cli/core/verdict/__init__.py +24 -0
- skill_sentry_cli/core/verdict/fusion.py +392 -0
- skill_sentry_cli/core/verdict/markdown_report.py +211 -0
- skill_sentry_cli/core/verdict/sarif_report.py +139 -0
- skill_sentry_cli/core/verdict/status.py +35 -0
- skill_sentry_cli/core/verdict/stix_report.py +132 -0
- skill_sentry_cli/core/version.py +38 -0
- skill_sentry_cli/core/vision/__init__.py +8 -0
- skill_sentry_cli/core/vision/image_extractor.py +91 -0
- skill_sentry_cli/core/vision/vision_scanner.py +274 -0
- skill_sentry_cli/data/__init__.py +1 -0
- skill_sentry_cli/data/known_skills_v1.json +276 -0
- skill_sentry_cli/data/sandbox_image.json +4 -0
- skill_sentry_cli/rules/credential_rules.toml +94 -0
- skill_sentry_cli/server/__init__.py +5 -0
- skill_sentry_cli/server/app.py +129 -0
- skill_sentry_cli/server/backend.py +63 -0
- skill_sentry_cli/server/demo_assets.py +62 -0
- skill_sentry_cli/server/demo_backend.py +475 -0
- skill_sentry_cli/server/demo_data.py +814 -0
- skill_sentry_cli/server/demo_mode.py +40 -0
- skill_sentry_cli/server/errors.py +48 -0
- skill_sentry_cli/server/live_backend.py +576 -0
- skill_sentry_cli/server/routes.py +191 -0
- skill_sentry_cli/skill/SKILL.md +67 -0
- skill_sentry_cli/skill/scripts/audit.sh +48 -0
- skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
- skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
- skill_sentry_cli/ui/dist/index.html +14 -0
- skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
- skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
- skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
- skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
- skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
skill_sentry_cli/cli.py
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
"""``skill-sentry-cli`` command-line interface.
|
|
2
|
+
|
|
3
|
+
``serve`` (demo + live), ``init``, live ``scan``, and ``self-audit``. Demo
|
|
4
|
+
mode remains a separate, explicitly labelled path; live scans always go through
|
|
5
|
+
the real :class:`ScanEngine`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from uuid import UUID
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
|
|
22
|
+
from skill_sentry_cli.core.cache import SQLiteStore
|
|
23
|
+
from skill_sentry_cli.core.config import (
|
|
24
|
+
ConfigError,
|
|
25
|
+
SkillsEntryConfig,
|
|
26
|
+
load_config,
|
|
27
|
+
write_default_config,
|
|
28
|
+
)
|
|
29
|
+
from skill_sentry_cli.core.contracts import ExportFormat, ScanOptions, Verdict, VerdictTier
|
|
30
|
+
from skill_sentry_cli.core.engine import ScanEngine, ScanError, ScanOutcome
|
|
31
|
+
from skill_sentry_cli.core.exit_codes import ExitCode
|
|
32
|
+
from skill_sentry_cli.core.models.gemma_local import ProbeResult
|
|
33
|
+
from skill_sentry_cli.core.models.mock import MockGemma
|
|
34
|
+
from skill_sentry_cli.core.reports import render_report
|
|
35
|
+
from skill_sentry_cli.core.verdict.status import degraded_reason, is_unscanned, shown_status
|
|
36
|
+
from skill_sentry_cli.core.version import GEMMA_BADGE, __version__
|
|
37
|
+
from skill_sentry_cli.server.demo_data import build_demo_store
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _configure_stdio() -> None:
|
|
41
|
+
"""Make CLI banners safe on legacy Windows consoles and redirected output."""
|
|
42
|
+
|
|
43
|
+
for stream in (sys.stdout, sys.stderr):
|
|
44
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
45
|
+
if reconfigure is None:
|
|
46
|
+
continue
|
|
47
|
+
with contextlib.suppress(OSError, ValueError):
|
|
48
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_configure_stdio()
|
|
52
|
+
|
|
53
|
+
app = typer.Typer(
|
|
54
|
+
name="skill-sentry-cli",
|
|
55
|
+
help="Audit AI Agent Skills before installation - fully local.",
|
|
56
|
+
no_args_is_help=True,
|
|
57
|
+
add_completion=False,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_FAIL_ON = {"safe", "suspicious", "malicious"}
|
|
61
|
+
_RANK = {VerdictTier.SAFE: 0, VerdictTier.SUSPICIOUS: 1, VerdictTier.MALICIOUS: 2}
|
|
62
|
+
_FORMATS = {"text", "json", "sarif", "markdown"}
|
|
63
|
+
_BACKENDS = {"auto", "local", "cloud", "demo"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _version_callback(value: bool) -> None:
|
|
67
|
+
if value:
|
|
68
|
+
typer.echo(f"SkillSentry {__version__} - {GEMMA_BADGE}")
|
|
69
|
+
raise typer.Exit(code=int(ExitCode.OK))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.callback()
|
|
73
|
+
def main(
|
|
74
|
+
version: bool = typer.Option(
|
|
75
|
+
False,
|
|
76
|
+
"--version",
|
|
77
|
+
callback=_version_callback,
|
|
78
|
+
is_eager=True,
|
|
79
|
+
help="Show the version and exit.",
|
|
80
|
+
),
|
|
81
|
+
) -> None:
|
|
82
|
+
"""SkillSentry - a context-aware firewall for the Agent Skill supply chain."""
|
|
83
|
+
del version
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def serve(
|
|
88
|
+
host: str = typer.Option("127.0.0.1", help="Bind address (loopback only by default)."),
|
|
89
|
+
port: int = typer.Option(8000, min=1, max=65535),
|
|
90
|
+
demo_mode: bool = typer.Option(False, "--demo-mode", help="Serve the canned 7-skill store."),
|
|
91
|
+
speed: float = typer.Option(1.0, min=0.0, help="Demo WS replay speed (0 = instant)."),
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Run the FastAPI server and dashboard."""
|
|
94
|
+
import uvicorn
|
|
95
|
+
|
|
96
|
+
if demo_mode:
|
|
97
|
+
from skill_sentry_cli.server.demo_mode import create_demo_app
|
|
98
|
+
|
|
99
|
+
typer.echo(f"SkillSentry {__version__} - demo mode - http://{host}:{port}")
|
|
100
|
+
uvicorn.run(create_demo_app(speed=speed), host=host, port=port)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
from skill_sentry_cli.server.app import create_app
|
|
104
|
+
from skill_sentry_cli.server.live_backend import LiveBackend
|
|
105
|
+
|
|
106
|
+
typer.echo(f"SkillSentry {__version__} - live mode - http://{host}:{port}")
|
|
107
|
+
uvicorn.run(create_app(LiveBackend()), host=host, port=port)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def init(
|
|
112
|
+
path: Path = typer.Option(
|
|
113
|
+
Path("skillsentry.toml"), "--path", help="Where to write the config."
|
|
114
|
+
),
|
|
115
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing file."),
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Write a documented ``skillsentry.toml``."""
|
|
118
|
+
try:
|
|
119
|
+
written = write_default_config(path, force=force)
|
|
120
|
+
except ConfigError as exc:
|
|
121
|
+
typer.echo(f"error: {exc}", err=True)
|
|
122
|
+
raise typer.Exit(code=int(ExitCode.USAGE)) from exc
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
typer.echo(f"error: could not write {path}: {exc.strerror or exc}", err=True)
|
|
125
|
+
raise typer.Exit(code=int(ExitCode.OPERATIONAL)) from exc
|
|
126
|
+
typer.echo(f"wrote {written}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command()
|
|
130
|
+
def scan(
|
|
131
|
+
path: Path = typer.Argument(..., help="Skill directory, or a folder containing skills."),
|
|
132
|
+
demo_mode: bool = typer.Option(False, "--demo-mode", help="Use the canned demo store."),
|
|
133
|
+
output_format: str = typer.Option("text", "--format", help="text | json | sarif | markdown"),
|
|
134
|
+
fail_on: str = typer.Option("suspicious", "--fail-on", help="safe | suspicious | malicious"),
|
|
135
|
+
quiet: bool = typer.Option(False, "-q", "--quiet", help="Verdict line only."),
|
|
136
|
+
no_cache: bool = typer.Option(False, "--no-cache", help="Bypass the verdict cache."),
|
|
137
|
+
sandbox: bool = typer.Option(
|
|
138
|
+
True, "--sandbox/--no-sandbox", help="Execute the skill in Docker when available."
|
|
139
|
+
),
|
|
140
|
+
backend: str = typer.Option(
|
|
141
|
+
"auto", "--backend", help="Model backend: auto | local | cloud | demo"
|
|
142
|
+
),
|
|
143
|
+
timeout_s: int = typer.Option(
|
|
144
|
+
60, "--timeout", min=5, max=600, help="Sandbox timeout (seconds)."
|
|
145
|
+
),
|
|
146
|
+
output: Path | None = typer.Option(
|
|
147
|
+
None, "--output", help="Write the rendered report to a file."
|
|
148
|
+
),
|
|
149
|
+
sarif_out: Path | None = typer.Option(
|
|
150
|
+
None, "--sarif-out", help="Write a SARIF 2.1.0 report to a file."
|
|
151
|
+
),
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Audit one skill directory or a folder of skills."""
|
|
154
|
+
|
|
155
|
+
if output_format not in _FORMATS:
|
|
156
|
+
typer.echo(f"error: unsupported --format {output_format!r}", err=True)
|
|
157
|
+
raise typer.Exit(code=int(ExitCode.USAGE))
|
|
158
|
+
if fail_on not in _FAIL_ON:
|
|
159
|
+
typer.echo(f"error: unsupported --fail-on {fail_on!r}", err=True)
|
|
160
|
+
raise typer.Exit(code=int(ExitCode.USAGE))
|
|
161
|
+
if backend not in _BACKENDS:
|
|
162
|
+
typer.echo(f"error: unsupported --backend {backend!r}", err=True)
|
|
163
|
+
raise typer.Exit(code=int(ExitCode.USAGE))
|
|
164
|
+
_refuse_mock_backend(backend)
|
|
165
|
+
|
|
166
|
+
if demo_mode:
|
|
167
|
+
verdict = _demo_verdict_for(path)
|
|
168
|
+
if sarif_out is not None:
|
|
169
|
+
_write_file(
|
|
170
|
+
sarif_out, render_report(verdict, ExportFormat.SARIF).content.decode("utf-8")
|
|
171
|
+
)
|
|
172
|
+
if not quiet and output_format == "text":
|
|
173
|
+
typer.echo(f"wrote {sarif_out}")
|
|
174
|
+
rendered = _render_verdict(
|
|
175
|
+
verdict, output_format=output_format, quiet=quiet, backend="demo"
|
|
176
|
+
)
|
|
177
|
+
if output is not None:
|
|
178
|
+
_write_file(output, rendered)
|
|
179
|
+
if output_format == "text":
|
|
180
|
+
typer.echo(rendered.rstrip())
|
|
181
|
+
else:
|
|
182
|
+
typer.echo(rendered.rstrip())
|
|
183
|
+
if _should_fail(verdict, fail_on):
|
|
184
|
+
raise typer.Exit(code=int(ExitCode.VERDICT))
|
|
185
|
+
raise typer.Exit(code=int(ExitCode.OK))
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
config = _config_for_backend(backend)
|
|
189
|
+
except ConfigError as exc:
|
|
190
|
+
typer.echo(f"error: {exc}", err=True)
|
|
191
|
+
raise typer.Exit(code=int(ExitCode.USAGE)) from exc
|
|
192
|
+
|
|
193
|
+
engine = _make_engine(config, backend)
|
|
194
|
+
try:
|
|
195
|
+
roots = engine.discover(path)
|
|
196
|
+
except ScanError as exc:
|
|
197
|
+
typer.echo(f"error: {exc}", err=True)
|
|
198
|
+
raise typer.Exit(code=int(ExitCode.OPERATIONAL)) from exc
|
|
199
|
+
|
|
200
|
+
options = ScanOptions(
|
|
201
|
+
no_cache=no_cache,
|
|
202
|
+
backend=backend, # type: ignore[arg-type]
|
|
203
|
+
sandbox=sandbox,
|
|
204
|
+
timeout_s=timeout_s,
|
|
205
|
+
)
|
|
206
|
+
outcomes: list[ScanOutcome] = []
|
|
207
|
+
failed = False
|
|
208
|
+
for root in roots:
|
|
209
|
+
try:
|
|
210
|
+
outcome = engine.scan(root, options=options)
|
|
211
|
+
except ScanError as exc:
|
|
212
|
+
failed = True
|
|
213
|
+
typer.echo(f"error: {root}: {exc}", err=True)
|
|
214
|
+
continue
|
|
215
|
+
outcomes.append(outcome)
|
|
216
|
+
engine.close()
|
|
217
|
+
|
|
218
|
+
if not outcomes:
|
|
219
|
+
raise typer.Exit(code=int(ExitCode.OPERATIONAL))
|
|
220
|
+
|
|
221
|
+
_emit_outcomes(
|
|
222
|
+
outcomes,
|
|
223
|
+
output_format=output_format,
|
|
224
|
+
quiet=quiet,
|
|
225
|
+
backend=backend,
|
|
226
|
+
output=output,
|
|
227
|
+
sarif_out=sarif_out,
|
|
228
|
+
skipped=len(roots) - len(outcomes),
|
|
229
|
+
)
|
|
230
|
+
if failed:
|
|
231
|
+
raise typer.Exit(code=int(ExitCode.OPERATIONAL))
|
|
232
|
+
if any(_should_fail(outcome.verdict, fail_on) for outcome in outcomes):
|
|
233
|
+
raise typer.Exit(code=int(ExitCode.VERDICT))
|
|
234
|
+
raise typer.Exit(code=int(ExitCode.OK))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.command("self-audit")
|
|
238
|
+
def self_audit(
|
|
239
|
+
output_format: str = typer.Option("text", "--format", help="text | json | sarif | markdown"),
|
|
240
|
+
fail_on: str = typer.Option("suspicious", "--fail-on", help="safe | suspicious | malicious"),
|
|
241
|
+
quiet: bool = typer.Option(False, "-q", "--quiet", help="Verdict line only."),
|
|
242
|
+
no_cache: bool = typer.Option(False, "--no-cache", help="Bypass the verdict cache."),
|
|
243
|
+
sandbox: bool = typer.Option(True, "--sandbox/--no-sandbox"),
|
|
244
|
+
backend: str = typer.Option("auto", "--backend", help="auto | local | cloud | demo"),
|
|
245
|
+
timeout_s: int = typer.Option(60, "--timeout", min=5, max=600),
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Audit SkillSentry's own bundled Agent Skill."""
|
|
248
|
+
|
|
249
|
+
if output_format not in _FORMATS or fail_on not in _FAIL_ON or backend not in _BACKENDS:
|
|
250
|
+
typer.echo("error: invalid self-audit option", err=True)
|
|
251
|
+
raise typer.Exit(code=int(ExitCode.USAGE))
|
|
252
|
+
_refuse_mock_backend(backend)
|
|
253
|
+
skill_dir = Path(__file__).parent / "skill"
|
|
254
|
+
if output_format == "text" and not quiet:
|
|
255
|
+
typer.echo(f"Scanning {skill_dir}...")
|
|
256
|
+
try:
|
|
257
|
+
config = _config_for_backend(backend)
|
|
258
|
+
engine = _make_engine(config, backend)
|
|
259
|
+
outcome = engine.scan(
|
|
260
|
+
skill_dir,
|
|
261
|
+
options=ScanOptions(
|
|
262
|
+
no_cache=no_cache,
|
|
263
|
+
backend=backend, # type: ignore[arg-type]
|
|
264
|
+
sandbox=sandbox,
|
|
265
|
+
timeout_s=timeout_s,
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
engine.close()
|
|
269
|
+
except (ConfigError, ScanError) as exc:
|
|
270
|
+
typer.echo(f"error: {exc}", err=True)
|
|
271
|
+
raise typer.Exit(code=int(ExitCode.OPERATIONAL)) from exc
|
|
272
|
+
_print_verdict(outcome.verdict, output_format=output_format, quiet=quiet, backend=backend)
|
|
273
|
+
if _should_fail(outcome.verdict, fail_on):
|
|
274
|
+
raise typer.Exit(code=int(ExitCode.VERDICT))
|
|
275
|
+
raise typer.Exit(code=int(ExitCode.OK))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# helpers
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
def _refuse_mock_backend(backend: str) -> None:
|
|
282
|
+
"""``--backend demo`` runs the deterministic mock model; it never produces a real verdict."""
|
|
283
|
+
|
|
284
|
+
if backend == "demo" and os.getenv("SKILLSENTRY_DEV") != "1":
|
|
285
|
+
typer.echo(
|
|
286
|
+
"error: --backend demo runs the mock model and is refused for real scans. "
|
|
287
|
+
"Use --demo-mode for the canned store, or set SKILLSENTRY_DEV=1 for development.",
|
|
288
|
+
err=True,
|
|
289
|
+
)
|
|
290
|
+
raise typer.Exit(code=int(ExitCode.USAGE))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _load_config_or_exit() -> SkillsEntryConfig:
|
|
294
|
+
"""Load the configuration; a bad file is a usage error with a message, not a traceback."""
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
return load_config()
|
|
298
|
+
except ConfigError as exc:
|
|
299
|
+
typer.echo(f"error: {exc}", err=True)
|
|
300
|
+
raise typer.Exit(code=int(ExitCode.USAGE)) from exc
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _config_for_backend(backend: str) -> SkillsEntryConfig:
|
|
304
|
+
config = _load_config_or_exit()
|
|
305
|
+
if backend == "auto":
|
|
306
|
+
return config
|
|
307
|
+
config = config.model_copy(deep=True)
|
|
308
|
+
config.model.backend = backend
|
|
309
|
+
return config
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _make_engine(config: SkillsEntryConfig, backend: str) -> ScanEngine:
|
|
313
|
+
model = MockGemma() if backend == "demo" else None
|
|
314
|
+
return ScanEngine(config, model=model)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _demo_verdict_for(path: Path) -> Verdict:
|
|
318
|
+
name = path.name.lower()
|
|
319
|
+
for item in build_demo_store():
|
|
320
|
+
if item.name == name:
|
|
321
|
+
return item.verdict
|
|
322
|
+
typer.echo(
|
|
323
|
+
f"error: demo store has no skill named {name!r}. "
|
|
324
|
+
"Available: " + ", ".join(item.name for item in build_demo_store()),
|
|
325
|
+
err=True,
|
|
326
|
+
)
|
|
327
|
+
raise typer.Exit(code=int(ExitCode.USAGE))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _outcome_status(outcome: ScanOutcome) -> str:
|
|
331
|
+
return shown_status(outcome.verdict.verdict, outcome.stage_results)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _folder_summary(outcomes: list[ScanOutcome], skipped: int) -> str:
|
|
335
|
+
counts = Counter(_outcome_status(outcome) for outcome in outcomes)
|
|
336
|
+
tiers = [tier.value for tier in VerdictTier] + ["UNSCANNED"]
|
|
337
|
+
parts = ", ".join(f"{counts.get(tier, 0)} {tier}" for tier in tiers)
|
|
338
|
+
line = f"Summary: {len(outcomes)} scanned - {parts}"
|
|
339
|
+
return f"{line}, {skipped} failed" if skipped else line
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _folder_summary_table(outcomes: list[ScanOutcome], skipped: int) -> str:
|
|
343
|
+
rows = [
|
|
344
|
+
f"| {outcome.verdict.skill} | {_outcome_status(outcome)} | {outcome.verdict.risk_score} |"
|
|
345
|
+
for outcome in outcomes
|
|
346
|
+
]
|
|
347
|
+
lines = [
|
|
348
|
+
"# SkillSentry folder summary",
|
|
349
|
+
"",
|
|
350
|
+
_folder_summary(outcomes, skipped),
|
|
351
|
+
"",
|
|
352
|
+
"| Skill | Verdict | Risk |",
|
|
353
|
+
"|:---|:---|---:|",
|
|
354
|
+
*rows,
|
|
355
|
+
]
|
|
356
|
+
return "\n".join(lines)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _write_file(path: Path, text: str) -> None:
|
|
360
|
+
"""Write a UTF-8 report or config; an unwritable path is an operational error, not a crash."""
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
path.write_text(text, encoding="utf-8")
|
|
365
|
+
except OSError as exc:
|
|
366
|
+
typer.echo(f"error: could not write {path}: {exc.strerror or exc}", err=True)
|
|
367
|
+
raise typer.Exit(code=int(ExitCode.OPERATIONAL)) from exc
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _emit_outcomes(
|
|
371
|
+
outcomes: list[ScanOutcome],
|
|
372
|
+
*,
|
|
373
|
+
output_format: str,
|
|
374
|
+
quiet: bool,
|
|
375
|
+
backend: str,
|
|
376
|
+
output: Path | None,
|
|
377
|
+
sarif_out: Path | None,
|
|
378
|
+
skipped: int = 0,
|
|
379
|
+
) -> None:
|
|
380
|
+
if sarif_out is not None:
|
|
381
|
+
_write_file(sarif_out, _combined_sarif(outcomes))
|
|
382
|
+
if not quiet and output_format == "text":
|
|
383
|
+
typer.echo(f"wrote {sarif_out}")
|
|
384
|
+
|
|
385
|
+
if output_format == "json":
|
|
386
|
+
payload = outcomes[0].verdict.model_dump(by_alias=True, mode="json")
|
|
387
|
+
if len(outcomes) > 1:
|
|
388
|
+
payload = [
|
|
389
|
+
outcome.verdict.model_dump(by_alias=True, mode="json") for outcome in outcomes
|
|
390
|
+
]
|
|
391
|
+
rendered = json.dumps(payload, indent=2, ensure_ascii=False)
|
|
392
|
+
elif output_format == "sarif":
|
|
393
|
+
rendered = _combined_sarif(outcomes)
|
|
394
|
+
elif output_format == "markdown":
|
|
395
|
+
reports = [
|
|
396
|
+
render_report(
|
|
397
|
+
outcome.verdict,
|
|
398
|
+
ExportFormat.MARKDOWN,
|
|
399
|
+
stage_results=outcome.stage_results,
|
|
400
|
+
).content.decode("utf-8")
|
|
401
|
+
for outcome in outcomes
|
|
402
|
+
]
|
|
403
|
+
if len(outcomes) > 1:
|
|
404
|
+
reports.insert(0, _folder_summary_table(outcomes, skipped))
|
|
405
|
+
rendered = "\n\n---\n\n".join(reports)
|
|
406
|
+
else:
|
|
407
|
+
lines = [_verdict_text(outcome, quiet=quiet, backend=backend) for outcome in outcomes]
|
|
408
|
+
if len(outcomes) > 1 and not quiet:
|
|
409
|
+
lines.append(_folder_summary(outcomes, skipped))
|
|
410
|
+
rendered = "\n".join(lines)
|
|
411
|
+
|
|
412
|
+
if output is not None:
|
|
413
|
+
_write_file(output, rendered)
|
|
414
|
+
if output_format == "text":
|
|
415
|
+
typer.echo(rendered.rstrip())
|
|
416
|
+
return
|
|
417
|
+
typer.echo(rendered.rstrip())
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _combined_sarif(outcomes: list[ScanOutcome]) -> str:
|
|
421
|
+
if len(outcomes) == 1:
|
|
422
|
+
return render_report(outcomes[0].verdict, ExportFormat.SARIF).content.decode("utf-8")
|
|
423
|
+
runs = []
|
|
424
|
+
for outcome in outcomes:
|
|
425
|
+
payload = json.loads(
|
|
426
|
+
render_report(outcome.verdict, ExportFormat.SARIF).content.decode("utf-8")
|
|
427
|
+
)
|
|
428
|
+
runs.extend(payload.get("runs", []))
|
|
429
|
+
return json.dumps(
|
|
430
|
+
{
|
|
431
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
432
|
+
"version": "2.1.0",
|
|
433
|
+
"runs": runs,
|
|
434
|
+
},
|
|
435
|
+
indent=2,
|
|
436
|
+
ensure_ascii=False,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _verdict_text(outcome: ScanOutcome, *, quiet: bool, backend: str) -> str:
|
|
441
|
+
verdict = outcome.verdict
|
|
442
|
+
lines: list[str] = []
|
|
443
|
+
if not quiet:
|
|
444
|
+
lines.append(
|
|
445
|
+
f"SkillSentry {__version__} - {GEMMA_BADGE} - backend: {_backend_label(backend)}"
|
|
446
|
+
)
|
|
447
|
+
cache = " (cached)" if verdict.cached else ""
|
|
448
|
+
if is_unscanned(verdict.verdict, outcome.stage_results):
|
|
449
|
+
lines.append(
|
|
450
|
+
f"[?] {verdict.skill} - UNSCANNED (verdict {verdict.verdict.value}, "
|
|
451
|
+
f"risk {verdict.risk_score}) - {degraded_reason(outcome.stage_results)}{cache}"
|
|
452
|
+
)
|
|
453
|
+
else:
|
|
454
|
+
icon = {"SAFE": "[OK]", "SUSPICIOUS": "[!]", "MALICIOUS": "[X]"}[verdict.verdict.value]
|
|
455
|
+
lines.append(
|
|
456
|
+
f"{icon} {verdict.skill} - {verdict.verdict.value} - "
|
|
457
|
+
f"risk {verdict.risk_score} - confidence {verdict.confidence:.2f}{cache}"
|
|
458
|
+
)
|
|
459
|
+
finding = verdict.key_finding
|
|
460
|
+
if finding is not None and finding.severity.value not in {"note"}:
|
|
461
|
+
location = ""
|
|
462
|
+
if finding.evidence.file:
|
|
463
|
+
location = f" ({finding.evidence.file}"
|
|
464
|
+
if finding.evidence.line:
|
|
465
|
+
location += f":{finding.evidence.line}"
|
|
466
|
+
location += ")"
|
|
467
|
+
lines.append(f" {finding.message}{location}")
|
|
468
|
+
active = verdict.active_defense
|
|
469
|
+
if active is not None and active.triggered:
|
|
470
|
+
channel = active.exfil_channel.value if active.exfil_channel else "egress"
|
|
471
|
+
lines.append(
|
|
472
|
+
f" Poisoned Well: {channel} -> "
|
|
473
|
+
f"{', '.join(active.c2_indicators) or 'unknown'} (marker {active.canary_marker})"
|
|
474
|
+
)
|
|
475
|
+
return "\n".join(lines)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _backend_label(backend: str) -> str:
|
|
479
|
+
return {
|
|
480
|
+
"auto": "auto (local -> cloud -> unavailable)",
|
|
481
|
+
"local": "local (ollama)",
|
|
482
|
+
"cloud": "cloud (gemini)",
|
|
483
|
+
"demo": "demo/mock",
|
|
484
|
+
}.get(backend, backend)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _render_verdict(
|
|
488
|
+
verdict: Verdict,
|
|
489
|
+
*,
|
|
490
|
+
output_format: str,
|
|
491
|
+
quiet: bool,
|
|
492
|
+
backend: str = "demo",
|
|
493
|
+
) -> str:
|
|
494
|
+
if output_format == "json":
|
|
495
|
+
return verdict.model_dump_json(by_alias=True, indent=2)
|
|
496
|
+
if output_format == "sarif":
|
|
497
|
+
return render_report(verdict, ExportFormat.SARIF).content.decode("utf-8")
|
|
498
|
+
if output_format == "markdown":
|
|
499
|
+
return render_report(verdict, ExportFormat.MARKDOWN).content.decode("utf-8")
|
|
500
|
+
outcome = ScanOutcome(
|
|
501
|
+
scan_id=verdict.scan_id,
|
|
502
|
+
skill_id=verdict.skill_id,
|
|
503
|
+
skill_name=verdict.skill,
|
|
504
|
+
verdict=verdict,
|
|
505
|
+
detail=None, # type: ignore[arg-type]
|
|
506
|
+
stage_results=[],
|
|
507
|
+
cached=verdict.cached,
|
|
508
|
+
started_at=None, # type: ignore[arg-type]
|
|
509
|
+
duration_ms=verdict.scan_duration_ms,
|
|
510
|
+
)
|
|
511
|
+
return _verdict_text(outcome, quiet=quiet, backend=backend)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _print_verdict(
|
|
515
|
+
verdict: Verdict,
|
|
516
|
+
*,
|
|
517
|
+
output_format: str,
|
|
518
|
+
quiet: bool,
|
|
519
|
+
backend: str = "demo",
|
|
520
|
+
) -> None:
|
|
521
|
+
typer.echo(
|
|
522
|
+
_render_verdict(verdict, output_format=output_format, quiet=quiet, backend=backend).rstrip()
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _should_fail(verdict: Verdict, fail_on: str) -> bool:
|
|
527
|
+
rank = _RANK[verdict.verdict]
|
|
528
|
+
if fail_on == "safe":
|
|
529
|
+
return rank > 0
|
|
530
|
+
if fail_on == "suspicious":
|
|
531
|
+
return rank >= 1
|
|
532
|
+
return rank >= 2
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
cache_app = typer.Typer(help="Inspect and clear the local verdict cache.", no_args_is_help=True)
|
|
536
|
+
app.add_typer(cache_app, name="cache")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _store() -> SQLiteStore:
|
|
540
|
+
return SQLiteStore(_load_config_or_exit().db_path)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@cache_app.command("stats")
|
|
544
|
+
def cache_stats() -> None:
|
|
545
|
+
"""Show the number of cached verdicts and the database size."""
|
|
546
|
+
|
|
547
|
+
with contextlib.closing(_store()) as store:
|
|
548
|
+
entries, hit_rate = store.cache_stats()
|
|
549
|
+
db_path = Path(str(store.path))
|
|
550
|
+
size_bytes = db_path.stat().st_size if db_path.is_file() else 0
|
|
551
|
+
typer.echo(f"entries: {entries}")
|
|
552
|
+
typer.echo(f"size_bytes: {size_bytes}")
|
|
553
|
+
typer.echo(f"hit_rate: {hit_rate:.3f}")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
@cache_app.command("list")
|
|
557
|
+
def cache_list(
|
|
558
|
+
limit: int = typer.Option(20, "--limit", min=1, max=500, help="Rows to show."),
|
|
559
|
+
) -> None:
|
|
560
|
+
"""List the most recent scans and their verdicts."""
|
|
561
|
+
|
|
562
|
+
with contextlib.closing(_store()) as store:
|
|
563
|
+
rows = store.list_scans(limit=limit)
|
|
564
|
+
if not rows:
|
|
565
|
+
typer.echo("no scans recorded")
|
|
566
|
+
return
|
|
567
|
+
for row in rows:
|
|
568
|
+
status = getattr(row.get("status"), "value", row.get("status"))
|
|
569
|
+
verdict = getattr(row.get("verdict"), "value", row.get("verdict"))
|
|
570
|
+
typer.echo(f"{row.get('scan_id')} {row.get('skill')} {status} {verdict or '-'}")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@cache_app.command("clear")
|
|
574
|
+
def cache_clear(
|
|
575
|
+
older_than: str | None = typer.Option(
|
|
576
|
+
None, "--older-than", help="Only remove entries older than this, e.g. 7d or 12h."
|
|
577
|
+
),
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Remove cached verdicts, optionally only those older than a given age."""
|
|
580
|
+
|
|
581
|
+
with contextlib.closing(_store()) as store:
|
|
582
|
+
try:
|
|
583
|
+
deleted, retained = store.clear_cache(older_than)
|
|
584
|
+
except ValueError as exc:
|
|
585
|
+
typer.echo(f"error: {exc}", err=True)
|
|
586
|
+
raise typer.Exit(code=int(ExitCode.USAGE)) from exc
|
|
587
|
+
typer.echo(f"deleted: {deleted} retained: {retained}")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@app.command("export")
|
|
591
|
+
def export_report(
|
|
592
|
+
scan_id: str = typer.Argument(..., help="Scan ID printed by `scan` or `cache list`."),
|
|
593
|
+
fmt: str = typer.Option("markdown", "--format", help="sarif | markdown | stix"),
|
|
594
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Write to a file."),
|
|
595
|
+
) -> None:
|
|
596
|
+
"""Re-render the report of a stored scan without re-running it."""
|
|
597
|
+
|
|
598
|
+
formats = {
|
|
599
|
+
"sarif": ExportFormat.SARIF,
|
|
600
|
+
"markdown": ExportFormat.MARKDOWN,
|
|
601
|
+
"stix": ExportFormat.STIX,
|
|
602
|
+
}
|
|
603
|
+
if fmt not in formats:
|
|
604
|
+
typer.echo(f"error: unsupported --format {fmt!r}", err=True)
|
|
605
|
+
raise typer.Exit(code=int(ExitCode.USAGE))
|
|
606
|
+
try:
|
|
607
|
+
parsed = UUID(scan_id)
|
|
608
|
+
except ValueError as exc:
|
|
609
|
+
typer.echo(f"error: {scan_id!r} is not a scan ID", err=True)
|
|
610
|
+
raise typer.Exit(code=int(ExitCode.USAGE)) from exc
|
|
611
|
+
with contextlib.closing(_store()) as store:
|
|
612
|
+
stored = store.get_result(parsed)
|
|
613
|
+
if stored is None:
|
|
614
|
+
typer.echo(f"error: no stored scan {scan_id}", err=True)
|
|
615
|
+
raise typer.Exit(code=int(ExitCode.USAGE))
|
|
616
|
+
verdict, _detail, stages, _cached = stored
|
|
617
|
+
rendered = render_report(verdict, formats[fmt], stage_results=stages).content.decode("utf-8")
|
|
618
|
+
if output is None:
|
|
619
|
+
typer.echo(rendered.rstrip())
|
|
620
|
+
else:
|
|
621
|
+
_write_file(output, rendered)
|
|
622
|
+
typer.echo(f"wrote {output}")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@dataclass(frozen=True)
|
|
626
|
+
class ReadinessCheck:
|
|
627
|
+
name: str
|
|
628
|
+
ok: bool
|
|
629
|
+
required: bool
|
|
630
|
+
detail: str
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def readiness_checks(cfg: SkillsEntryConfig) -> list[ReadinessCheck]:
|
|
634
|
+
"""What a full scan needs on this machine. Used by ``doctor`` and ``setup``.
|
|
635
|
+
|
|
636
|
+
Required checks must pass for a full scan. Docker and the sandbox image are
|
|
637
|
+
optional: without them, scans run with --no-sandbox and the sandbox stage
|
|
638
|
+
reports itself as degraded.
|
|
639
|
+
"""
|
|
640
|
+
|
|
641
|
+
from skill_sentry_cli.core.models.gemma_local import GemmaLocal
|
|
642
|
+
from skill_sentry_cli.server.app import _default_ui_dist
|
|
643
|
+
|
|
644
|
+
checks: list[ReadinessCheck] = []
|
|
645
|
+
probe_error: str | None = None
|
|
646
|
+
try:
|
|
647
|
+
model = GemmaLocal(base_url=cfg.model.ollama_url, model=cfg.model.ollama_model)
|
|
648
|
+
try:
|
|
649
|
+
probe = model.probe()
|
|
650
|
+
finally:
|
|
651
|
+
model.close()
|
|
652
|
+
except Exception as exc: # any probe failure is a finding, not a crash
|
|
653
|
+
probe = ProbeResult.UNREACHABLE
|
|
654
|
+
probe_error = type(exc).__name__
|
|
655
|
+
if probe == ProbeResult.READY:
|
|
656
|
+
model_detail = f"{cfg.model.ollama_model} is pulled"
|
|
657
|
+
elif probe == ProbeResult.NO_MODEL:
|
|
658
|
+
model_detail = (
|
|
659
|
+
f"{cfg.model.ollama_model} is not pulled; run: ollama pull {cfg.model.ollama_model}"
|
|
660
|
+
)
|
|
661
|
+
else:
|
|
662
|
+
cause = f" ({probe_error})" if probe_error else ""
|
|
663
|
+
model_detail = f"cannot reach the model server at {cfg.model.ollama_url}{cause}"
|
|
664
|
+
checks.append(ReadinessCheck("model", probe == ProbeResult.READY, True, model_detail))
|
|
665
|
+
|
|
666
|
+
ui_index = _default_ui_dist() / "index.html"
|
|
667
|
+
checks.append(
|
|
668
|
+
ReadinessCheck(
|
|
669
|
+
"dashboard",
|
|
670
|
+
ui_index.is_file(),
|
|
671
|
+
True,
|
|
672
|
+
"built" if ui_index.is_file() else "not built; run scripts/build_release.sh",
|
|
673
|
+
)
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
with contextlib.closing(_store()):
|
|
678
|
+
db_ok = True
|
|
679
|
+
db_detail = f"writable at {cfg.db_path}"
|
|
680
|
+
except Exception as exc:
|
|
681
|
+
db_ok = False
|
|
682
|
+
db_detail = f"cannot open {cfg.db_path}: {exc}"
|
|
683
|
+
checks.append(ReadinessCheck("database", db_ok, True, db_detail))
|
|
684
|
+
|
|
685
|
+
checks.extend(_docker_checks(cfg))
|
|
686
|
+
return checks
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
_DOCKER_DOWN = "engine not reachable; sandbox scans are unavailable (use --no-sandbox)"
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _docker_checks(cfg: SkillsEntryConfig) -> list[ReadinessCheck]:
|
|
693
|
+
import docker
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
client = docker.from_env()
|
|
697
|
+
client.ping()
|
|
698
|
+
except Exception:
|
|
699
|
+
return [ReadinessCheck("docker", False, False, _DOCKER_DOWN)]
|
|
700
|
+
try:
|
|
701
|
+
image_check: ReadinessCheck
|
|
702
|
+
try:
|
|
703
|
+
client.images.get(cfg.sandbox.base_image)
|
|
704
|
+
image_check = ReadinessCheck(
|
|
705
|
+
"sandbox image", True, False, f"{cfg.sandbox.base_image} is present"
|
|
706
|
+
)
|
|
707
|
+
except Exception:
|
|
708
|
+
image_check = ReadinessCheck(
|
|
709
|
+
"sandbox image",
|
|
710
|
+
False,
|
|
711
|
+
False,
|
|
712
|
+
f"{cfg.sandbox.base_image} is missing; run: skill-sentry-cli sandbox build",
|
|
713
|
+
)
|
|
714
|
+
finally:
|
|
715
|
+
client.close()
|
|
716
|
+
return [ReadinessCheck("docker", True, False, "engine reachable"), image_check]
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@app.command("doctor")
|
|
720
|
+
def doctor() -> None:
|
|
721
|
+
"""Check what a scan needs on this machine, and say what is missing.
|
|
722
|
+
|
|
723
|
+
Required checks must pass for a full scan. Docker and the sandbox image are
|
|
724
|
+
optional: without them, scans run with --no-sandbox and the sandbox stage
|
|
725
|
+
reports itself as degraded.
|
|
726
|
+
"""
|
|
727
|
+
|
|
728
|
+
checks = readiness_checks(_load_config_or_exit())
|
|
729
|
+
for check in checks:
|
|
730
|
+
status = "PASS" if check.ok else ("FAIL" if check.required else "WARN")
|
|
731
|
+
typer.echo(f"{status} {check.name}: {check.detail}")
|
|
732
|
+
if any(check.required and not check.ok for check in checks):
|
|
733
|
+
raise typer.Exit(code=int(ExitCode.OPERATIONAL))
|
|
734
|
+
typer.echo("ready for a full scan")
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
sandbox_app = typer.Typer(help="Build and inspect the sandbox image.", no_args_is_help=True)
|
|
738
|
+
app.add_typer(sandbox_app, name="sandbox")
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
@sandbox_app.command("build")
|
|
742
|
+
def sandbox_build() -> None:
|
|
743
|
+
"""Build the sandbox image from the sources packaged with SkillSentry."""
|
|
744
|
+
|
|
745
|
+
build_sandbox_image(_load_config_or_exit())
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def pinned_sandbox_digest() -> tuple[str, str] | None:
|
|
749
|
+
"""The published sandbox image this release was built against, or None for a source checkout.
|
|
750
|
+
|
|
751
|
+
The release workflow writes the digest into ``data/sandbox_image.json`` before
|
|
752
|
+
it builds the wheel, so an installed version pulls exactly the image it shipped with.
|
|
753
|
+
"""
|
|
754
|
+
|
|
755
|
+
import importlib.resources
|
|
756
|
+
import json
|
|
757
|
+
|
|
758
|
+
text = (
|
|
759
|
+
importlib.resources.files("skill_sentry_cli.data")
|
|
760
|
+
.joinpath("sandbox_image.json")
|
|
761
|
+
.read_text(encoding="utf-8")
|
|
762
|
+
)
|
|
763
|
+
record = json.loads(text)
|
|
764
|
+
digest = str(record.get("digest") or "").strip()
|
|
765
|
+
repository = str(record.get("repository") or "").strip()
|
|
766
|
+
if not digest or not repository:
|
|
767
|
+
return None
|
|
768
|
+
return repository, digest
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def pull_pinned_sandbox(cfg: SkillsEntryConfig) -> bool:
|
|
772
|
+
"""Pull the pinned published image and tag it as the configured base image.
|
|
773
|
+
|
|
774
|
+
Returns False when no digest is pinned or the pull fails, so the caller can
|
|
775
|
+
fall back to building from the packaged sources.
|
|
776
|
+
"""
|
|
777
|
+
|
|
778
|
+
pinned = pinned_sandbox_digest()
|
|
779
|
+
if pinned is None:
|
|
780
|
+
return False
|
|
781
|
+
repository, digest = pinned
|
|
782
|
+
import docker
|
|
783
|
+
|
|
784
|
+
client = None
|
|
785
|
+
try:
|
|
786
|
+
client = docker.from_env()
|
|
787
|
+
image = client.images.pull(f"{repository}@{digest}")
|
|
788
|
+
image.tag(cfg.sandbox.base_image)
|
|
789
|
+
except docker.errors.DockerException as exc:
|
|
790
|
+
typer.echo(f"could not pull the published sandbox image: {exc}")
|
|
791
|
+
return False
|
|
792
|
+
finally:
|
|
793
|
+
close = getattr(client, "close", None)
|
|
794
|
+
if callable(close):
|
|
795
|
+
close()
|
|
796
|
+
typer.echo(f"pulled the published sandbox image as {cfg.sandbox.base_image}")
|
|
797
|
+
return True
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def build_sandbox_image(cfg: SkillsEntryConfig) -> None:
|
|
801
|
+
"""Build the sandbox image from the packaged sources; a Docker failure is an operational error."""
|
|
802
|
+
|
|
803
|
+
import docker
|
|
804
|
+
|
|
805
|
+
assets = Path(__file__).resolve().parent / "core" / "sandbox" / "assets"
|
|
806
|
+
client = None
|
|
807
|
+
try:
|
|
808
|
+
client = docker.from_env()
|
|
809
|
+
client.images.build(
|
|
810
|
+
path=str(assets),
|
|
811
|
+
dockerfile="Dockerfile.sandbox",
|
|
812
|
+
tag=cfg.sandbox.base_image,
|
|
813
|
+
rm=True,
|
|
814
|
+
)
|
|
815
|
+
except docker.errors.DockerException as exc:
|
|
816
|
+
typer.echo(f"error: could not build the sandbox image: {exc}", err=True)
|
|
817
|
+
raise typer.Exit(code=int(ExitCode.OPERATIONAL)) from exc
|
|
818
|
+
finally:
|
|
819
|
+
close = getattr(client, "close", None)
|
|
820
|
+
if callable(close):
|
|
821
|
+
close()
|
|
822
|
+
typer.echo(f"built {cfg.sandbox.base_image}")
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _confirm(question: str, *, default: bool, assume_yes: bool) -> bool:
|
|
826
|
+
"""Ask before anything is downloaded or built. ``--yes`` answers every question."""
|
|
827
|
+
|
|
828
|
+
if assume_yes:
|
|
829
|
+
return True
|
|
830
|
+
return typer.confirm(question, default=default)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
@app.command("setup")
|
|
834
|
+
def setup(
|
|
835
|
+
assume_yes: bool = typer.Option(
|
|
836
|
+
False, "--yes", help="Answer yes to every prompt (downloads and builds)."
|
|
837
|
+
),
|
|
838
|
+
skip_model: bool = typer.Option(False, "--skip-model", help="Do not check or pull the model."),
|
|
839
|
+
skip_sandbox: bool = typer.Option(
|
|
840
|
+
False, "--skip-sandbox", help="Do not check or build the sandbox image."
|
|
841
|
+
),
|
|
842
|
+
) -> None:
|
|
843
|
+
"""Guided first-time setup. Asks before every download or build.
|
|
844
|
+
|
|
845
|
+
It never installs Docker or Ollama itself; it says what to install. It ends
|
|
846
|
+
with the same readiness checks as ``doctor``.
|
|
847
|
+
"""
|
|
848
|
+
|
|
849
|
+
from skill_sentry_cli.core.config import write_default_config
|
|
850
|
+
from skill_sentry_cli.core.models.gemma_local import GemmaLocal
|
|
851
|
+
|
|
852
|
+
config_path = Path(os.getenv("SKILLSENTRY_CONFIG") or Path.cwd() / "skillsentry.toml")
|
|
853
|
+
if not config_path.exists() and _confirm(
|
|
854
|
+
f"Write the default configuration to {config_path}?", default=True, assume_yes=assume_yes
|
|
855
|
+
):
|
|
856
|
+
write_default_config(config_path, force=False)
|
|
857
|
+
typer.echo(f"wrote {config_path}")
|
|
858
|
+
cfg = _load_config_or_exit()
|
|
859
|
+
|
|
860
|
+
checks = {check.name: check for check in readiness_checks(cfg)}
|
|
861
|
+
|
|
862
|
+
if not skip_sandbox:
|
|
863
|
+
docker_check = checks.get("docker")
|
|
864
|
+
if docker_check is None or not docker_check.ok:
|
|
865
|
+
typer.echo(
|
|
866
|
+
"Docker is not reachable. Install Docker, then make sure your user can run "
|
|
867
|
+
"`docker ps` without sudo (the user guide, section 4, explains the group step)."
|
|
868
|
+
)
|
|
869
|
+
elif (
|
|
870
|
+
not checks["sandbox image"].ok
|
|
871
|
+
and _confirm(
|
|
872
|
+
"Install the sandbox image now? A published copy is downloaded when this release "
|
|
873
|
+
"has one; otherwise it is built from the packaged sources (a few minutes).",
|
|
874
|
+
default=True,
|
|
875
|
+
assume_yes=assume_yes,
|
|
876
|
+
)
|
|
877
|
+
and not pull_pinned_sandbox(cfg)
|
|
878
|
+
):
|
|
879
|
+
build_sandbox_image(cfg)
|
|
880
|
+
|
|
881
|
+
if not skip_model:
|
|
882
|
+
model_name = cfg.model.ollama_model
|
|
883
|
+
probe = GemmaLocal(base_url=cfg.model.ollama_url, model=model_name)
|
|
884
|
+
try:
|
|
885
|
+
state = probe.probe()
|
|
886
|
+
finally:
|
|
887
|
+
probe.close()
|
|
888
|
+
if state == ProbeResult.UNREACHABLE:
|
|
889
|
+
typer.echo(
|
|
890
|
+
f"The model server is not reachable at {cfg.model.ollama_url}. Install Ollama "
|
|
891
|
+
"from https://ollama.com, start it (`ollama serve`), then run setup again."
|
|
892
|
+
)
|
|
893
|
+
elif state == ProbeResult.NO_MODEL and _confirm(
|
|
894
|
+
f"Download the configured model now with `ollama pull {model_name}`? "
|
|
895
|
+
"This is several gigabytes.",
|
|
896
|
+
default=False,
|
|
897
|
+
assume_yes=assume_yes,
|
|
898
|
+
):
|
|
899
|
+
try:
|
|
900
|
+
result = subprocess.run(["ollama", "pull", model_name], check=False)
|
|
901
|
+
except FileNotFoundError:
|
|
902
|
+
typer.echo("The ollama command was not found. Install Ollama first.")
|
|
903
|
+
else:
|
|
904
|
+
if result.returncode != 0:
|
|
905
|
+
typer.echo(f"`ollama pull {model_name}` failed (exit {result.returncode}).")
|
|
906
|
+
|
|
907
|
+
final = readiness_checks(cfg)
|
|
908
|
+
for check in final:
|
|
909
|
+
status = "PASS" if check.ok else ("FAIL" if check.required else "WARN")
|
|
910
|
+
typer.echo(f"{status} {check.name}: {check.detail}")
|
|
911
|
+
if any(check.required and not check.ok for check in final):
|
|
912
|
+
typer.echo("Setup is not complete yet. Fix the FAIL lines above, then run setup again.")
|
|
913
|
+
raise typer.Exit(code=int(ExitCode.OPERATIONAL))
|
|
914
|
+
typer.echo("ready for a full scan")
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
if __name__ == "__main__": # pragma: no cover
|
|
918
|
+
app()
|