Skip to content

Commit e31cfde

Browse files
author
Brian Krafft
committed
feat(skill-engine): add ReviewGate with AST safety, content, and lineage checks - 27 tests
1 parent 0400f21 commit e31cfde

2 files changed

Lines changed: 609 additions & 0 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
"""Eight-Eyes Review Gate for skill evolution.
2+
3+
Intercepts skill activation after evolution and runs multiple review
4+
checks before allowing the skill to become active. Skills that fail
5+
review are quarantined (deactivated) until manually approved.
6+
7+
Checks:
8+
1. AST Safety — scans Python files for dangerous patterns
9+
2. Content — validates required fields (name, description, SKILL.md)
10+
3. Lineage — validates parent chain integrity for evolved skills
11+
12+
Usage::
13+
14+
from openspace.skill_engine.review_gate import ReviewGate, quarantine_skill
15+
16+
gate = ReviewGate()
17+
result = gate.review(evolved_record)
18+
if not result.passed:
19+
await quarantine_skill(store, evolved_record.skill_id, result)
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import logging
25+
from dataclasses import dataclass, field
26+
from typing import Callable, List, Optional
27+
28+
from openspace.skill_engine.types import SkillOrigin, SkillRecord
29+
30+
logger = logging.getLogger(__name__)
31+
32+
33+
@dataclass
34+
class CheckResult:
35+
"""Result of a single review check."""
36+
37+
name: str
38+
verdict: str # "pass" | "fail"
39+
detail: str = ""
40+
41+
42+
@dataclass
43+
class ReviewResult:
44+
"""Aggregated result of all review checks."""
45+
46+
verdict: str # "pass" | "fail" | "quarantine"
47+
checks: List[CheckResult] = field(default_factory=list)
48+
49+
@property
50+
def passed(self) -> bool:
51+
return self.verdict == "pass"
52+
53+
@classmethod
54+
def from_checks(cls, checks: List[CheckResult]) -> ReviewResult:
55+
"""Aggregate individual check results into a final verdict."""
56+
any_fail = any(c.verdict == "fail" for c in checks)
57+
return cls(
58+
verdict="fail" if any_fail else "pass",
59+
checks=list(checks),
60+
)
61+
62+
63+
# ======================================================================
64+
# Individual review checks
65+
# ======================================================================
66+
67+
68+
def check_ast_safety(record: SkillRecord) -> CheckResult:
69+
"""Scan Python files in the skill's content snapshot for dangerous patterns.
70+
71+
Uses the existing AST scanner from openspace.security.
72+
"""
73+
snapshot = record.lineage.content_snapshot or {}
74+
py_files = {k: v for k, v in snapshot.items() if k.endswith(".py")}
75+
76+
if not py_files:
77+
return CheckResult(name="ast-safety", verdict="pass", detail="no Python files")
78+
79+
try:
80+
from openspace.security import check_code_safety
81+
except ImportError:
82+
return CheckResult(
83+
name="ast-safety",
84+
verdict="pass",
85+
detail="AST scanner not available (skipped)",
86+
)
87+
88+
violations = []
89+
for filename, source in py_files.items():
90+
is_safe, findings = check_code_safety(source)
91+
if not is_safe:
92+
descs = [f.description for f in findings]
93+
violations.append(f"{filename}: {'; '.join(descs) or 'unsafe'}")
94+
95+
if violations:
96+
return CheckResult(
97+
name="ast-safety",
98+
verdict="fail",
99+
detail=f"Dangerous code detected: {'; '.join(violations)}",
100+
)
101+
return CheckResult(name="ast-safety", verdict="pass", detail="all files clean")
102+
103+
104+
def check_content(record: SkillRecord) -> CheckResult:
105+
"""Validate skill has required content fields."""
106+
issues = []
107+
108+
if not record.name or not record.name.strip():
109+
issues.append("skill name is empty")
110+
111+
if not record.description or not record.description.strip():
112+
issues.append("skill description is empty")
113+
114+
snapshot = record.lineage.content_snapshot or {}
115+
if not snapshot or "SKILL.md" not in snapshot:
116+
issues.append("SKILL.md missing from content snapshot")
117+
118+
if issues:
119+
return CheckResult(
120+
name="content",
121+
verdict="fail",
122+
detail=f"Content validation failed: {'; '.join(issues)}",
123+
)
124+
return CheckResult(name="content", verdict="pass", detail="all required fields present")
125+
126+
127+
def check_lineage(record: SkillRecord) -> CheckResult:
128+
"""Validate lineage integrity for evolved skills.
129+
130+
Imported/discovered skills skip lineage validation.
131+
Evolved (FIXED/DERIVED/CAPTURED) skills must have valid parent chain.
132+
"""
133+
origin = record.lineage.origin
134+
135+
# Non-evolved skills don't need lineage validation
136+
if origin in (SkillOrigin.IMPORTED, SkillOrigin.CAPTURED):
137+
return CheckResult(
138+
name="lineage", verdict="pass", detail="non-evolved skill (skipped)"
139+
)
140+
141+
# Evolved skills must have parent(s)
142+
if not record.lineage.parent_skill_ids:
143+
return CheckResult(
144+
name="lineage",
145+
verdict="fail",
146+
detail=f"Evolved skill ({origin.value}) has no parent_skill_ids",
147+
)
148+
149+
# Evolved skills must have generation > 0
150+
if record.lineage.generation < 1:
151+
return CheckResult(
152+
name="lineage",
153+
verdict="fail",
154+
detail=f"Evolved skill ({origin.value}) has generation={record.lineage.generation}, expected >= 1",
155+
)
156+
157+
return CheckResult(
158+
name="lineage",
159+
verdict="pass",
160+
detail=f"valid {origin.value} chain (gen={record.lineage.generation})",
161+
)
162+
163+
164+
# ======================================================================
165+
# ReviewGate — orchestrates all checks
166+
# ======================================================================
167+
168+
169+
class ReviewGate:
170+
"""Runs multi-check review on evolved skills before activation.
171+
172+
Each check is independent — the gate runs ALL checks even if earlier
173+
ones fail, so the developer gets a complete picture of issues.
174+
"""
175+
176+
def __init__(self, checks: Optional[List[Callable]] = None) -> None:
177+
self._checks = checks or [
178+
check_ast_safety,
179+
check_content,
180+
check_lineage,
181+
]
182+
183+
def review(self, record: SkillRecord) -> ReviewResult:
184+
"""Run all review checks on a skill record.
185+
186+
Returns a ReviewResult with the aggregate verdict and per-check details.
187+
Always runs ALL checks — does not short-circuit on first failure.
188+
"""
189+
results = []
190+
for check_fn in self._checks:
191+
try:
192+
result = check_fn(record)
193+
results.append(result)
194+
except Exception as exc:
195+
results.append(
196+
CheckResult(
197+
name=getattr(check_fn, "__name__", "unknown"),
198+
verdict="fail",
199+
detail=f"Check raised exception: {type(exc).__name__}",
200+
)
201+
)
202+
203+
return ReviewResult.from_checks(results)
204+
205+
206+
# ======================================================================
207+
# Quarantine — deactivate skills that fail review
208+
# ======================================================================
209+
210+
211+
async def quarantine_skill(
212+
store,
213+
skill_id: str,
214+
review_result: ReviewResult,
215+
) -> bool:
216+
"""Quarantine a skill that failed review by deactivating it.
217+
218+
Args:
219+
store: SkillStore instance with deactivate_record()
220+
skill_id: ID of the skill to quarantine
221+
review_result: The review result (quarantine only if failed)
222+
223+
Returns:
224+
True if skill was quarantined, False if review passed (no action).
225+
"""
226+
if review_result.passed:
227+
return False
228+
229+
failed_checks = [c for c in review_result.checks if c.verdict == "fail"]
230+
check_names = ", ".join(c.name for c in failed_checks)
231+
232+
logger.warning(
233+
"Quarantining skill %s — failed checks: %s",
234+
skill_id,
235+
check_names,
236+
)
237+
for check in failed_checks:
238+
logger.warning(" [%s] %s", check.name, check.detail)
239+
240+
await store.deactivate_record(skill_id)
241+
return True

0 commit comments

Comments
 (0)