Skip to content

Commit e9cde83

Browse files
Pigbibicodex
andcommitted
fix: harden declared theme integrity
Co-Authored-By: Codex <noreply@openai.com>
1 parent e54123a commit e9cde83

2 files changed

Lines changed: 55 additions & 7 deletions

File tree

‎src/research_signal_context_pipelines/schema.py‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import datetime as dt
44
import hashlib
55
import json
6+
import re
67
from collections.abc import Mapping, Sequence
78
from pathlib import Path
89
from typing import Any
@@ -161,6 +162,7 @@ def validate_latest_signal(
161162
payload: Mapping[str, Any],
162163
*,
163164
theme_artifact_path: str | Path | None = None,
165+
signal_base_dir: str | Path | None = None,
164166
) -> None:
165167
"""Validate a v2 signal against its declared theme artifact linkage."""
166168
validate_signal(payload)
@@ -173,8 +175,18 @@ def validate_latest_signal(
173175
if len(theme_sources) != 1:
174176
raise SignalValidationError("latest signal must declare exactly one theme source")
175177
declared_source = theme_sources[0]
176-
declared_path = Path(declared_source).resolve()
177-
artifact_path = Path(theme_artifact_path).resolve() if theme_artifact_path else declared_path
178+
base_dir = Path(signal_base_dir).resolve() if signal_base_dir else None
179+
180+
def resolve_source(value: str | Path) -> Path:
181+
path = Path(value)
182+
if path.is_absolute():
183+
return path.resolve()
184+
if base_dir is None:
185+
raise SignalValidationError("relative theme source requires signal_base_dir")
186+
return (base_dir / path).resolve()
187+
188+
declared_path = resolve_source(declared_source)
189+
artifact_path = resolve_source(theme_artifact_path) if theme_artifact_path else declared_path
178190
if artifact_path != declared_path:
179191
raise SignalValidationError("theme artifact override must match the declared theme source")
180192
if not artifact_path.exists():
@@ -185,11 +197,21 @@ def validate_latest_signal(
185197
source_hashes = {}
186198
if not isinstance(source_hashes, Mapping):
187199
raise SignalValidationError("evidence.source_hashes must be an object")
188-
expected_hash = source_hashes.get(declared_source) or source_hashes.get(str(declared_path))
189-
if expected_hash is not None:
190-
_require_string(expected_hash, "evidence.source_hashes value")
191-
digest = hashlib.sha256(artifact_path.read_bytes()).hexdigest()
192-
if digest != expected_hash:
200+
if declared_source in source_hashes:
201+
expected_hash = source_hashes[declared_source]
202+
elif str(declared_path) in source_hashes:
203+
expected_hash = source_hashes[str(declared_path)]
204+
else:
205+
expected_hash = None
206+
if expected_hash is not None or declared_source in source_hashes or str(declared_path) in source_hashes:
207+
expected_hash = _require_string(expected_hash, "evidence.source_hashes value")
208+
if not re.fullmatch(r"[0-9a-fA-F]{64}", expected_hash):
209+
raise SignalValidationError("evidence.source_hashes value must be a SHA-256 hex digest")
210+
try:
211+
digest = hashlib.sha256(artifact_path.read_bytes()).hexdigest()
212+
except OSError as exc:
213+
raise SignalValidationError(f"declared theme artifact unreadable: {artifact_path}") from exc
214+
if digest != expected_hash.lower():
193215
raise SignalValidationError("declared theme artifact sha256 does not match")
194216

195217
try:

‎tests/test_signal_validation.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,32 @@ def test_strict_latest_rejects_linked_theme_as_of_mismatch(tmp_path) -> None:
104104
validate_latest_signal(payload, theme_artifact_path=theme_path)
105105

106106

107+
def test_strict_latest_rejects_present_but_invalid_source_hash(tmp_path) -> None:
108+
theme_path = tmp_path / "theme_momentum_snapshot.json"
109+
theme_path.write_text(json.dumps(_theme_snapshot()), encoding="utf-8")
110+
payload = _strict_signal(source=str(theme_path))
111+
payload["evidence"]["source_hashes"] = {str(theme_path): ""}
112+
113+
with pytest.raises(SignalValidationError, match="source_hashes"):
114+
validate_latest_signal(payload, theme_artifact_path=theme_path)
115+
116+
117+
def test_strict_latest_resolves_relative_source_from_signal_base_dir(tmp_path) -> None:
118+
theme_path = tmp_path / "theme_momentum_snapshot.json"
119+
theme_path.write_text(json.dumps(_theme_snapshot()), encoding="utf-8")
120+
relative_source = "theme_momentum_snapshot.json"
121+
payload = _strict_signal(
122+
source=relative_source,
123+
source_sha256=hashlib.sha256(theme_path.read_bytes()).hexdigest(),
124+
)
125+
126+
validate_latest_signal(
127+
payload,
128+
theme_artifact_path=relative_source,
129+
signal_base_dir=tmp_path,
130+
)
131+
132+
107133
def test_signal_requires_long_horizon_contract() -> None:
108134
payload = load_example()
109135
payload["horizon"] = "1-3 months"

0 commit comments

Comments
 (0)