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.
Files changed (96) hide show
  1. skill_sentry_cli/__init__.py +5 -0
  2. skill_sentry_cli/__main__.py +4 -0
  3. skill_sentry_cli/cli.py +918 -0
  4. skill_sentry_cli/core/__init__.py +1 -0
  5. skill_sentry_cli/core/anchors/__init__.py +5 -0
  6. skill_sentry_cli/core/anchors/credential_flow.py +440 -0
  7. skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
  8. skill_sentry_cli/core/cache/__init__.py +35 -0
  9. skill_sentry_cli/core/cache/hash_utils.py +206 -0
  10. skill_sentry_cli/core/cache/sqlite_store.py +925 -0
  11. skill_sentry_cli/core/config.py +299 -0
  12. skill_sentry_cli/core/contracts.py +551 -0
  13. skill_sentry_cli/core/decisions/__init__.py +44 -0
  14. skill_sentry_cli/core/decisions/base.py +174 -0
  15. skill_sentry_cli/core/decisions/calibration.py +325 -0
  16. skill_sentry_cli/core/decisions/laya.py +439 -0
  17. skill_sentry_cli/core/decisions/mapping.py +218 -0
  18. skill_sentry_cli/core/decisions/mock.py +118 -0
  19. skill_sentry_cli/core/decisions/questions.py +118 -0
  20. skill_sentry_cli/core/decisions/report.py +65 -0
  21. skill_sentry_cli/core/decisions/selector.py +151 -0
  22. skill_sentry_cli/core/engine.py +1412 -0
  23. skill_sentry_cli/core/evasion/__init__.py +27 -0
  24. skill_sentry_cli/core/evasion/detector.py +210 -0
  25. skill_sentry_cli/core/exit_codes.py +13 -0
  26. skill_sentry_cli/core/models/__init__.py +20 -0
  27. skill_sentry_cli/core/models/_json.py +147 -0
  28. skill_sentry_cli/core/models/base.py +50 -0
  29. skill_sentry_cli/core/models/gemma_cloud.py +240 -0
  30. skill_sentry_cli/core/models/gemma_local.py +273 -0
  31. skill_sentry_cli/core/models/image_utils.py +112 -0
  32. skill_sentry_cli/core/models/mock.py +152 -0
  33. skill_sentry_cli/core/models/selector.py +123 -0
  34. skill_sentry_cli/core/parser/__init__.py +29 -0
  35. skill_sentry_cli/core/parser/skill_parser.py +520 -0
  36. skill_sentry_cli/core/parser/typosquat.py +167 -0
  37. skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
  38. skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
  39. skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
  40. skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
  41. skill_sentry_cli/core/reports.py +114 -0
  42. skill_sentry_cli/core/sandbox/__init__.py +74 -0
  43. skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
  44. skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
  45. skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
  46. skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
  47. skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
  48. skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
  49. skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
  50. skill_sentry_cli/core/sandbox/fake_http.py +194 -0
  51. skill_sentry_cli/core/sandbox/instructions.py +170 -0
  52. skill_sentry_cli/core/sandbox/masquerade.py +265 -0
  53. skill_sentry_cli/core/sandbox/monitor.py +278 -0
  54. skill_sentry_cli/core/sandbox/runner.py +776 -0
  55. skill_sentry_cli/core/sandbox/sidecar.py +327 -0
  56. skill_sentry_cli/core/semantic/__init__.py +9 -0
  57. skill_sentry_cli/core/semantic/analyzer.py +443 -0
  58. skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
  59. skill_sentry_cli/core/semantic/ast_python.py +156 -0
  60. skill_sentry_cli/core/semantic/candidates.py +78 -0
  61. skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
  62. skill_sentry_cli/core/semantic/prompts.py +61 -0
  63. skill_sentry_cli/core/verdict/__init__.py +24 -0
  64. skill_sentry_cli/core/verdict/fusion.py +392 -0
  65. skill_sentry_cli/core/verdict/markdown_report.py +211 -0
  66. skill_sentry_cli/core/verdict/sarif_report.py +139 -0
  67. skill_sentry_cli/core/verdict/status.py +35 -0
  68. skill_sentry_cli/core/verdict/stix_report.py +132 -0
  69. skill_sentry_cli/core/version.py +38 -0
  70. skill_sentry_cli/core/vision/__init__.py +8 -0
  71. skill_sentry_cli/core/vision/image_extractor.py +91 -0
  72. skill_sentry_cli/core/vision/vision_scanner.py +274 -0
  73. skill_sentry_cli/data/__init__.py +1 -0
  74. skill_sentry_cli/data/known_skills_v1.json +276 -0
  75. skill_sentry_cli/data/sandbox_image.json +4 -0
  76. skill_sentry_cli/rules/credential_rules.toml +94 -0
  77. skill_sentry_cli/server/__init__.py +5 -0
  78. skill_sentry_cli/server/app.py +129 -0
  79. skill_sentry_cli/server/backend.py +63 -0
  80. skill_sentry_cli/server/demo_assets.py +62 -0
  81. skill_sentry_cli/server/demo_backend.py +475 -0
  82. skill_sentry_cli/server/demo_data.py +814 -0
  83. skill_sentry_cli/server/demo_mode.py +40 -0
  84. skill_sentry_cli/server/errors.py +48 -0
  85. skill_sentry_cli/server/live_backend.py +576 -0
  86. skill_sentry_cli/server/routes.py +191 -0
  87. skill_sentry_cli/skill/SKILL.md +67 -0
  88. skill_sentry_cli/skill/scripts/audit.sh +48 -0
  89. skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
  90. skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
  91. skill_sentry_cli/ui/dist/index.html +14 -0
  92. skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
  93. skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
  94. skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
  95. skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
  96. skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -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()