Skip to content

Commit b422915

Browse files
Brian KrafftCopilot
andcommitted
fix(3.1): address /8eyes findings — defaults, validation, type coercion, recursion
- F1: SkillVersion default minor=0 (was 1), from_dict default minor=0 - F2: Store._upsert() and _insert_analysis() call validate() before SQL - F3: Type coercion (int/str) in from_dict for SkillVersion, SkillRecord, ExecutionAnalysis with TypeError/ValueError → ValidationError - F4: Recursive validation: ExecutionAnalysis validates judgments/suggestions, SkillRecord guards lineage None check - F5: 16 hostile-input tests (wrong types, partial payloads, adversarial values) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 699764b commit b422915

3 files changed

Lines changed: 209 additions & 44 deletions

File tree

openspace/skill_engine/store.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
SkillOrigin,
3636
SkillRecord,
3737
SkillVisibility,
38+
ValidationError,
3839
)
3940

4041
logger = Logger.get_logger(__name__)
@@ -1089,6 +1090,12 @@ def _upsert(self, record: SkillRecord) -> None:
10891090
10901091
Called within a transaction holding ``self._mu``.
10911092
"""
1093+
try:
1094+
record.validate()
1095+
except ValidationError as exc:
1096+
raise ValidationError(
1097+
f"Cannot persist invalid SkillRecord '{record.skill_id}': {exc}"
1098+
) from exc
10921099
lin = record.lineage
10931100
# content_snapshot is Dict[str, str]; store as JSON text
10941101
snapshot_json = json.dumps(lin.content_snapshot, ensure_ascii=False)
@@ -1204,6 +1211,12 @@ def _insert_analysis(self, a: ExecutionAnalysis) -> int:
12041211
Returns:
12051212
int: The ``execution_analyses.id`` of the newly inserted row.
12061213
"""
1214+
try:
1215+
a.validate()
1216+
except ValidationError as exc:
1217+
raise ValidationError(
1218+
f"Cannot persist invalid ExecutionAnalysis '{a.task_id}': {exc}"
1219+
) from exc
12071220
cur = self._conn.execute(
12081221
"""
12091222
INSERT INTO execution_analyses (

openspace/skill_engine/types.py

Lines changed: 57 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class SkillVersion:
3030
"""
3131

3232
major: int = 0
33-
minor: int = 1
33+
minor: int = 0
3434
patch: int = 0
3535

3636
def __post_init__(self) -> None:
@@ -94,11 +94,14 @@ def to_dict(self) -> Dict[str, int]:
9494

9595
@classmethod
9696
def from_dict(cls, data: Dict[str, Any]) -> "SkillVersion":
97-
return cls(
98-
major=data.get("major", 0),
99-
minor=data.get("minor", 1),
100-
patch=data.get("patch", 0),
101-
)
97+
try:
98+
return cls(
99+
major=int(data.get("major", 0)),
100+
minor=int(data.get("minor", 0)),
101+
patch=int(data.get("patch", 0)),
102+
)
103+
except (TypeError, ValueError) as exc:
104+
raise ValidationError(f"Invalid SkillVersion data: {exc}") from exc
102105

103106

104107
class SkillCategory(str, Enum):
@@ -428,9 +431,13 @@ def suggestions_by_type(self, evo_type: EvolutionType) -> List[EvolutionSuggesti
428431
return [s for s in self.evolution_suggestions if s.evolution_type == evo_type]
429432

430433
def validate(self) -> None:
431-
"""Validate analysis fields."""
434+
"""Validate analysis fields and nested objects."""
432435
if not self.task_id:
433436
raise ValidationError("task_id must be non-empty")
437+
for j in self.skill_judgments:
438+
j.validate()
439+
for s in self.evolution_suggestions:
440+
s.validate()
434441

435442
def to_dict(self) -> Dict[str, Any]:
436443
return {
@@ -447,17 +454,20 @@ def to_dict(self) -> Dict[str, Any]:
447454

448455
@classmethod
449456
def from_dict(cls, data: Dict[str, Any]) -> "ExecutionAnalysis":
450-
return cls(
451-
task_id=data["task_id"],
452-
timestamp=datetime.fromisoformat(data["timestamp"]),
453-
task_completed=data.get("task_completed", False),
454-
execution_note=data.get("execution_note", ""),
455-
tool_issues=data.get("tool_issues", []),
456-
skill_judgments=[SkillJudgment.from_dict(j) for j in data.get("skill_judgments", [])],
457-
evolution_suggestions=[EvolutionSuggestion.from_dict(s) for s in data.get("evolution_suggestions", [])],
458-
analyzed_by=data.get("analyzed_by", ""),
459-
analyzed_at=(datetime.fromisoformat(data["analyzed_at"]) if data.get("analyzed_at") else datetime.now()),
460-
)
457+
try:
458+
return cls(
459+
task_id=str(data["task_id"]),
460+
timestamp=datetime.fromisoformat(data["timestamp"]),
461+
task_completed=data.get("task_completed", False),
462+
execution_note=data.get("execution_note", ""),
463+
tool_issues=data.get("tool_issues", []),
464+
skill_judgments=[SkillJudgment.from_dict(j) for j in data.get("skill_judgments", [])],
465+
evolution_suggestions=[EvolutionSuggestion.from_dict(s) for s in data.get("evolution_suggestions", [])],
466+
analyzed_by=data.get("analyzed_by", ""),
467+
analyzed_at=(datetime.fromisoformat(data["analyzed_at"]) if data.get("analyzed_at") else datetime.now()),
468+
)
469+
except (TypeError, ValueError) as exc:
470+
raise ValidationError(f"Invalid ExecutionAnalysis data: {exc}") from exc
461471

462472

463473
# Full skill profile (identity + lineage + deps + quality)
@@ -555,7 +565,8 @@ def validate(self) -> None:
555565
f"total_completions ({self.total_completions}) must not exceed "
556566
f"total_applied ({self.total_applied})"
557567
)
558-
self.lineage.validate()
568+
if self.lineage is not None:
569+
self.lineage.validate()
559570

560571
def to_dict(self) -> Dict[str, Any]:
561572
return {
@@ -582,30 +593,33 @@ def to_dict(self) -> Dict[str, Any]:
582593

583594
@classmethod
584595
def from_dict(cls, data: Dict[str, Any]) -> "SkillRecord":
585-
record = cls(
586-
skill_id=data["skill_id"],
587-
name=data["name"],
588-
description=data.get("description", ""),
589-
path=data.get("path", ""),
590-
is_active=data.get("is_active", True),
591-
category=SkillCategory(data["category"]) if data.get("category") else SkillCategory.WORKFLOW,
592-
tags=data.get("tags", []),
593-
visibility=(SkillVisibility(data["visibility"]) if data.get("visibility") else SkillVisibility.PRIVATE),
594-
creator_id=data.get("creator_id", ""),
595-
lineage=(
596-
SkillLineage.from_dict(data["lineage"])
597-
if data.get("lineage")
598-
else SkillLineage(origin=SkillOrigin.IMPORTED)
599-
),
600-
tool_dependencies=data.get("tool_dependencies", []),
601-
critical_tools=data.get("critical_tools", []),
602-
total_selections=data.get("total_selections", 0),
603-
total_applied=data.get("total_applied", 0),
604-
total_completions=data.get("total_completions", 0),
605-
total_fallbacks=data.get("total_fallbacks", 0),
606-
first_seen=(datetime.fromisoformat(data["first_seen"]) if data.get("first_seen") else datetime.now()),
607-
last_updated=(datetime.fromisoformat(data["last_updated"]) if data.get("last_updated") else datetime.now()),
608-
)
596+
try:
597+
record = cls(
598+
skill_id=str(data["skill_id"]),
599+
name=str(data["name"]),
600+
description=data.get("description", ""),
601+
path=data.get("path", ""),
602+
is_active=data.get("is_active", True),
603+
category=SkillCategory(data["category"]) if data.get("category") else SkillCategory.WORKFLOW,
604+
tags=data.get("tags", []),
605+
visibility=(SkillVisibility(data["visibility"]) if data.get("visibility") else SkillVisibility.PRIVATE),
606+
creator_id=data.get("creator_id", ""),
607+
lineage=(
608+
SkillLineage.from_dict(data["lineage"])
609+
if data.get("lineage")
610+
else SkillLineage(origin=SkillOrigin.IMPORTED)
611+
),
612+
tool_dependencies=data.get("tool_dependencies", []),
613+
critical_tools=data.get("critical_tools", []),
614+
total_selections=int(data.get("total_selections", 0)),
615+
total_applied=int(data.get("total_applied", 0)),
616+
total_completions=int(data.get("total_completions", 0)),
617+
total_fallbacks=int(data.get("total_fallbacks", 0)),
618+
first_seen=(datetime.fromisoformat(data["first_seen"]) if data.get("first_seen") else datetime.now()),
619+
last_updated=(datetime.fromisoformat(data["last_updated"]) if data.get("last_updated") else datetime.now()),
620+
)
621+
except (TypeError, ValueError) as exc:
622+
raise ValidationError(f"Invalid SkillRecord data: {exc}") from exc
609623
for a in data.get("recent_analyses", []):
610624
record.recent_analyses.append(ExecutionAnalysis.from_dict(a))
611625
return record

tests/test_skill_types.py

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class TestSkillVersion:
4343
def test_default_version(self):
4444
v = SkillVersion()
4545
assert v.major == 0
46-
assert v.minor == 1
46+
assert v.minor == 0
4747
assert v.patch == 0
4848

4949
def test_explicit_construction(self):
@@ -746,3 +746,141 @@ def test_datetime_round_trip_precision(self):
746746
restored = SkillLineage.from_dict(lin.to_dict())
747747
assert restored.created_at.year == 2025
748748
assert restored.created_at.second == 59
749+
750+
751+
# ═══════════════════════════════════════════════════════════════════════
752+
# Hostile / adversarial input tests (F5)
753+
# ═══════════════════════════════════════════════════════════════════════
754+
755+
756+
class TestFromDictHostileInput:
757+
"""from_dict() with wrong types, partial payloads, and adversarial values."""
758+
759+
# --- Wrong types: int where string expected ---
760+
761+
def test_skill_record_numeric_skill_id_coerced(self):
762+
"""Numeric skill_id should be coerced to str."""
763+
d = {"skill_id": 12345, "name": "test"}
764+
r = SkillRecord.from_dict(d)
765+
assert r.skill_id == "12345"
766+
assert isinstance(r.skill_id, str)
767+
768+
def test_skill_record_numeric_name_coerced(self):
769+
"""Numeric name should be coerced to str."""
770+
d = {"skill_id": "s1", "name": 42}
771+
r = SkillRecord.from_dict(d)
772+
assert r.name == "42"
773+
assert isinstance(r.name, str)
774+
775+
def test_execution_analysis_numeric_task_id_coerced(self):
776+
"""Numeric task_id should be coerced to str."""
777+
d = {"task_id": 999, "timestamp": "2025-01-01T00:00:00"}
778+
a = ExecutionAnalysis.from_dict(d)
779+
assert a.task_id == "999"
780+
assert isinstance(a.task_id, str)
781+
782+
def test_skill_version_string_components_coerced(self):
783+
"""String version components should be coerced to int."""
784+
d = {"major": "2", "minor": "3", "patch": "4"}
785+
v = SkillVersion.from_dict(d)
786+
assert v == SkillVersion(2, 3, 4)
787+
788+
def test_skill_record_string_counters_coerced(self):
789+
"""String counter values should be coerced to int."""
790+
d = {
791+
"skill_id": "s1",
792+
"name": "test",
793+
"total_selections": "10",
794+
"total_applied": "8",
795+
"total_completions": "7",
796+
"total_fallbacks": "1",
797+
}
798+
r = SkillRecord.from_dict(d)
799+
assert r.total_selections == 10
800+
assert isinstance(r.total_selections, int)
801+
assert r.total_applied == 8
802+
assert r.total_completions == 7
803+
assert r.total_fallbacks == 1
804+
805+
# --- Partial payloads: missing keys ---
806+
807+
def test_skill_version_from_dict_empty(self):
808+
"""Empty dict should yield 0.0.0."""
809+
v = SkillVersion.from_dict({})
810+
assert v == SkillVersion(0, 0, 0)
811+
812+
def test_skill_version_from_dict_major_only(self):
813+
"""Only major → minor and patch default to 0."""
814+
v = SkillVersion.from_dict({"major": 2})
815+
assert v == SkillVersion(2, 0, 0)
816+
817+
def test_skill_record_from_dict_minimal(self):
818+
"""Only required keys; all optional fields get safe defaults."""
819+
d = {"skill_id": "s1", "name": "n"}
820+
r = SkillRecord.from_dict(d)
821+
assert r.total_selections == 0
822+
assert r.total_applied == 0
823+
assert r.total_completions == 0
824+
assert r.total_fallbacks == 0
825+
assert r.description == ""
826+
assert r.is_active is True
827+
828+
def test_execution_analysis_from_dict_minimal(self):
829+
"""Only required keys for ExecutionAnalysis."""
830+
d = {"task_id": "t1", "timestamp": "2025-01-01T00:00:00"}
831+
a = ExecutionAnalysis.from_dict(d)
832+
assert a.task_id == "t1"
833+
assert a.skill_judgments == []
834+
assert a.evolution_suggestions == []
835+
836+
# --- Adversarial values ---
837+
838+
def test_skill_record_empty_string_skill_id(self):
839+
"""Empty string skill_id should be coerced to str, validation catches it."""
840+
d = {"skill_id": "", "name": "test"}
841+
r = SkillRecord.from_dict(d)
842+
assert r.skill_id == ""
843+
with pytest.raises(ValidationError, match="skill_id"):
844+
r.validate()
845+
846+
def test_skill_record_very_long_name(self):
847+
"""Very long name should still round-trip without crash."""
848+
long_name = "x" * 10_000
849+
d = {"skill_id": "s1", "name": long_name}
850+
r = SkillRecord.from_dict(d)
851+
assert r.name == long_name
852+
assert len(r.name) == 10_000
853+
854+
def test_skill_version_non_numeric_raises(self):
855+
"""Non-numeric version components should raise ValidationError."""
856+
with pytest.raises(ValidationError):
857+
SkillVersion.from_dict({"major": "abc"})
858+
859+
def test_skill_record_non_numeric_counter_raises(self):
860+
"""Non-numeric counter should raise ValidationError."""
861+
with pytest.raises(ValidationError):
862+
SkillRecord.from_dict({
863+
"skill_id": "s1",
864+
"name": "test",
865+
"total_selections": "not_a_number",
866+
})
867+
868+
# --- Type coercion correctness ---
869+
870+
def test_skill_version_float_coerced(self):
871+
"""Float version components should be truncated to int."""
872+
d = {"major": 1.9, "minor": 2.1, "patch": 0.0}
873+
v = SkillVersion.from_dict(d)
874+
assert v == SkillVersion(1, 2, 0)
875+
876+
def test_skill_record_float_counters_coerced(self):
877+
"""Float counter values should be truncated to int."""
878+
d = {
879+
"skill_id": "s1",
880+
"name": "test",
881+
"total_selections": 5.7,
882+
"total_applied": 3.2,
883+
}
884+
r = SkillRecord.from_dict(d)
885+
assert r.total_selections == 5
886+
assert r.total_applied == 3

0 commit comments

Comments
 (0)