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,299 @@
1
+ """Configuration loading for SkillSentry.
2
+
3
+ Precedence (docs/07-cli-reference.md §7.3): CLI flags > environment > file >
4
+ defaults. The file is ``skillsentry.toml`` in the working directory, or the
5
+ path supplied via ``--config`` / ``SKILLSENTRY_CONFIG``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tomllib
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
16
+
17
+ from skill_sentry_cli.core.version import DEFAULT_WEIGHTS, engine_version
18
+
19
+ APP_DIR_NAME = ".skillsentry"
20
+
21
+
22
+ class ConfigError(RuntimeError):
23
+ """Raised when the configuration file is invalid."""
24
+
25
+
26
+ class _ConfigModel(BaseModel):
27
+ model_config = ConfigDict(extra="forbid", protected_namespaces=())
28
+
29
+
30
+ class ModelSettings(_ConfigModel):
31
+ backend: str = "auto" # auto | local | cloud | demo
32
+ ollama_url: str = "http://localhost:11434"
33
+ ollama_model: str = "gemma4:e4b-it-qat"
34
+ gemini_model: str = "gemma-4-e4b"
35
+ image_max_px: int = Field(default=768, ge=256, le=4096)
36
+ request_timeout_s: float = Field(default=180.0, gt=0)
37
+ ollama_num_ctx: int = Field(default=4096, ge=512, le=32768)
38
+ ollama_num_predict: int = Field(default=768, ge=1, le=4096)
39
+ ollama_keep_alive: str = "30m"
40
+
41
+
42
+ class DecisionSettings(_ConfigModel):
43
+ """Typed semantic decision backend settings.
44
+
45
+ ``model`` remains the legacy generation/vision client selection. This
46
+ section controls the Laya-style decision layer independently.
47
+ """
48
+
49
+ # Opt-in: Laya stays off until its calibration gates pass (docs/13 §13.20).
50
+ backend: str = "legacy" # auto | laya | legacy | none
51
+ checkpoint: str = "auto" # auto | english | multilingual | typed-decisions
52
+ device: str = "auto" # auto | cpu | cuda | mps
53
+ model_path: str = ""
54
+ timeout_s: float = Field(default=30.0, gt=0, le=600)
55
+ max_len: int = Field(default=1024, ge=64, le=8192)
56
+ head_max_len: int = Field(default=256, ge=16, le=4096)
57
+ calibration_path: str = ""
58
+ calibration_version: str = ""
59
+ required: bool = False
60
+
61
+ @field_validator("backend")
62
+ @classmethod
63
+ def _validate_backend(cls, value: str) -> str:
64
+ normalised = value.strip().lower()
65
+ allowed = {"auto", "laya", "legacy", "none"}
66
+ if normalised not in allowed:
67
+ raise ValueError(f"backend must be one of {sorted(allowed)}")
68
+ return normalised
69
+
70
+ @field_validator("checkpoint")
71
+ @classmethod
72
+ def _validate_checkpoint(cls, value: str) -> str:
73
+ normalised = value.strip().lower()
74
+ allowed = {"auto", "english", "multilingual", "typed-decisions"}
75
+ if normalised not in allowed:
76
+ raise ValueError(f"checkpoint must be one of {sorted(allowed)}")
77
+ return normalised
78
+
79
+ @field_validator("device")
80
+ @classmethod
81
+ def _validate_device(cls, value: str) -> str:
82
+ normalised = value.strip().lower()
83
+ allowed = {"auto", "cpu", "cuda", "mps"}
84
+ if normalised not in allowed:
85
+ raise ValueError(f"device must be one of {sorted(allowed)}")
86
+ return normalised
87
+
88
+ @model_validator(mode="after")
89
+ def _validate_token_budgets(self) -> DecisionSettings:
90
+ if self.head_max_len > self.max_len:
91
+ raise ValueError("head_max_len must not exceed max_len")
92
+ return self
93
+
94
+ @property
95
+ def cache_identity(self) -> str:
96
+ """Stable identity for cache invalidation when the decision layer changes."""
97
+
98
+ calibration = self.calibration_version.strip() or "uncalibrated"
99
+ return f"{self.backend}:{self.checkpoint}:{calibration}:{self.max_len}:{self.head_max_len}"
100
+
101
+
102
+ class SandboxSettings(_ConfigModel):
103
+ enabled: bool = True
104
+ base_image: str = "skillsentry-sandbox:local"
105
+ timeout_s: int = Field(default=60, ge=5, le=600)
106
+ memory_mb: int = Field(default=512, ge=128)
107
+ cpus: float = Field(default=1.0, gt=0)
108
+ masquerade: bool = True
109
+ max_parallel: int = Field(default=1, ge=1, le=8)
110
+
111
+
112
+ class PoisonedWellSettings(_ConfigModel):
113
+ enabled: bool = True
114
+ correlation_window_s: float = Field(default=10.0, gt=0)
115
+ secondary_canary: bool = True
116
+
117
+
118
+ class TierSettings(_ConfigModel):
119
+ safe_max: int = Field(default=30, ge=0, le=100)
120
+ malicious_min: int = Field(default=70, ge=0, le=100)
121
+
122
+
123
+ class ScoringSettings(_ConfigModel):
124
+ weights: dict[str, float] = Field(default_factory=lambda: dict(DEFAULT_WEIGHTS))
125
+ typosquat_bonus: int = Field(default=15, ge=0, le=100)
126
+ tiers: TierSettings = Field(default_factory=TierSettings)
127
+
128
+ def weight_fingerprint_input(self) -> dict[str, float]:
129
+ return dict(self.weights)
130
+
131
+
132
+ class CacheSettings(_ConfigModel):
133
+ db_path: str = "~/.skillsentry/skillsentry.db"
134
+ backend_strict: bool = False
135
+
136
+
137
+ class CISettings(_ConfigModel):
138
+ fail_on: str = "malicious"
139
+ upload_sarif: bool = False
140
+
141
+
142
+ class SkillsEntryConfig(_ConfigModel):
143
+ model: ModelSettings = Field(default_factory=ModelSettings)
144
+ decision: DecisionSettings = Field(default_factory=DecisionSettings)
145
+ sandbox: SandboxSettings = Field(default_factory=SandboxSettings)
146
+ poisoned_well: PoisonedWellSettings = Field(default_factory=PoisonedWellSettings)
147
+ scoring: ScoringSettings = Field(default_factory=ScoringSettings)
148
+ cache: CacheSettings = Field(default_factory=CacheSettings)
149
+ ci: CISettings = Field(default_factory=CISettings)
150
+
151
+ @property
152
+ def db_path(self) -> Path:
153
+ return Path(os.path.expandvars(os.path.expanduser(self.cache.db_path))).resolve()
154
+
155
+ @property
156
+ def engine_version(self) -> str:
157
+ return engine_version(self.scoring.weights)
158
+
159
+
160
+ DEFAULT_CONFIG_TOML = """\
161
+ # SkillSentry 2.0 configuration — generated by `skill-sentry-cli init`.
162
+ # Precedence: CLI flags > environment > this file > built-in defaults.
163
+
164
+ [model]
165
+ backend = "auto" # auto | local | cloud | demo
166
+ ollama_url = "http://localhost:11434"
167
+ ollama_model = "gemma4:e4b-it-qat"
168
+ gemini_model = "gemma-4-e4b" # used only when backend = cloud/auto-fallback
169
+ image_max_px = 1024
170
+ request_timeout_s = 180
171
+
172
+ [decision]
173
+ backend = "legacy" # auto | laya | legacy | none (laya is opt-in until calibrated)
174
+ checkpoint = "auto" # auto | english | multilingual | typed-decisions
175
+ device = "auto" # auto | cpu | cuda | mps
176
+ model_path = "" # optional offline checkpoint directory
177
+ timeout_s = 30
178
+ max_len = 1024
179
+ head_max_len = 256
180
+ calibration_path = ""
181
+ calibration_version = ""
182
+ required = false
183
+
184
+ [sandbox]
185
+ enabled = true
186
+ base_image = "skillsentry-sandbox:local" # built from assets/Dockerfile.sandbox
187
+ timeout_s = 60
188
+ memory_mb = 512
189
+ cpus = 1.0
190
+ masquerade = true
191
+ max_parallel = 1
192
+
193
+ [poisoned_well]
194
+ enabled = true
195
+ correlation_window_s = 10
196
+ secondary_canary = true
197
+
198
+ [scoring]
199
+ weights = { anchors = 0.15, semantic = 0.20, vision = 0.20, sandbox = 0.25 }
200
+ typosquat_bonus = 15
201
+ tiers = { safe_max = 30, malicious_min = 70 }
202
+
203
+ [cache]
204
+ db_path = "~/.skillsentry/skillsentry.db"
205
+ backend_strict = false
206
+
207
+ [ci]
208
+ fail_on = "malicious"
209
+ upload_sarif = false
210
+ """
211
+
212
+
213
+ def settings_dir() -> Path:
214
+ return Path.home() / APP_DIR_NAME
215
+
216
+
217
+ def reports_dir(scan_id: str | None = None) -> Path:
218
+ base = settings_dir() / "reports"
219
+ return base / scan_id if scan_id else base
220
+
221
+
222
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
223
+ merged = dict(base)
224
+ for key, value in override.items():
225
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
226
+ merged[key] = _deep_merge(merged[key], value)
227
+ else:
228
+ merged[key] = value
229
+ return merged
230
+
231
+
232
+ def _env_overrides() -> dict[str, Any]:
233
+ overrides: dict[str, Any] = {}
234
+
235
+ def set_value(section: str, key: str, value: Any) -> None:
236
+ overrides.setdefault(section, {})[key] = value
237
+
238
+ if backend := os.getenv("SKILLSENTRY_BACKEND"):
239
+ set_value("model", "backend", backend)
240
+ if decision_backend := os.getenv("SKILLSENTRY_DECISION_BACKEND"):
241
+ set_value("decision", "backend", decision_backend)
242
+ if checkpoint := os.getenv("SKILLSENTRY_LAYA_CHECKPOINT"):
243
+ set_value("decision", "checkpoint", checkpoint)
244
+ if device := os.getenv("SKILLSENTRY_LAYA_DEVICE"):
245
+ set_value("decision", "device", device)
246
+ if model_path := os.getenv("SKILLSENTRY_LAYA_MODEL_PATH"):
247
+ set_value("decision", "model_path", model_path)
248
+ if os.getenv("SKILLSENTRY_LAYA_REQUIRED") == "1":
249
+ set_value("decision", "required", True)
250
+ if os.getenv("SKILLSENTRY_NO_SANDBOX") == "1":
251
+ set_value("sandbox", "enabled", False)
252
+ if db_path := os.getenv("SKILLSENTRY_DB"):
253
+ set_value("cache", "db_path", db_path)
254
+ if ollama_host := os.getenv("OLLAMA_HOST"):
255
+ set_value("model", "ollama_url", ollama_host)
256
+ return overrides
257
+
258
+
259
+ def default_config() -> SkillsEntryConfig:
260
+ return SkillsEntryConfig()
261
+
262
+
263
+ def load_config(path: str | Path | None = None) -> SkillsEntryConfig:
264
+ """Load config from TOML, then apply environment overrides.
265
+
266
+ A missing file is not an error: defaults are used (except when the caller
267
+ explicitly passes a path, in which case a missing file raises).
268
+ """
269
+ # A path named by the environment is as explicit as one passed in: a typo must not
270
+ # silently fall back to defaults.
271
+ env_path = os.getenv("SKILLSENTRY_CONFIG")
272
+ explicit = path is not None or bool(env_path)
273
+ if path is None:
274
+ path = env_path or Path.cwd() / "skillsentry.toml"
275
+ config_path = Path(path).expanduser()
276
+
277
+ data: dict[str, Any] = {}
278
+ if config_path.exists():
279
+ try:
280
+ data = tomllib.loads(config_path.read_text(encoding="utf-8"))
281
+ except (OSError, tomllib.TOMLDecodeError) as exc:
282
+ raise ConfigError(f"could not read {config_path}: {exc}") from exc
283
+ elif explicit:
284
+ raise ConfigError(f"config file not found: {config_path}")
285
+
286
+ data = _deep_merge(data, _env_overrides())
287
+ try:
288
+ return SkillsEntryConfig.model_validate(data)
289
+ except Exception as exc: # pydantic ValidationError
290
+ raise ConfigError(f"invalid configuration in {config_path}: {exc}") from exc
291
+
292
+
293
+ def write_default_config(path: str | Path, *, force: bool = False) -> Path:
294
+ target = Path(path).expanduser()
295
+ if target.exists() and not force:
296
+ raise ConfigError(f"{target} already exists (use --force to overwrite)")
297
+ target.parent.mkdir(parents=True, exist_ok=True)
298
+ target.write_text(DEFAULT_CONFIG_TOML, encoding="utf-8")
299
+ return target