skillpool 4.3.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.
- skillpool/__init__.py +74 -0
- skillpool/__main__.py +6 -0
- skillpool/adapters/__init__.py +8 -0
- skillpool/adapters/base.py +41 -0
- skillpool/adapters/claude_adapter.py +36 -0
- skillpool/adapters/codex_adapter.py +92 -0
- skillpool/adapters/hermes_adapter.py +38 -0
- skillpool/audit/__init__.py +651 -0
- skillpool/bridge/__init__.py +16 -0
- skillpool/bridge/freeze_detector.py +134 -0
- skillpool/bridge/maintenance.py +119 -0
- skillpool/bridge/wal_manager.py +136 -0
- skillpool/clawmem_client.py +176 -0
- skillpool/cli.py +700 -0
- skillpool/combiner/__init__.py +31 -0
- skillpool/combiner/lifecycle.py +453 -0
- skillpool/combiner/models.py +99 -0
- skillpool/config.py +34 -0
- skillpool/cost/__init__.py +111 -0
- skillpool/cost/audit_hash.py +51 -0
- skillpool/cost/budget_tracker.py +66 -0
- skillpool/cost/dashboard.py +189 -0
- skillpool/cost/models.py +129 -0
- skillpool/cost/token_governor.py +264 -0
- skillpool/cost/trace_ceiling.py +38 -0
- skillpool/csdf.py +126 -0
- skillpool/evolver/__init__.py +978 -0
- skillpool/gain/__init__.py +285 -0
- skillpool/gate.py +282 -0
- skillpool/gate_policy/__init__.py +31 -0
- skillpool/gate_policy/incremental.py +157 -0
- skillpool/gate_policy/parser.py +258 -0
- skillpool/gate_policy/state_machine.py +432 -0
- skillpool/graph/__init__.py +14 -0
- skillpool/graph/ppr.py +279 -0
- skillpool/health/__init__.py +73 -0
- skillpool/health/check.py +85 -0
- skillpool/health/degradation.py +90 -0
- skillpool/health/models.py +43 -0
- skillpool/hooks/__init__.py +4 -0
- skillpool/hooks/security_scanner.py +288 -0
- skillpool/lifecycle.py +150 -0
- skillpool/materializer/__init__.py +124 -0
- skillpool/materializer/budget_cropper.py +178 -0
- skillpool/materializer/csdf_loader.py +114 -0
- skillpool/materializer/lazy_loader.py +265 -0
- skillpool/materializer/lifecycle_filter.py +93 -0
- skillpool/materializer/mapper.py +178 -0
- skillpool/materializer/models.py +66 -0
- skillpool/mcp_server.py +2005 -0
- skillpool/monitor/__init__.py +576 -0
- skillpool/monitor/bug_collector.py +392 -0
- skillpool/monitor/defect_classifier.py +218 -0
- skillpool/monitor/self_healing.py +530 -0
- skillpool/monitor/telemetry_bridge.py +197 -0
- skillpool/paradigm/__init__.py +312 -0
- skillpool/paradigm/override.py +285 -0
- skillpool/profile.py +94 -0
- skillpool/quality.py +254 -0
- skillpool/registry/__init__.py +509 -0
- skillpool/registry/models.py +98 -0
- skillpool/resolver/__init__.py +320 -0
- skillpool/resolver/cache.py +103 -0
- skillpool/resolver/circuit_breaker.py +103 -0
- skillpool/resolver/conflict_detector.py +111 -0
- skillpool/resolver/health_filter.py +38 -0
- skillpool/resolver/models.py +154 -0
- skillpool/resolver/rate_limiter.py +48 -0
- skillpool/resolver/skill_graph.py +183 -0
- skillpool/review/__init__.py +242 -0
- skillpool/review/async_queue.py +96 -0
- skillpool/review/checkpoint_runner.py +345 -0
- skillpool/review/models.py +164 -0
- skillpool/review/suspect_marker.py +39 -0
- skillpool/review/veto_evaluator.py +94 -0
- skillpool/router/__init__.py +481 -0
- skillpool/schemas.py +119 -0
- skillpool/synergy/__init__.py +240 -0
- skillpool/synergy/detector.py +5 -0
- skillpool/telemetry.py +126 -0
- skillpool/utils/__init__.py +21 -0
- skillpool/utils/changelog.py +218 -0
- skillpool/utils/logger.py +273 -0
- skillpool/utils/runtime_audit.py +163 -0
- skillpool/utils/time_utils.py +13 -0
- skillpool-4.3.0.dist-info/METADATA +21 -0
- skillpool-4.3.0.dist-info/RECORD +90 -0
- skillpool-4.3.0.dist-info/WHEEL +5 -0
- skillpool-4.3.0.dist-info/entry_points.txt +3 -0
- skillpool-4.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""Registry Layer — Skill metadata and version truth source.
|
|
2
|
+
|
|
3
|
+
Architecture constraint:
|
|
4
|
+
- Registry MUST NOT execute skills
|
|
5
|
+
- Registry stores metadata, versions, state, signatures, SBOM, licenses
|
|
6
|
+
- State transitions require Audit record
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AuditUnavailableError",
|
|
13
|
+
"IllegalStateTransitionError",
|
|
14
|
+
"PolicyDeniedError",
|
|
15
|
+
"ProblemDetail",
|
|
16
|
+
"Registry",
|
|
17
|
+
"SandboxRequiredError",
|
|
18
|
+
"SkillNotFoundError",
|
|
19
|
+
"SkillRecord",
|
|
20
|
+
"SupplyChainEvidenceMissingError",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import sqlite3
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from skillpool.registry.models import (
|
|
32
|
+
ProblemDetail,
|
|
33
|
+
RegisterSkillRequest,
|
|
34
|
+
RegisterSkillResponse,
|
|
35
|
+
SkillMetadata,
|
|
36
|
+
SkillStatus,
|
|
37
|
+
StateTransitionRequest,
|
|
38
|
+
StateTransitionResponse,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class SkillRecord:
|
|
46
|
+
"""Internal skill record in Registry."""
|
|
47
|
+
|
|
48
|
+
metadata: SkillMetadata
|
|
49
|
+
created_at: datetime
|
|
50
|
+
updated_at: datetime
|
|
51
|
+
evidence: set[str] = field(default_factory=set)
|
|
52
|
+
audit_refs: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
"""Serialize SkillRecord to dict for JSON persistence."""
|
|
56
|
+
return {
|
|
57
|
+
"metadata": {
|
|
58
|
+
"skill_id": self.metadata.skill_id,
|
|
59
|
+
"name": self.metadata.name,
|
|
60
|
+
"version": self.metadata.version,
|
|
61
|
+
"status": self.metadata.status.value,
|
|
62
|
+
"description": self.metadata.description,
|
|
63
|
+
"author": self.metadata.author,
|
|
64
|
+
"created_at": self.metadata.created_at,
|
|
65
|
+
"updated_at": self.metadata.updated_at,
|
|
66
|
+
"tags": self.metadata.tags,
|
|
67
|
+
"dependencies": self.metadata.dependencies,
|
|
68
|
+
"security": self.metadata.security,
|
|
69
|
+
"quality_score": self.metadata.quality_score,
|
|
70
|
+
},
|
|
71
|
+
"created_at": self.created_at.isoformat(),
|
|
72
|
+
"updated_at": self.updated_at.isoformat(),
|
|
73
|
+
"evidence": sorted(self.evidence),
|
|
74
|
+
"audit_refs": list(self.audit_refs),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_dict(cls, data: dict) -> SkillRecord:
|
|
79
|
+
"""Deserialize SkillRecord from dict."""
|
|
80
|
+
meta = data.get("metadata", {})
|
|
81
|
+
if isinstance(meta, dict):
|
|
82
|
+
status_val = meta.get("status", "draft")
|
|
83
|
+
try:
|
|
84
|
+
status_enum = SkillStatus(status_val)
|
|
85
|
+
except ValueError:
|
|
86
|
+
status_enum = SkillStatus.DRAFT
|
|
87
|
+
meta_obj = SkillMetadata(
|
|
88
|
+
skill_id=meta.get("skill_id", ""),
|
|
89
|
+
name=meta.get("name", ""),
|
|
90
|
+
version=meta.get("version", ""),
|
|
91
|
+
status=status_enum,
|
|
92
|
+
description=meta.get("description", ""),
|
|
93
|
+
author=meta.get("author", ""),
|
|
94
|
+
created_at=meta.get("created_at", ""),
|
|
95
|
+
updated_at=meta.get("updated_at", ""),
|
|
96
|
+
tags=meta.get("tags", []),
|
|
97
|
+
dependencies=meta.get("dependencies", []),
|
|
98
|
+
security=meta.get("security", {}),
|
|
99
|
+
quality_score=meta.get("quality_score", 0.0),
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
meta_obj = meta
|
|
103
|
+
return cls(
|
|
104
|
+
metadata=meta_obj,
|
|
105
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
106
|
+
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
107
|
+
evidence=set(data.get("evidence", [])),
|
|
108
|
+
audit_refs=data.get("audit_refs", []),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Legal state transitions
|
|
113
|
+
LEGAL_TRANSITIONS = {
|
|
114
|
+
("draft", "imported"),
|
|
115
|
+
("imported", "testing"),
|
|
116
|
+
("testing", "enabled"),
|
|
117
|
+
("testing", "disabled"),
|
|
118
|
+
("enabled", "disabled"),
|
|
119
|
+
("enabled", "deprecated"),
|
|
120
|
+
("deprecated", "disabled"),
|
|
121
|
+
("disabled", "testing"),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Illegal transitions
|
|
125
|
+
ILLEGAL_TRANSITIONS = {
|
|
126
|
+
("draft", "enabled"),
|
|
127
|
+
("imported", "enabled"),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Required evidence per environment (SLSA-aligned)
|
|
131
|
+
# dev=L0 (no requirements), ci=L1 (source pin + SBOM), prod=L2+ (full evidence)
|
|
132
|
+
SUPPLY_CHAIN_PROFILES = {
|
|
133
|
+
"dev": set(), # SLSA Build L0 — no requirements for local development
|
|
134
|
+
"ci": {"source pin", "SPDX SBOM"}, # SLSA Build L1 — provenance exists
|
|
135
|
+
"prod": {"SPDX SBOM", "SLSA provenance", "source pin", "signature"}, # SLSA Build L2+
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Default: production-level evidence (backward compatible)
|
|
139
|
+
REQUIRED_EVIDENCE = SUPPLY_CHAIN_PROFILES["prod"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class AuditUnavailableError(Exception):
|
|
143
|
+
"""Audit unavailable — fail closed."""
|
|
144
|
+
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class SupplyChainEvidenceMissingError(Exception):
|
|
149
|
+
"""Missing SPDX/SLSA/signature evidence."""
|
|
150
|
+
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class IllegalStateTransitionError(Exception):
|
|
155
|
+
"""Illegal lifecycle transition."""
|
|
156
|
+
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SkillNotFoundError(Exception):
|
|
161
|
+
"""Skill not found in Registry."""
|
|
162
|
+
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class SandboxRequiredError(Exception):
|
|
167
|
+
"""Sandbox pass required."""
|
|
168
|
+
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class PolicyDeniedError(Exception):
|
|
173
|
+
"""Policy approval denied."""
|
|
174
|
+
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Registry:
|
|
179
|
+
"""
|
|
180
|
+
Registry layer — skill metadata and lifecycle governance.
|
|
181
|
+
|
|
182
|
+
Hard rules:
|
|
183
|
+
- Requires SPDX SBOM, SLSA provenance, source pin, signature
|
|
184
|
+
- Audit must be available for all mutations
|
|
185
|
+
- State transitions follow legal paths only
|
|
186
|
+
|
|
187
|
+
Lookup supports both skill_id (e.g., "S09") and name (e.g., "resilience-degradation").
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def __init__(self, audit_layer, registry_path: str | None = None) -> None:
|
|
191
|
+
self._skills: dict[str, SkillRecord] = {}
|
|
192
|
+
self._by_name: dict[str, str] = {} # name -> skill_id index for dual lookup
|
|
193
|
+
self._audit = audit_layer
|
|
194
|
+
self._registry_path = Path(registry_path) if registry_path else None
|
|
195
|
+
self._evidence_profile: str = os.environ.get("SKILLPOOL_EVIDENCE_TIER", "prod")
|
|
196
|
+
self._required_evidence: set[str] = SUPPLY_CHAIN_PROFILES.get(
|
|
197
|
+
self._evidence_profile, SUPPLY_CHAIN_PROFILES["prod"]
|
|
198
|
+
)
|
|
199
|
+
self._load()
|
|
200
|
+
|
|
201
|
+
def _load(self) -> None:
|
|
202
|
+
"""Load registry from persistent storage if path is configured.
|
|
203
|
+
|
|
204
|
+
Supports three formats:
|
|
205
|
+
- .db / .sqlite → SQLite backend (preferred for production)
|
|
206
|
+
- .jsonl → JSONL format (one record per line, legacy)
|
|
207
|
+
- other → JSON object format (single dict, legacy)
|
|
208
|
+
"""
|
|
209
|
+
if not self._registry_path:
|
|
210
|
+
return
|
|
211
|
+
if not self._registry_path.exists():
|
|
212
|
+
return
|
|
213
|
+
suffix = self._registry_path.suffix.lower()
|
|
214
|
+
try:
|
|
215
|
+
if suffix in (".db", ".sqlite"):
|
|
216
|
+
self._load_sqlite()
|
|
217
|
+
else:
|
|
218
|
+
self._load_json()
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
logger.warning("Registry load failed from %s: %s", self._registry_path, exc)
|
|
221
|
+
|
|
222
|
+
def _load_sqlite(self) -> None:
|
|
223
|
+
"""Load skills from SQLite database."""
|
|
224
|
+
conn = sqlite3.connect(str(self._registry_path))
|
|
225
|
+
try:
|
|
226
|
+
conn.execute("CREATE TABLE IF NOT EXISTS skills (skill_id TEXT PRIMARY KEY, data TEXT NOT NULL)")
|
|
227
|
+
for row in conn.execute("SELECT skill_id, data FROM skills"):
|
|
228
|
+
rec = SkillRecord.from_dict(json.loads(row[1]))
|
|
229
|
+
self._skills[row[0]] = rec
|
|
230
|
+
self._by_name[rec.metadata.name] = row[0]
|
|
231
|
+
finally:
|
|
232
|
+
conn.close()
|
|
233
|
+
|
|
234
|
+
def _load_json(self) -> None:
|
|
235
|
+
"""Load skills from JSON/JSONL file."""
|
|
236
|
+
content = self._registry_path.read_text(encoding="utf-8").strip()
|
|
237
|
+
if not content:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
# Try JSON object format first (standard Registry format)
|
|
241
|
+
try:
|
|
242
|
+
data = json.loads(content)
|
|
243
|
+
if isinstance(data, dict):
|
|
244
|
+
for sid, sdata in data.items():
|
|
245
|
+
rec = SkillRecord.from_dict(sdata)
|
|
246
|
+
self._skills[sid] = rec
|
|
247
|
+
self._by_name[rec.metadata.name] = sid
|
|
248
|
+
return
|
|
249
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
# Try JSONL format (one record per line)
|
|
253
|
+
for line in content.splitlines():
|
|
254
|
+
line = line.strip()
|
|
255
|
+
if not line:
|
|
256
|
+
continue
|
|
257
|
+
try:
|
|
258
|
+
rec = SkillRecord.from_dict(json.loads(line))
|
|
259
|
+
self._skills[rec.metadata.skill_id] = rec
|
|
260
|
+
self._by_name[rec.metadata.name] = rec.metadata.skill_id
|
|
261
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
def _save(self) -> None:
|
|
265
|
+
"""Persist registry to disk if path is configured."""
|
|
266
|
+
if not self._registry_path:
|
|
267
|
+
return
|
|
268
|
+
suffix = self._registry_path.suffix.lower()
|
|
269
|
+
try:
|
|
270
|
+
if suffix in (".db", ".sqlite"):
|
|
271
|
+
self._save_sqlite()
|
|
272
|
+
else:
|
|
273
|
+
self._save_json()
|
|
274
|
+
except OSError as exc:
|
|
275
|
+
logger.warning("Registry save failed to %s: %s", self._registry_path, exc)
|
|
276
|
+
|
|
277
|
+
def _save_sqlite(self) -> None:
|
|
278
|
+
"""Persist skills to SQLite database."""
|
|
279
|
+
self._registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
conn = sqlite3.connect(str(self._registry_path))
|
|
281
|
+
try:
|
|
282
|
+
conn.execute("CREATE TABLE IF NOT EXISTS skills (skill_id TEXT PRIMARY KEY, data TEXT NOT NULL)")
|
|
283
|
+
# Upsert all records in a single transaction
|
|
284
|
+
conn.execute("DELETE FROM skills")
|
|
285
|
+
for sid, rec in self._skills.items():
|
|
286
|
+
conn.execute(
|
|
287
|
+
"INSERT INTO skills (skill_id, data) VALUES (?, ?)",
|
|
288
|
+
(sid, json.dumps(rec.to_dict(), ensure_ascii=False)),
|
|
289
|
+
)
|
|
290
|
+
conn.commit()
|
|
291
|
+
finally:
|
|
292
|
+
conn.close()
|
|
293
|
+
|
|
294
|
+
def _save_json(self) -> None:
|
|
295
|
+
"""Persist skills to JSON file (legacy format)."""
|
|
296
|
+
self._registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
data = {sid: rec.to_dict() for sid, rec in self._skills.items()}
|
|
298
|
+
self._registry_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
299
|
+
|
|
300
|
+
def _check_audit_available(self) -> bool:
|
|
301
|
+
"""Check if Audit layer is available."""
|
|
302
|
+
return self._audit.is_available()
|
|
303
|
+
|
|
304
|
+
def register_candidate(
|
|
305
|
+
self,
|
|
306
|
+
request: RegisterSkillRequest,
|
|
307
|
+
) -> RegisterSkillResponse:
|
|
308
|
+
"""
|
|
309
|
+
Register a skill candidate into testing state.
|
|
310
|
+
|
|
311
|
+
Prerequisites:
|
|
312
|
+
- SPDX SBOM present
|
|
313
|
+
- SLSA provenance present
|
|
314
|
+
- Source pin present
|
|
315
|
+
- Signature present
|
|
316
|
+
- Audit available
|
|
317
|
+
|
|
318
|
+
Returns skill in 'testing' state, NOT production routable.
|
|
319
|
+
"""
|
|
320
|
+
skill_id = request.skill_metadata.skill_id
|
|
321
|
+
|
|
322
|
+
if not self._check_audit_available():
|
|
323
|
+
raise AuditUnavailableError("Audit unavailable - cannot register skill")
|
|
324
|
+
|
|
325
|
+
security = request.skill_metadata.security
|
|
326
|
+
evidence = set()
|
|
327
|
+
|
|
328
|
+
if security.get("sbom_ref") or security.get("sbom"):
|
|
329
|
+
evidence.add("SPDX SBOM")
|
|
330
|
+
if security.get("provenance_ref") or security.get("provenance"):
|
|
331
|
+
evidence.add("SLSA provenance")
|
|
332
|
+
if security.get("source_pin") or security.get("source_ref") or security.get("source"):
|
|
333
|
+
evidence.add("source pin")
|
|
334
|
+
if security.get("signature_ref") or security.get("signature") or security.get("digest"):
|
|
335
|
+
evidence.add("signature")
|
|
336
|
+
|
|
337
|
+
missing = self._required_evidence - evidence
|
|
338
|
+
if missing:
|
|
339
|
+
raise SupplyChainEvidenceMissingError(
|
|
340
|
+
f"Missing required evidence: {missing}. "
|
|
341
|
+
f"Provided fields: {list(security.keys())}. "
|
|
342
|
+
f"Current profile: {self._evidence_profile} "
|
|
343
|
+
f"(set SKILLPOOL_EVIDENCE_TIER=dev to relax)"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
now = datetime.now(UTC)
|
|
347
|
+
record = SkillRecord(
|
|
348
|
+
metadata=request.skill_metadata,
|
|
349
|
+
created_at=now,
|
|
350
|
+
updated_at=now,
|
|
351
|
+
evidence=evidence,
|
|
352
|
+
)
|
|
353
|
+
record.metadata.status = SkillStatus.TESTING
|
|
354
|
+
|
|
355
|
+
audit_ref = self._audit.append(
|
|
356
|
+
action="register_skill_candidate",
|
|
357
|
+
object_id=skill_id,
|
|
358
|
+
result="success",
|
|
359
|
+
)
|
|
360
|
+
record.audit_refs.append(audit_ref)
|
|
361
|
+
|
|
362
|
+
self._skills[skill_id] = record
|
|
363
|
+
self._by_name[request.skill_metadata.name] = skill_id
|
|
364
|
+
self._save()
|
|
365
|
+
|
|
366
|
+
return RegisterSkillResponse(
|
|
367
|
+
context=request.context,
|
|
368
|
+
skill_id=skill_id,
|
|
369
|
+
status="testing",
|
|
370
|
+
audit_ref=audit_ref,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def transition_state(
|
|
374
|
+
self,
|
|
375
|
+
skill_id: str,
|
|
376
|
+
request: StateTransitionRequest,
|
|
377
|
+
sandbox_result: str | None = None,
|
|
378
|
+
policy_approval: bool = False,
|
|
379
|
+
) -> StateTransitionResponse:
|
|
380
|
+
"""
|
|
381
|
+
Transition skill state with Audit fail-closed and prerequisites.
|
|
382
|
+
|
|
383
|
+
Prerequisites for 'enabled':
|
|
384
|
+
- Sandbox L1, L2, L3 pass
|
|
385
|
+
- Policy approval exists
|
|
386
|
+
- Audit available
|
|
387
|
+
"""
|
|
388
|
+
if not self._check_audit_available():
|
|
389
|
+
raise AuditUnavailableError("Audit unavailable - cannot transition state")
|
|
390
|
+
|
|
391
|
+
record = self._skills.get(skill_id)
|
|
392
|
+
if not record:
|
|
393
|
+
raise SkillNotFoundError(f"Skill not found: {skill_id}")
|
|
394
|
+
|
|
395
|
+
from_status = request.from_status.value
|
|
396
|
+
to_status = request.to_status.value
|
|
397
|
+
|
|
398
|
+
if record.metadata.status.value != from_status:
|
|
399
|
+
raise IllegalStateTransitionError(
|
|
400
|
+
f"Current state {record.metadata.status.value} != requested {from_status}"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if (from_status, to_status) in ILLEGAL_TRANSITIONS:
|
|
404
|
+
self._audit.append(
|
|
405
|
+
action="illegal_state_transition",
|
|
406
|
+
object_id=skill_id,
|
|
407
|
+
result="denied",
|
|
408
|
+
)
|
|
409
|
+
raise IllegalStateTransitionError(f"Illegal transition: {from_status} -> {to_status}")
|
|
410
|
+
|
|
411
|
+
if (from_status, to_status) not in LEGAL_TRANSITIONS:
|
|
412
|
+
self._audit.append(
|
|
413
|
+
action="illegal_state_transition",
|
|
414
|
+
object_id=skill_id,
|
|
415
|
+
result="denied",
|
|
416
|
+
)
|
|
417
|
+
raise IllegalStateTransitionError(f"Unknown transition: {from_status} -> {to_status}")
|
|
418
|
+
|
|
419
|
+
if to_status == "enabled":
|
|
420
|
+
if sandbox_result != "pass":
|
|
421
|
+
raise SandboxRequiredError("Sandbox pass required for enabled state")
|
|
422
|
+
if not policy_approval:
|
|
423
|
+
raise PolicyDeniedError("Policy approval required for enabled state")
|
|
424
|
+
|
|
425
|
+
record.metadata.status = SkillStatus(to_status)
|
|
426
|
+
record.updated_at = datetime.now(UTC)
|
|
427
|
+
|
|
428
|
+
audit_ref = self._audit.append(
|
|
429
|
+
action="transition_skill_state",
|
|
430
|
+
object_id=skill_id,
|
|
431
|
+
result="success",
|
|
432
|
+
)
|
|
433
|
+
record.audit_refs.append(audit_ref)
|
|
434
|
+
self._save()
|
|
435
|
+
|
|
436
|
+
return StateTransitionResponse(
|
|
437
|
+
context=request.context,
|
|
438
|
+
skill_id=skill_id,
|
|
439
|
+
from_status=from_status,
|
|
440
|
+
to_status=to_status,
|
|
441
|
+
audit_ref=audit_ref,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def get_skill(self, skill_id: str) -> SkillRecord | None:
|
|
445
|
+
"""Get skill metadata from Registry truth source.
|
|
446
|
+
|
|
447
|
+
Supports lookup by skill_id (e.g., "S09") or name (e.g., "resilience-degradation").
|
|
448
|
+
"""
|
|
449
|
+
# Try by skill_id first
|
|
450
|
+
record = self._skills.get(skill_id)
|
|
451
|
+
if record is not None:
|
|
452
|
+
return record
|
|
453
|
+
|
|
454
|
+
# Try by name index
|
|
455
|
+
mapped_id = self._by_name.get(skill_id)
|
|
456
|
+
if mapped_id is not None:
|
|
457
|
+
return self._skills.get(mapped_id)
|
|
458
|
+
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
def is_enabled(self, skill_id: str) -> bool:
|
|
462
|
+
"""Check if skill version is routable by Execution Engine."""
|
|
463
|
+
record = self._skills.get(skill_id)
|
|
464
|
+
return record is not None and record.metadata.status == SkillStatus.ENABLED
|
|
465
|
+
|
|
466
|
+
def get_supply_chain_evidence(self, skill_id: str) -> dict | None:
|
|
467
|
+
"""Get supply chain evidence for a skill.
|
|
468
|
+
|
|
469
|
+
Returns dict with: skill_id, evidence (set), missing, is_complete.
|
|
470
|
+
"""
|
|
471
|
+
record = self._skills.get(skill_id)
|
|
472
|
+
if not record:
|
|
473
|
+
return None
|
|
474
|
+
missing = self._required_evidence - record.evidence
|
|
475
|
+
return {
|
|
476
|
+
"skill_id": skill_id,
|
|
477
|
+
"evidence": sorted(record.evidence),
|
|
478
|
+
"missing": sorted(missing),
|
|
479
|
+
"is_complete": len(missing) == 0,
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
def verify_evidence_integrity(self, skill_id: str) -> list[str]:
|
|
483
|
+
"""Verify supply chain evidence integrity for a skill.
|
|
484
|
+
|
|
485
|
+
Returns list of issues found (empty = all valid).
|
|
486
|
+
"""
|
|
487
|
+
record = self._skills.get(skill_id)
|
|
488
|
+
if not record:
|
|
489
|
+
return [f"Skill not found: {skill_id}"]
|
|
490
|
+
|
|
491
|
+
issues = []
|
|
492
|
+
missing = self._required_evidence - record.evidence
|
|
493
|
+
if missing:
|
|
494
|
+
issues.append(f"Missing evidence: {sorted(missing)}")
|
|
495
|
+
|
|
496
|
+
# Check security metadata fields
|
|
497
|
+
security = record.metadata.security
|
|
498
|
+
if not security.get("sbom_ref") and not security.get("sbom"):
|
|
499
|
+
if "SPDX SBOM" in record.evidence:
|
|
500
|
+
issues.append("SBOM evidence claimed but no sbom_ref/sbom field")
|
|
501
|
+
if not security.get("signature_ref") and not security.get("signature") and not security.get("digest"):
|
|
502
|
+
if "signature" in record.evidence:
|
|
503
|
+
issues.append("Signature evidence claimed but no signature field")
|
|
504
|
+
|
|
505
|
+
return issues
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# Backward-compatible alias
|
|
509
|
+
SkillRegistry = Registry
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Registry models — Skill metadata and lifecycle state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ProblemDetail",
|
|
7
|
+
"RegisterSkillRequest",
|
|
8
|
+
"RegisterSkillResponse",
|
|
9
|
+
"SkillMetadata",
|
|
10
|
+
"SkillStatus",
|
|
11
|
+
"StateTransitionRequest",
|
|
12
|
+
"StateTransitionResponse",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SkillStatus(StrEnum):
|
|
21
|
+
"""Skill lifecycle states (9-state model)."""
|
|
22
|
+
|
|
23
|
+
DRAFT = "draft"
|
|
24
|
+
IMPORTED = "imported"
|
|
25
|
+
TESTING = "testing"
|
|
26
|
+
ENABLED = "enabled"
|
|
27
|
+
DISABLED = "disabled"
|
|
28
|
+
DEPRECATED = "deprecated"
|
|
29
|
+
ARCHIVED = "archived"
|
|
30
|
+
REJECTED = "rejected"
|
|
31
|
+
QUARANTINED = "quarantined"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class SkillMetadata:
|
|
36
|
+
"""Skill metadata stored in Registry."""
|
|
37
|
+
|
|
38
|
+
skill_id: str
|
|
39
|
+
name: str
|
|
40
|
+
version: str
|
|
41
|
+
status: SkillStatus = SkillStatus.DRAFT
|
|
42
|
+
description: str = ""
|
|
43
|
+
author: str = ""
|
|
44
|
+
created_at: str = ""
|
|
45
|
+
updated_at: str = ""
|
|
46
|
+
tags: list[str] = field(default_factory=list)
|
|
47
|
+
dependencies: list[str] = field(default_factory=list)
|
|
48
|
+
security: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
quality_score: float = 0.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ProblemDetail:
|
|
54
|
+
"""RFC 7807 Problem Detail for error responses."""
|
|
55
|
+
|
|
56
|
+
type: str
|
|
57
|
+
title: str
|
|
58
|
+
status: int
|
|
59
|
+
detail: str = ""
|
|
60
|
+
instance: str = ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class RegisterSkillRequest:
|
|
65
|
+
"""Request to register a skill candidate."""
|
|
66
|
+
|
|
67
|
+
skill_metadata: SkillMetadata
|
|
68
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class RegisterSkillResponse:
|
|
73
|
+
"""Response from skill registration."""
|
|
74
|
+
|
|
75
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
skill_id: str = ""
|
|
77
|
+
status: str = "testing"
|
|
78
|
+
audit_ref: str = ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class StateTransitionRequest:
|
|
83
|
+
"""Request to transition skill state."""
|
|
84
|
+
|
|
85
|
+
from_status: SkillStatus
|
|
86
|
+
to_status: SkillStatus
|
|
87
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class StateTransitionResponse:
|
|
92
|
+
"""Response from state transition."""
|
|
93
|
+
|
|
94
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
95
|
+
skill_id: str = ""
|
|
96
|
+
from_status: str = ""
|
|
97
|
+
to_status: str = ""
|
|
98
|
+
audit_ref: str = ""
|