Skip to content

Commit 9876b36

Browse files
PhysShellclaude
andcommitted
feat(s2-step11): green — bounded child runner + strict validation + bind_delta (H4)
One shared bounded runner (_run_child) drives both bind and every probe attempt: it enforces the fixed 30s timeout, caps stdout/stderr at 65536 bytes WHILE the child runs, and kills the direct child (no descendant-containment claim) on timeout or overflow. The Python parent now strictly validates the canonical binding-result.json (exact keys/types, non-negative derived ordinal, per-callsite identity == resolved wrapper, a sorted total bijection onto the converted ids with distinct postimage spans; malformed -> INFRASTRUCTURE, non-bijective -> CALLSITE_BINDING), the probe-result resolved-wrapper field formats (int ordinal, lowercase sha256:, 0x token, GUID mvid), and the exact runtime-unsupported schema before accepting child exit 10 (else INFRASTRUCTURE). bind_delta rejects unknown top-level keys and validates the exact frozen shapes it consumes, so a malformed Step 10 delta is DELTA_BINDING, never a KeyError. Tier A 73/73, Tier B 52/52; the frozen Step 10 producer/schema is untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G
1 parent 97db6e3 commit 9876b36

1 file changed

Lines changed: 226 additions & 33 deletions

File tree

‎ownlang/fix_target.py‎

Lines changed: 226 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222

2323
import json
2424
import os
25+
import re
2526
import shutil
2627
import stat
2728
import subprocess
2829
import tempfile
30+
import threading
2931
from typing import Any, cast
3032

3133
from ownlang.fix_delta import (
@@ -121,6 +123,42 @@ def load_authority(plan_bytes: bytes, candidates_bytes: bytes) -> tuple[Any, Any
121123
return auth, plan, candidates
122124

123125

126+
_DELTA_TOP_KEYS = frozenset({
127+
"schema", "operation", "status", "analysis_scope", "input_hashes", "gate_binding",
128+
"toolchain_fingerprint", "reference_closure", "target_api", "expected", "baseline",
129+
"postimage", "delta", "semantic_idempotence", "checks",
130+
})
131+
_DELTA_IH_KEYS = ("input_bundle_sha256", "validated_plan_sha256", "candidates_sha256",
132+
"apply_manifest_sha256", "patch_sha256", "pre_sha256", "post_sha256")
133+
_DELTA_RID_KEYS = ("framework_name", "tfm", "requested_framework_version",
134+
"selected_framework_version", "runtime_manifest_sha256")
135+
136+
137+
def _bind_delta_shapes(d: dict[str, Any], cat: str) -> None:
138+
"""Validate the exact frozen shapes of the Step 10 delta fields Step 11 consumes, so a
139+
malformed/missing field is DELTA_BINDING rather than a KeyError/INFRASTRUCTURE (H4)."""
140+
ih = d["input_hashes"]
141+
if not isinstance(ih, dict) or not all(isinstance(ih.get(k), str) for k in _DELTA_IH_KEYS):
142+
raise TargetError(cat, "delta-result.json input_hashes shape is wrong")
143+
scope = d["analysis_scope"]
144+
if not isinstance(scope, dict) or not isinstance(scope.get("source_file"), str) \
145+
or not isinstance(scope.get("target_file_identity"), str):
146+
raise TargetError(cat, "delta-result.json analysis_scope shape is wrong")
147+
tfp = d["toolchain_fingerprint"]
148+
rid = tfp.get("resolved_runtime_identity") if isinstance(tfp, dict) else None
149+
if not isinstance(rid, dict) or not all(isinstance(rid.get(k), str) for k in _DELTA_RID_KEYS):
150+
raise TargetError(cat, "delta-result.json resolved_runtime_identity shape is wrong")
151+
tapi = d["target_api"]
152+
if not isinstance(tapi, dict) or not isinstance(tapi.get("subscribe"), str):
153+
raise TargetError(cat, "delta-result.json target_api shape is wrong")
154+
exp = d["expected"]
155+
if not isinstance(exp, dict) or not isinstance(exp.get("convert_acquire_ids"), list) \
156+
or not isinstance(exp.get("manual_review_ids"), list):
157+
raise TargetError(cat, "delta-result.json expected shape is wrong")
158+
if not isinstance(d["reference_closure"], list):
159+
raise TargetError(cat, "delta-result.json reference_closure shape is wrong")
160+
161+
124162
def bind_delta(delta_bytes: bytes, auth: Any, plan_bytes: bytes,
125163
candidates_bytes: bytes) -> dict[str, Any]:
126164
"""Bind the Step 10 delta-result.json as the upstream authority (canonical bytes, exact
@@ -134,6 +172,9 @@ def bind_delta(delta_bytes: bytes, auth: Any, plan_bytes: bytes,
134172
raise TargetError(cat, f"delta-result.json is not valid JSON ({exc})") from exc
135173
if _canonical_bytes(d) != delta_bytes:
136174
raise TargetError(cat, "delta-result.json is not canonical bytes")
175+
if not isinstance(d, dict) or set(d) != _DELTA_TOP_KEYS:
176+
raise TargetError(cat, "delta-result.json has unknown or missing top-level keys")
177+
_bind_delta_shapes(d, cat)
137178
if d.get("schema") != 1 or d.get("operation") != "verify-subscription-analyzer-delta" \
138179
or d.get("status") != "pass":
139180
raise TargetError(cat, "delta-result.json schema/operation/status is wrong")
@@ -268,7 +309,6 @@ def resolve_probe_runtime(dll_dst: str, dotnet_host: str,
268309
def _peel_handler(handler: str) -> str:
269310
"""The frozen Step 8 handler peel + whitespace normalization, mirrored for bind-params:
270311
`new H(M)` / `new(M)` -> M, then collapse whitespace."""
271-
import re
272312
s = handler.strip()
273313
while True:
274314
m = re.fullmatch(r"new\s+[^\s(]+\s*\(\s*(.*)\s*\)", s) or re.fullmatch(
@@ -303,9 +343,63 @@ def build_bind_params(candidates: Any, convert_ids: list[str], rel: str) -> dict
303343
14: WRAPPER_RUNTIME_UNSUPPORTED}
304344

305345

346+
def _run_child(argv: list[str], cwd: str, env: dict[str, str],
347+
timeout: int) -> tuple[int, bytes, bytes, str | None]:
348+
"""One shared bounded runner for the bind and probe children (H4). It enforces the fixed
349+
timeout, caps each of stdout/stderr at _OUT_LIMIT bytes WHILE the child runs, kills the DIRECT
350+
child on timeout or overflow (no descendant-containment claim), and returns bounded diagnostics.
351+
Returns (returncode, stdout, stderr, reason) where reason is None on a clean exit, else
352+
'timeout' / 'stdout_overflow' / 'stderr_overflow'."""
353+
proc = subprocess.Popen(argv, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
354+
bufs = {"stdout": bytearray(), "stderr": bytearray()}
355+
over: dict[str, str | None] = {"which": None}
356+
lock = threading.Lock()
357+
358+
def pump(name: str, pipe: Any) -> None:
359+
try:
360+
while True:
361+
chunk = pipe.read(4096)
362+
if not chunk:
363+
return
364+
with lock:
365+
buf = bufs[name]
366+
room = _OUT_LIMIT - len(buf)
367+
if room > 0:
368+
buf.extend(chunk[:room])
369+
if len(chunk) > room:
370+
over["which"] = over["which"] or name
371+
try:
372+
proc.kill()
373+
except OSError:
374+
pass
375+
return
376+
except (OSError, ValueError):
377+
return
378+
379+
threads = [threading.Thread(target=pump, args=(n, p), daemon=True)
380+
for n, p in (("stdout", proc.stdout), ("stderr", proc.stderr))]
381+
for t in threads:
382+
t.start()
383+
reason: str | None = None
384+
try:
385+
proc.wait(timeout=timeout)
386+
except subprocess.TimeoutExpired:
387+
reason = "timeout"
388+
try:
389+
proc.kill()
390+
except OSError:
391+
pass
392+
proc.wait()
393+
for t in threads:
394+
t.join(2)
395+
if reason is None and over["which"]:
396+
reason = f"{over['which']}_overflow"
397+
return proc.returncode, bytes(bufs["stdout"]), bytes(bufs["stderr"]), reason
398+
399+
306400
def run_bind(work: str, dotnet_host: str, probe_dll: str, selected_ver: str, rel: str,
307-
preimage: str, postimage: str, slots_dir: str, target: str,
308-
selected_class: str, bind_params: dict[str, Any]) -> dict[str, Any]:
401+
preimage: str, postimage: str, slots_dir: str, target: str, selected_class: str,
402+
bind_params: dict[str, Any], convert_ids: list[str]) -> dict[str, Any]:
309403
core = os.path.join(work, "bind")
310404
os.makedirs(core, exist_ok=True)
311405
pre_path = os.path.join(core, "pre.cs")
@@ -322,24 +416,92 @@ def run_bind(work: str, dotnet_host: str, probe_dll: str, selected_ver: str, rel
322416
probe_dll, "bind", "--preimage", pre_path, "--postimage", post_path,
323417
"--slots-dir", slots_dir, "--target", target, "--selected-class", selected_class,
324418
"--source-file", rel, "--bind-params", params_path, "--out", out_path]
325-
proc = subprocess.run(argv, cwd=core, env=_probe_env(work, core),
326-
capture_output=True, text=True, check=False)
327-
if proc.returncode in _BIND_EXIT:
328-
raise TargetError(_BIND_EXIT[proc.returncode], f"bind: {proc.stderr.strip()[:300]}")
329-
if proc.returncode != 0:
330-
raise TargetError(INFRASTRUCTURE, f"bind failed (rc={proc.returncode}): "
331-
f"{proc.stderr.strip()[:300]}")
419+
rc, _out, err, reason = _run_child(argv, core, _probe_env(work, core), _CHILD_TIMEOUT_SECONDS)
420+
if reason is not None:
421+
raise TargetError(INFRASTRUCTURE, f"bind {reason}")
422+
err_text = err.decode("utf-8", "replace").strip()[:300]
423+
if rc in _BIND_EXIT:
424+
raise TargetError(_BIND_EXIT[rc], f"bind: {err_text}")
425+
if rc != 0:
426+
raise TargetError(INFRASTRUCTURE, f"bind failed (rc={rc}): {err_text}")
332427
try:
333428
with open(out_path, "rb") as fh:
334429
raw = fh.read()
335-
binding = json.loads(raw)
336-
except (OSError, ValueError) as exc:
430+
except OSError as exc:
337431
raise TargetError(INFRASTRUCTURE, f"binding-result.json unreadable ({exc})") from exc
432+
if len(raw) > _OUT_LIMIT:
433+
raise TargetError(INFRASTRUCTURE, "binding-result.json too large")
434+
try:
435+
binding = json.loads(raw)
436+
except ValueError as exc:
437+
raise TargetError(INFRASTRUCTURE, f"binding-result.json is not valid JSON ({exc})") from exc
338438
if _canonical_bytes(binding) != raw:
339439
raise TargetError(INFRASTRUCTURE, "binding-result.json is not canonical bytes")
440+
_validate_binding_result(binding, convert_ids)
340441
return cast("dict[str, Any]", binding)
341442

342443

444+
_BINDING_KEYS = ("version", "operation", "converted_callsites", "derived_wrapper_ordinal",
445+
"resolved_wrapper", "callsite_binding", "callsites")
446+
_BINDING_RW_KEYS = ("assembly_simple_name", "module_mvid", "metadata_token", "resolved_signature")
447+
_BINDING_CB_KEYS = ("all_callsites_same_symbol", "target_is_source_defined")
448+
_BINDING_CS_KEYS = ("finding_id", "preimage_span", "postimage_span", "assembly_simple_name",
449+
"module_mvid", "metadata_token", "resolved_signature")
450+
451+
452+
def _is_int(x: Any) -> bool:
453+
return isinstance(x, int) and not isinstance(x, bool)
454+
455+
456+
def _validate_binding_result(obj: Any, convert_ids: list[str]) -> None:
457+
"""Strict canonical binding-result.json validation (H4). A malformed shape/type is
458+
INFRASTRUCTURE; a well-formed but semantically incomplete / non-bijective binding is
459+
CALLSITE_BINDING."""
460+
inf = INFRASTRUCTURE
461+
if not isinstance(obj, dict) or set(obj) != set(_BINDING_KEYS):
462+
raise TargetError(inf, "binding-result.json is not the exact schema")
463+
if obj["version"] != 1 or obj["operation"] != "weak-target-bind":
464+
raise TargetError(inf, "binding-result.json version/operation wrong")
465+
if not _is_int(obj["converted_callsites"]):
466+
raise TargetError(inf, "converted_callsites must be an int")
467+
if not _is_int(obj["derived_wrapper_ordinal"]) or obj["derived_wrapper_ordinal"] < 0:
468+
raise TargetError(inf, "derived_wrapper_ordinal must be a non-negative int")
469+
rw = obj["resolved_wrapper"]
470+
if not isinstance(rw, dict) or set(rw) != set(_BINDING_RW_KEYS) \
471+
or not all(isinstance(rw[k], str) for k in _BINDING_RW_KEYS):
472+
raise TargetError(inf, "resolved_wrapper is not the exact schema")
473+
cb = obj["callsite_binding"]
474+
if not isinstance(cb, dict) or set(cb) != set(_BINDING_CB_KEYS) \
475+
or not all(isinstance(cb[k], bool) for k in _BINDING_CB_KEYS):
476+
raise TargetError(inf, "callsite_binding is not the exact schema")
477+
cs = obj["callsites"]
478+
if not isinstance(cs, list):
479+
raise TargetError(inf, "callsites must be a list")
480+
fids, spans = [], []
481+
for c in cs:
482+
if not isinstance(c, dict) or set(c) != set(_BINDING_CS_KEYS):
483+
raise TargetError(inf, "a callsite is not the exact schema")
484+
if not isinstance(c["finding_id"], str):
485+
raise TargetError(inf, "callsite finding_id must be a string")
486+
for sk in ("preimage_span", "postimage_span"):
487+
sp = c[sk]
488+
if not isinstance(sp, list) or len(sp) != 2 \
489+
or not all(_is_int(v) and v >= 0 for v in sp):
490+
raise TargetError(inf, f"callsite {sk} must be two non-negative ints")
491+
if any(c[k] != rw[k] for k in _BINDING_RW_KEYS):
492+
raise TargetError(inf, "a callsite identity does not equal resolved_wrapper")
493+
fids.append(c["finding_id"])
494+
spans.append((c["postimage_span"][0], c["postimage_span"][1]))
495+
if fids != sorted(fids):
496+
raise TargetError(inf, "callsites are not sorted by finding_id")
497+
want = set(convert_ids)
498+
if obj["converted_callsites"] != len(want) or len(cs) != len(want) \
499+
or set(fids) != want or len(set(fids)) != len(fids):
500+
raise TargetError(CALLSITE_BINDING, "callsites are not a bijection onto the converted ids")
501+
if len(set(spans)) != len(spans):
502+
raise TargetError(CALLSITE_BINDING, "two callsites share a postimage span")
503+
504+
343505
def _probe_env(work: str, cwd_dir: str) -> dict[str, str]:
344506
env: dict[str, str] = {}
345507
for k in ("SystemRoot", "SYSTEMROOT", "windir", "PATH", "LANG", "LC_ALL"):
@@ -377,29 +539,49 @@ def run_probe_attempt(work: str, dotnet_host: str, probe_dll: str, selected_ver:
377539
probe_dll, "probe", "--wrapper-ordinal", str(wrapper_ordinal),
378540
"--slots-dir", slots_dir, "--runtime-dir", runtime_dir, "--attempt", str(attempt),
379541
"--target", target, "--out", out_path]
380-
try:
381-
proc = subprocess.run(argv, cwd=os.path.join(work, "probe"), env=_probe_env(work, adir),
382-
capture_output=True, timeout=_CHILD_TIMEOUT_SECONDS, check=False)
383-
except subprocess.TimeoutExpired:
384-
raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} timed out") from None
385-
if len(proc.stdout) > _OUT_LIMIT or len(proc.stderr) > _OUT_LIMIT:
386-
raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} output overflow")
387-
if proc.returncode == 10:
542+
rc, _out, _err, reason = _run_child(argv, os.path.join(work, "probe"),
543+
_probe_env(work, adir), _CHILD_TIMEOUT_SECONDS)
544+
if reason is not None:
545+
raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} {reason}")
546+
if rc not in (0, 10):
547+
raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} rc={rc}")
548+
obj = _read_probe_json(out_path, attempt)
549+
if rc == 10:
550+
_validate_unsupported_result(obj, attempt) # exit 10 needs the exact unsupported schema
388551
return 10, None
389-
if proc.returncode != 0:
390-
raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} rc={proc.returncode}")
552+
_validate_probe_result(obj, attempt)
553+
return 0, obj
554+
555+
556+
def _read_probe_json(out_path: str, attempt: int) -> Any:
391557
try:
392558
with open(out_path, "rb") as fh:
393559
raw = fh.read()
394-
if len(raw) > _OUT_LIMIT:
395-
raise TargetError(INFRASTRUCTURE, "probe-result.json too large")
560+
except OSError as exc:
561+
raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} result unreadable ({exc})") \
562+
from exc
563+
if len(raw) > _OUT_LIMIT:
564+
raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} result too large")
565+
try:
396566
obj = json.loads(raw)
397-
except (OSError, ValueError) as exc:
398-
raise TargetError(INFRASTRUCTURE, f"probe-result.json unreadable ({exc})") from exc
567+
except ValueError as exc:
568+
raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} result not JSON ({exc})") \
569+
from exc
399570
if _canonical_bytes(obj) != raw:
400-
raise TargetError(INFRASTRUCTURE, "probe-result.json is not canonical bytes")
401-
_validate_probe_result(obj, attempt)
402-
return 0, obj
571+
raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} result not canonical")
572+
return obj
573+
574+
575+
_UNSUPPORTED_KEYS = ("version", "operation", "attempt", "runtime_unsupported", "reason")
576+
577+
578+
def _validate_unsupported_result(obj: Any, attempt: int) -> None:
579+
if not isinstance(obj, dict) or set(obj) != set(_UNSUPPORTED_KEYS):
580+
raise TargetError(INFRASTRUCTURE, "runtime-unsupported result is not the exact schema")
581+
if obj["version"] != 1 or obj["operation"] != "weak-target-probe" or obj["attempt"] != attempt:
582+
raise TargetError(INFRASTRUCTURE, "runtime-unsupported result version/operation/attempt")
583+
if obj["runtime_unsupported"] is not True or not isinstance(obj["reason"], str):
584+
raise TargetError(INFRASTRUCTURE, "runtime-unsupported result flag/reason wrong")
403585

404586

405587
def _validate_probe_result(obj: Any, attempt: int) -> None:
@@ -412,11 +594,24 @@ def _validate_probe_result(obj: Any, attempt: int) -> None:
412594
"threw_on_post_collection_raise"):
413595
if not isinstance(obj[k], bool):
414596
raise TargetError(INFRASTRUCTURE, f"probe-result.{k} must be a boolean")
415-
if not isinstance(obj["delivered_count"], int) or isinstance(obj["delivered_count"], bool):
597+
if not _is_int(obj["delivered_count"]):
416598
raise TargetError(INFRASTRUCTURE, "probe-result.delivered_count must be an int")
417599
rw = obj["resolved_wrapper"]
418600
if not isinstance(rw, dict) or set(rw) != set(_RESOLVED_KEYS):
419601
raise TargetError(INFRASTRUCTURE, "probe-result.resolved_wrapper is not the exact schema")
602+
if not _is_int(rw["ordinal"]) or rw["ordinal"] < 0:
603+
raise TargetError(INFRASTRUCTURE, "resolved_wrapper.ordinal must be a non-negative int")
604+
for k in ("slot_sha256", "assembly_simple_name", "module_mvid", "metadata_token",
605+
"resolved_signature"):
606+
if not isinstance(rw[k], str):
607+
raise TargetError(INFRASTRUCTURE, f"resolved_wrapper.{k} must be a string")
608+
if not re.fullmatch(r"sha256:[0-9a-f]{64}", rw["slot_sha256"]):
609+
raise TargetError(INFRASTRUCTURE, "resolved_wrapper.slot_sha256 is not a lowercase sha256")
610+
if not re.fullmatch(r"0x[0-9a-f]{8}", rw["metadata_token"]):
611+
raise TargetError(INFRASTRUCTURE, "resolved_wrapper.metadata_token is malformed")
612+
if not re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
613+
rw["module_mvid"]):
614+
raise TargetError(INFRASTRUCTURE, "resolved_wrapper.module_mvid is not a GUID")
420615

421616

422617
def _attempt_verdict(p: dict[str, Any]) -> str:
@@ -739,9 +934,7 @@ def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: s
739934
dotnet_host, dotnet_host_sha) # before bind
740935
binding = run_bind(work, dotnet_host, probe_dll_dst, selected_ver, rel,
741936
bundle_info["preimage"], bundle_info["postimage"], slots_root,
742-
target, class_fqn, bind_params)
743-
if binding["converted_callsites"] != len(convert_ids):
744-
raise TargetError(CALLSITE_BINDING, "bound callsite count != converted candidates")
937+
target, class_fqn, bind_params, convert_ids)
745938
if not (0 <= wrapper_ordinal < len(slot_evidence)):
746939
raise TargetError(INPUT_LAYOUT, "--wrapper-ordinal is out of range")
747940
if binding["derived_wrapper_ordinal"] != wrapper_ordinal:

0 commit comments

Comments
 (0)