Skip to content

Commit 9d4abdb

Browse files
committed
feat(validation): add hardened plan executor
1 parent 36d1687 commit 9d4abdb

3 files changed

Lines changed: 925 additions & 0 deletions

File tree

src/openenv/validation/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
"""Versioned OpenEnv validation contracts and Harbor manifest loading."""
44

5+
from .executor import execute_validation_plan
56
from .models import (
67
CheckOutcome,
78
RunnerCapabilities,
@@ -52,5 +53,6 @@
5253
"ValidationSeverity",
5354
"ValidationStatus",
5455
"build_validation_plan",
56+
"execute_validation_plan",
5557
"load_task_manifest",
5658
]

src/openenv/validation/executor.py

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
# SPDX-License-Identifier: BSD-3-Clause
2+
3+
"""Capability-aware executor for shared validation plans."""
4+
5+
from __future__ import annotations
6+
7+
import json
8+
import math
9+
import re
10+
import signal
11+
import threading
12+
import time
13+
from contextlib import contextmanager
14+
from datetime import datetime, timezone
15+
from enum import Enum
16+
from pathlib import Path
17+
from typing import Any, Iterator, Mapping
18+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
19+
20+
from .models import (
21+
CheckOutcome,
22+
ValidationContext,
23+
ValidationPlan,
24+
ValidationProfile,
25+
ValidationReport,
26+
ValidationResult,
27+
ValidationSeverity,
28+
ValidationStatus,
29+
)
30+
from .planner import is_canonical_policy_plan
31+
32+
33+
_SECRET_KEY = re.compile(
34+
r"(?i)(authorization|cookie|credential|password|private.?key|secret|token|api.?key)"
35+
)
36+
_SECRET_VALUE_PATTERNS = (
37+
re.compile(r"(?i)(bearer\s+)[^\s,;]+"),
38+
re.compile(r"\b(?:hf_|sk-|github_pat_)[A-Za-z0-9_-]{8,}\b", re.IGNORECASE),
39+
)
40+
_MAX_EVIDENCE_DEPTH = 32
41+
_MAX_EVIDENCE_NODES = 10_000
42+
_MAX_EVIDENCE_STRING_LENGTH = 1_000_000
43+
44+
45+
class _CheckTimedOut(BaseException):
46+
pass
47+
48+
49+
class _TimeoutContextUnsupported(BaseException):
50+
pass
51+
52+
53+
def _redact_string(value: str) -> str:
54+
redacted = value
55+
for pattern in _SECRET_VALUE_PATTERNS:
56+
redacted = pattern.sub(
57+
lambda match: (
58+
f"{match.group(1)}[REDACTED]" if match.lastindex else "[REDACTED]"
59+
),
60+
redacted,
61+
)
62+
try:
63+
parsed = urlsplit(redacted)
64+
hostname = parsed.hostname
65+
port = parsed.port
66+
except (UnicodeError, ValueError):
67+
if redacted.lower().startswith(("http://", "https://", "ws://", "wss://")):
68+
return "[REDACTED_INVALID_URL]"
69+
return redacted
70+
if parsed.scheme not in {"http", "https", "ws", "wss"} or not hostname:
71+
return redacted
72+
host = hostname
73+
if ":" in host and not host.startswith("["):
74+
host = f"[{host}]"
75+
if port is not None:
76+
host = f"{host}:{port}"
77+
query = []
78+
for key, item in parse_qsl(parsed.query, keep_blank_values=True):
79+
query.append((key, "[REDACTED]" if _SECRET_KEY.search(key) else item))
80+
return urlunsplit((parsed.scheme, host, parsed.path, urlencode(query), ""))
81+
82+
83+
def _json_safe(
84+
value: Any,
85+
*,
86+
key: str | None = None,
87+
seen: set[int] | None = None,
88+
depth: int = 0,
89+
budget: list[int] | None = None,
90+
) -> Any:
91+
if depth > _MAX_EVIDENCE_DEPTH:
92+
raise ValueError("Evidence exceeds the maximum nesting depth")
93+
if budget is None:
94+
budget = [_MAX_EVIDENCE_NODES]
95+
budget[0] -= 1
96+
if budget[0] < 0:
97+
raise ValueError("Evidence exceeds the maximum node count")
98+
if key is not None and _SECRET_KEY.search(key):
99+
return "[REDACTED]"
100+
if value is None or isinstance(value, (bool, int)):
101+
return value
102+
if isinstance(value, float):
103+
if not math.isfinite(value):
104+
raise ValueError("Evidence contains a non-finite float")
105+
return value
106+
if isinstance(value, str):
107+
if len(value) > _MAX_EVIDENCE_STRING_LENGTH:
108+
raise ValueError("Evidence string exceeds the maximum length")
109+
return _redact_string(value)
110+
if isinstance(value, Enum):
111+
return _json_safe(value.value, seen=seen, depth=depth + 1, budget=budget)
112+
if isinstance(value, Path):
113+
return _json_safe(str(value), seen=seen, depth=depth + 1, budget=budget)
114+
if seen is None:
115+
seen = set()
116+
container_id = id(value)
117+
if isinstance(value, (Mapping, list, tuple, set, frozenset)):
118+
if container_id in seen:
119+
raise ValueError("Evidence contains a reference cycle")
120+
seen.add(container_id)
121+
if isinstance(value, Mapping):
122+
items: list[tuple[str, Any]] = []
123+
for index, (item_key, item) in enumerate(value.items()):
124+
if index >= _MAX_EVIDENCE_NODES:
125+
raise ValueError("Evidence mapping exceeds the maximum size")
126+
if not isinstance(item_key, (str, int, float, bool)):
127+
raise ValueError("Evidence mapping contains an unsupported key")
128+
if isinstance(item_key, float) and not math.isfinite(item_key):
129+
raise ValueError("Evidence mapping contains a non-finite key")
130+
key_text = str(item_key)
131+
if len(key_text) > _MAX_EVIDENCE_STRING_LENGTH:
132+
raise ValueError("Evidence key exceeds the maximum length")
133+
items.append((key_text, item))
134+
items.sort(key=lambda pair: pair[0])
135+
result: dict[str, Any] = {}
136+
for item_key, item in items:
137+
if item_key in result:
138+
raise ValueError("Evidence mapping contains duplicate JSON keys")
139+
result[item_key] = _json_safe(
140+
item,
141+
key=item_key,
142+
seen=seen,
143+
depth=depth + 1,
144+
budget=budget,
145+
)
146+
seen.remove(container_id)
147+
return result
148+
if isinstance(value, (list, tuple)):
149+
result = [
150+
_json_safe(item, seen=seen, depth=depth + 1, budget=budget)
151+
for item in value
152+
]
153+
seen.remove(container_id)
154+
return result
155+
if isinstance(value, (set, frozenset)):
156+
converted = [
157+
_json_safe(item, seen=seen, depth=depth + 1, budget=budget)
158+
for item in value
159+
]
160+
seen.remove(container_id)
161+
return sorted(converted, key=lambda item: json.dumps(item, sort_keys=True))
162+
return {"type": type(value).__name__}
163+
164+
165+
@contextmanager
166+
def _timeout(seconds: float) -> Iterator[None]:
167+
supported = (
168+
hasattr(signal, "SIGALRM")
169+
and threading.current_thread() is threading.main_thread()
170+
)
171+
if not supported:
172+
raise _TimeoutContextUnsupported
173+
174+
previous_handler = signal.getsignal(signal.SIGALRM)
175+
previous_timer = signal.getitimer(signal.ITIMER_REAL)
176+
177+
def handle_timeout(_signum: int, _frame: Any) -> None:
178+
raise _CheckTimedOut
179+
180+
signal.signal(signal.SIGALRM, handle_timeout)
181+
signal.setitimer(signal.ITIMER_REAL, seconds)
182+
try:
183+
yield
184+
finally:
185+
signal.setitimer(signal.ITIMER_REAL, *previous_timer)
186+
signal.signal(signal.SIGALRM, previous_handler)
187+
188+
189+
def _execute_check(
190+
plan: ValidationPlan,
191+
context: ValidationContext,
192+
index: int,
193+
) -> ValidationResult:
194+
check = plan.checks[index]
195+
missing = check.capabilities - plan.capabilities.available
196+
if missing:
197+
return ValidationResult(
198+
criterion_id=check.criterion_id,
199+
requirement=check.requirement,
200+
status=ValidationStatus.SKIP,
201+
severity=check.severity,
202+
evidence={
203+
"reason": "runner_capability_unavailable",
204+
"required": sorted(
205+
capability.value for capability in check.capabilities
206+
),
207+
"missing": sorted(capability.value for capability in missing),
208+
"available": sorted(
209+
capability.value for capability in plan.capabilities.available
210+
),
211+
},
212+
duration_s=0.0,
213+
timeout_s=check.timeout_s,
214+
required_capabilities=check.capabilities,
215+
built_in=check.built_in,
216+
message="Runner does not provide the capabilities required by this check",
217+
)
218+
219+
started = time.perf_counter()
220+
if check.evaluator is None:
221+
return ValidationResult(
222+
criterion_id=check.criterion_id,
223+
requirement=check.requirement,
224+
status=ValidationStatus.ERROR,
225+
severity=check.severity,
226+
evidence={"reason": "missing_check_implementation"},
227+
duration_s=time.perf_counter() - started,
228+
timeout_s=check.timeout_s,
229+
required_capabilities=check.capabilities,
230+
built_in=check.built_in,
231+
message="No check implementation is registered",
232+
)
233+
234+
try:
235+
with _timeout(check.timeout_s):
236+
outcome = check.evaluator(context)
237+
if not isinstance(outcome, CheckOutcome):
238+
outcome = CheckOutcome.error(
239+
{"actual_type": type(outcome).__name__},
240+
message="Check implementation returned an invalid outcome",
241+
)
242+
elif (
243+
not isinstance(outcome.status, ValidationStatus)
244+
or not isinstance(outcome.evidence, Mapping)
245+
or (outcome.message is not None and not isinstance(outcome.message, str))
246+
):
247+
outcome = CheckOutcome.error(
248+
{"reason": "invalid_outcome_shape"},
249+
message="Check implementation returned malformed status, evidence, or message",
250+
)
251+
elif time.perf_counter() - started > check.timeout_s:
252+
outcome = CheckOutcome.error(
253+
{"reason": "timeout", "timeout_s": check.timeout_s},
254+
message=f"Check exceeded its {check.timeout_s:g}s timeout",
255+
)
256+
except _CheckTimedOut:
257+
outcome = CheckOutcome.error(
258+
{"reason": "timeout", "timeout_s": check.timeout_s},
259+
message=f"Check exceeded its {check.timeout_s:g}s timeout",
260+
)
261+
except _TimeoutContextUnsupported:
262+
outcome = CheckOutcome.error(
263+
{
264+
"reason": "timeout_context_unsupported",
265+
"timeout_s": check.timeout_s,
266+
},
267+
message=(
268+
"Timed checks must run on the POSIX main thread or in a "
269+
"runner-managed worker process"
270+
),
271+
)
272+
except Exception as exc:
273+
outcome = CheckOutcome.error(
274+
{"reason": "uncaught_exception", "error_type": type(exc).__name__},
275+
message="Check implementation raised an unexpected exception",
276+
)
277+
278+
try:
279+
evidence = _json_safe(outcome.evidence)
280+
except Exception as exc:
281+
outcome = CheckOutcome.error(
282+
{
283+
"reason": "invalid_evidence",
284+
"error_type": type(exc).__name__,
285+
},
286+
message="Check evidence is not safe to serialize",
287+
)
288+
evidence = _json_safe(outcome.evidence)
289+
290+
return ValidationResult(
291+
criterion_id=check.criterion_id,
292+
requirement=check.requirement,
293+
status=outcome.status,
294+
# Severity is policy, not runner evidence. A subject or lane must not
295+
# downgrade a canonical blocking criterion through CheckOutcome.
296+
severity=check.severity,
297+
evidence=evidence,
298+
duration_s=time.perf_counter() - started,
299+
timeout_s=check.timeout_s,
300+
required_capabilities=check.capabilities,
301+
built_in=check.built_in,
302+
message=(
303+
_redact_string(outcome.message) if outcome.message is not None else None
304+
),
305+
)
306+
307+
308+
def execute_validation_plan(
309+
plan: ValidationPlan, context: ValidationContext
310+
) -> ValidationReport:
311+
"""Execute every planned check and preserve partial results on errors."""
312+
started_at = datetime.now(timezone.utc)
313+
started = time.perf_counter()
314+
results = tuple(
315+
_execute_check(plan, context, index) for index in range(len(plan.checks))
316+
)
317+
finished_at = datetime.now(timezone.utc)
318+
319+
blocking_skips = any(
320+
result.severity is ValidationSeverity.BLOCKING
321+
and result.status is ValidationStatus.SKIP
322+
for result in results
323+
)
324+
all_blocking_pass = not any(
325+
result.severity is ValidationSeverity.BLOCKING
326+
and result.status is not ValidationStatus.PASS
327+
for result in results
328+
)
329+
policy_complete = bool(
330+
plan.profile is ValidationProfile.FULL
331+
and is_canonical_policy_plan(plan, context)
332+
)
333+
repo_sha_valid = bool(
334+
context.repo_sha and re.fullmatch(r"[0-9a-fA-F]{40,64}", context.repo_sha)
335+
)
336+
image_digest_valid = bool(
337+
context.image_digest
338+
and re.fullmatch(r"sha256:[0-9a-fA-F]{64}", context.image_digest)
339+
)
340+
certification_eligible = bool(
341+
policy_complete
342+
and plan.capabilities.official
343+
and plan.capabilities.isolation_mode == "dedicated"
344+
and context.discovered.get("runner_attestation_verified") is True
345+
and repo_sha_valid
346+
and image_digest_valid
347+
and not blocking_skips
348+
and all_blocking_pass
349+
)
350+
return ValidationReport(
351+
target=(
352+
_redact_string(plan.target)
353+
if isinstance(plan.target, str)
354+
else "[INVALID_TARGET]"
355+
),
356+
profile=plan.profile,
357+
policy_version=plan.policy_version,
358+
runner=plan.capabilities,
359+
results=results,
360+
duration_s=time.perf_counter() - started,
361+
started_at=started_at.isoformat(),
362+
finished_at=finished_at.isoformat(),
363+
repo_sha=context.repo_sha,
364+
image_digest=context.image_digest,
365+
# Certification is a property of the signed registry envelope produced
366+
# after this immutable payload exists. An executor cannot verify a
367+
# signature over a report it has not produced yet.
368+
certified=False,
369+
certification_eligible=certification_eligible,
370+
)

0 commit comments

Comments
 (0)