|
| 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