diff --git a/docs/evidence/issue-62/README.md b/docs/evidence/issue-62/README.md index 75a45748..83ab7ee5 100644 --- a/docs/evidence/issue-62/README.md +++ b/docs/evidence/issue-62/README.md @@ -254,6 +254,47 @@ URLs, headers, credentials, prompts, tool arguments, or wire identifiers. validates the canonical manifest schema and fails closed on mutation, deletion, loss, missing fingerprints, or route/catalog mismatch. +#### Planner v2 contract + +The manifest's `planner` object is an exact four-field envelope: + +```json +{ + "inputs": { + "provider": "official", + "model": "gpt-5.6-sol", + "protocol": "responses", + "cli_version": "0.146.0", + "cli_package_sha256": "", + "candidate_sha": "", + "catalog_digest": "", + "route_digest": "" + }, + "core_plan": { + "status": "complete", + "items": [ + { + "id": "core-message", + "type": "message", + "disposition": "preserved", + "evidence_ref": "artifact.json#core.message" + } + ] + }, + "hosted_only_items": [], + "unknown_tagged_items": [] +} +``` + +`core_plan.status` is explicitly `complete` or `partial`. Every item in all +three lists has exactly `id`, `type`, `disposition`, and `evidence_ref`; IDs +are globally unique and each list is deterministically sorted. Dispositions +use the vocabulary above, and `evidence_ref` is a non-empty relative +`artifact#pointer` reference (no URLs, backslashes, or parent-directory +segments). Planner inputs are bound to the candidate identity and the official +Responses route before a live child starts. A synthetic fixture may carry only +`partial`/`Unqualified` planner evidence and can never qualify the issue. + For Codex CLI `0.146.0`, the package metadata does not include `gitHead`. Evidence may use `cli_source_commit: null` with `cli_source_commit_status: not_published_by_registry`; a fabricated SHA is @@ -276,6 +317,12 @@ Run two independent instances around an isolated Gateway process: isolated client -> pre sidecar -> isolated Gateway -> post sidecar -> upstream ``` +Codex CLI may issue a model-refresh `GET /models` before the Responses controls. +The sidecar forwards that discovery GET with the same bounded timeout and +response cap, but never persists its path, headers, or body; only the required +POST Responses controls create evidence records. A discovery failure therefore +fails the client request without fabricating a core capture. + The two commands require distinct output directories and a shared, isolated 32-byte-or-longer HMAC key file. The operator must replace every angle-bracket token only after the live window identifies the exact candidate, isolated @@ -290,6 +337,7 @@ py -3.13 scripts/capture_issue_62_live_evidence.py ` --forward-base-url http://127.0.0.1: ` --output-dir \pre ` --hmac-key-file ` + --run-nonce ` --max-request-bytes ` --max-response-bytes ` --connect-timeout-seconds ` @@ -304,6 +352,7 @@ py -3.13 scripts/capture_issue_62_live_evidence.py ` --forward-base-url ` --output-dir \post ` --hmac-key-file ` + --run-nonce ` --max-request-bytes ` --max-response-bytes ` --connect-timeout-seconds ` @@ -317,7 +366,12 @@ terminal classifications, or a fixed incomplete failure code. URLs, paths, headers, credentials, key material, raw bodies, prompt/tool content, wire identifiers, and exception text are never artifact fields. Overflow, timeout, cancellation, forwarding failure, incomplete SSE framing, and server lifecycle -failure cannot produce a complete record and leave no `.partial` artifact. +failure cannot produce a complete record and leave no `.partial` artifact. The +operator supplies one fresh 32-hex run nonce to both hops for each window. It +is included in the correlation HMAC context and the producer HMAC over each +canonical record (excluding the producer-HMAC field); post accepts each token +only once for that run. A producer snapshot is read back immediately and +checked again at shutdown, so a rewritten record fails closed. The focused tests use loopback fake servers only: diff --git a/docs/superpowers/plans/2026-08-02-issue-62-live-evidence-sidecar.md b/docs/superpowers/plans/2026-08-02-issue-62-live-evidence-sidecar.md index 6ddc8068..d1904516 100644 --- a/docs/superpowers/plans/2026-08-02-issue-62-live-evidence-sidecar.md +++ b/docs/superpowers/plans/2026-08-02-issue-62-live-evidence-sidecar.md @@ -27,7 +27,7 @@ - Create: `tests/test_issue_62_live_evidence_sidecar.py` **Interfaces:** -- Produces: `SidecarConfig`, `validate_config(config)`, `write_capture_record(output_dir, hop, record)`, and `main(argv=None) -> int`. +- Produces: `SidecarConfig`, `validate_config(config)`, `write_capture_record(output_dir, hop, record, capture_key=..., correlation_token=...)`, and `main(argv=None) -> int`. - Consumes: only Python standard-library modules and a pre-existing HMAC key file. - [x] **Step 1: Write failing activation and artifact tests** @@ -43,7 +43,13 @@ def test_config_rejects_non_loopback(host, base_config): sidecar.validate_config(dataclasses.replace(base_config, listen_host=host)) def test_atomic_record_is_sanitized_and_leaves_no_partial(tmp_path): - record_path = sidecar.write_capture_record(tmp_path, "pre", SAFE_RECORD) + record_path = sidecar.write_capture_record( + tmp_path, + "pre", + SAFE_RECORD, + capture_key=KEY, + correlation_token=CORRELATION_TOKEN, + ) assert json.loads(record_path.read_text(encoding="utf-8"))["schema"] == ( "codexhub.issue62.live-evidence-lane.v1" ) diff --git a/scripts/build_issue_62_control_manifest.py b/scripts/build_issue_62_control_manifest.py index 9301d08d..2ac4bcdf 100644 --- a/scripts/build_issue_62_control_manifest.py +++ b/scripts/build_issue_62_control_manifest.py @@ -45,6 +45,7 @@ "candidate_identity", "controls", "identity_control", + "planner", "capture_manifest_sha256", "qualification", } @@ -102,14 +103,48 @@ ) _HEX64 = re.compile(r"[0-9a-f]{64}\Z") -_SHA1 = re.compile(r"[0-9a-f]{40}\Z") +_SHA1_OR_HEX64 = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") _SEMVER = re.compile(r"0\.(?:\d+)\.(?:\d+)(?:-[0-9A-Za-z.-]+)?\Z") +PLANNER_FIELDS = frozenset( + {"inputs", "core_plan", "hosted_only_items", "unknown_tagged_items"} +) +PLANNER_INPUT_FIELDS = frozenset( + { + "provider", + "model", + "protocol", + "cli_version", + "cli_package_sha256", + "candidate_sha", + "catalog_digest", + "route_digest", + } +) +PLANNER_CORE_PLAN_FIELDS = frozenset({"status", "items"}) +PLANNER_ITEM_FIELDS = frozenset({"id", "type", "disposition", "evidence_ref"}) +PLANNER_PLAN_STATES = frozenset({"complete", "partial"}) +PLANNER_DISPOSITIONS = frozenset( + { + "preserved", + "reversibly_adapted", + "local_consume", + "Unsupported", + "Unqualified", + } +) +_PLANNER_TOKEN = re.compile(r"[A-Za-z0-9_.:-]{1,128}\Z") +_PLANNER_EVIDENCE_REF = re.compile( + r"[A-Za-z0-9][A-Za-z0-9._/-]{0,191}#[A-Za-z0-9][A-Za-z0-9_.:/-]{0,191}\Z" +) + _SIDEcar_ROOT_FIELDS = frozenset( { "schema", "verification_scope", "capture_id", + "run_nonce", + "producer_hmac_sha256", "hop", "outcome", "failure", @@ -238,6 +273,12 @@ def sanitize_sidecar_record(record: Mapping[str, Any], *, expected_hop: str) -> capture_id = record.get("capture_id") if not isinstance(capture_id, str) or not re.fullmatch(r"c[0-9a-f]{32}\Z", capture_id): raise ManifestValidationError("sidecar_record_capture_id_invalid") + run_nonce = record.get("run_nonce") + if not isinstance(run_nonce, str) or not re.fullmatch(r"[0-9a-f]{32}\Z", run_nonce): + raise ManifestValidationError("sidecar_record_nonce_invalid") + producer_hmac = record.get("producer_hmac_sha256") + if not isinstance(producer_hmac, str) or _HEX64.fullmatch(producer_hmac) is None: + raise ManifestValidationError("sidecar_record_producer_binding_invalid") outcome = record.get("outcome") if outcome not in {"complete", "incomplete"}: raise ManifestValidationError("sidecar_record_outcome_invalid") @@ -455,6 +496,7 @@ def _sanitize_route(value: Any) -> dict[str, Any]: "behavior_profile", "inbound_format", "upstream_format", + "route_digest", } ) _require_exact_fields(value, expected, "route_identity_fields_invalid") @@ -471,6 +513,7 @@ def _sanitize_route(value: Any) -> dict[str, Any]: model = value.get("model") if not isinstance(model, str) or not model or "/" in model: raise ManifestValidationError("route_identity_model_invalid") + _require_hex(value.get("route_digest"), _HEX64, "route_identity_digest_invalid") return {key: value[key] for key in expected} @@ -538,8 +581,14 @@ def _sanitize_control(control: Mapping[str, Any]) -> dict[str, Any]: name = control.get("name") if name not in CONTROL_NAME_SET: raise ManifestValidationError("control_name_invalid") - pre = sanitize_sidecar_record(control["pre"], expected_hop="pre") - post = sanitize_sidecar_record(control["post"], expected_hop="post") + raw_pre = control["pre"] + raw_post = control["post"] + if not isinstance(raw_pre, Mapping) or not isinstance(raw_post, Mapping): + raise ManifestValidationError("control_sidecar_invalid") + if raw_pre.get("capture_id") != raw_post.get("capture_id"): + raise ManifestValidationError("control_capture_correlation_mismatch") + pre = sanitize_sidecar_record(raw_pre, expected_hop="pre") + post = sanitize_sidecar_record(raw_post, expected_hop="post") if pre["outcome"] != "complete" or post["outcome"] != "complete": raise ManifestValidationError("control_capture_incomplete") _require_complete_fingerprints(pre, post) @@ -568,7 +617,9 @@ def _sanitize_control(control: Mapping[str, Any]) -> dict[str, Any]: if response_shape["terminal"] != "json_response" and pre["content_type_class"] == "json": raise ManifestValidationError("control_json_terminal_invalid") _validate_control_semantics(name, request_shape, response_shape) - if identity["unclassified_core_items"] != 0: + if identity["unclassified_core_items"] != 0 or not all( + identity[field] for field in IDENTITY_BOOLEAN_FIELDS + ): raise ManifestValidationError("control_identity_unclassified") return { "name": name, @@ -721,13 +772,14 @@ def _validate_candidate(value: Mapping[str, Any]) -> dict[str, Any]: "cli_package_sha256", "catalog_snapshot_sha256", "catalog_model_entry_id", + "route_digest", } ) if not isinstance(value, Mapping): raise ManifestValidationError("candidate_identity_invalid") _require_exact_fields(value, expected, "candidate_identity_fields_invalid") result = dict(value) - _require_hex(result["codexhub_candidate_sha"], _SHA1, "candidate_codexhub_sha_invalid") + _require_hex(result["codexhub_candidate_sha"], _SHA1_OR_HEX64, "candidate_codexhub_sha_invalid") if not isinstance(result["cli_version"], str) or _SEMVER.fullmatch(result["cli_version"]) is None: raise ManifestValidationError("candidate_cli_version_invalid") source_status = result["cli_source_commit_status"] @@ -735,16 +787,220 @@ def _validate_candidate(value: Mapping[str, Any]) -> dict[str, Any]: raise ManifestValidationError("candidate_cli_source_commit_status_invalid") source_commit = result["cli_source_commit"] if source_status == "published": - _require_hex(source_commit, _SHA1, "candidate_cli_source_commit_invalid") + _require_hex(source_commit, _SHA1_OR_HEX64, "candidate_cli_source_commit_invalid") elif source_commit is not None: raise ManifestValidationError("candidate_cli_source_commit_unexpected") _require_hex(result["cli_package_sha256"], _HEX64, "candidate_cli_package_sha_invalid") _require_hex(result["catalog_snapshot_sha256"], _HEX64, "candidate_catalog_sha_invalid") + _require_hex(result["route_digest"], _HEX64, "candidate_route_digest_invalid") if result["catalog_model_entry_id"] != "gpt-5.6-sol": raise ManifestValidationError("candidate_catalog_model_invalid") return result +def _planner_token(value: Any, code: str) -> str: + if not isinstance(value, str) or _PLANNER_TOKEN.fullmatch(value) is None: + raise ManifestValidationError(code) + return value + + +def _planner_evidence_ref(value: Any) -> str: + if not isinstance(value, str) or _PLANNER_EVIDENCE_REF.fullmatch(value) is None: + raise ManifestValidationError("planner_evidence_ref_invalid") + source, pointer = value.split("#", 1) + if any(part in {"", ".", ".."} for part in source.split("/")): + raise ManifestValidationError("planner_evidence_ref_invalid") + if "://" in value or "\\" in value: + raise ManifestValidationError("planner_evidence_ref_invalid") + if not pointer or pointer.startswith("/"): + raise ManifestValidationError("planner_evidence_ref_invalid") + if any(part in {"", ".", ".."} for part in pointer.split("/")): + raise ManifestValidationError("planner_evidence_ref_invalid") + return value + + +def _validate_planner_items( + value: Any, + *, + field: str, + verification_scope: str, + seen_ids: set[str], +) -> list[dict[str, str]]: + if not isinstance(value, list): + raise ManifestValidationError(f"planner_{field}_invalid") + items: list[dict[str, str]] = [] + for raw in value: + if not isinstance(raw, Mapping): + raise ManifestValidationError(f"planner_{field}_item_invalid") + _require_exact_fields(raw, PLANNER_ITEM_FIELDS, f"planner_{field}_item_fields_invalid") + item_id = _planner_token(raw.get("id"), f"planner_{field}_item_id_invalid") + item_type = _planner_token(raw.get("type"), f"planner_{field}_item_type_invalid") + disposition = raw.get("disposition") + if disposition not in PLANNER_DISPOSITIONS: + raise ManifestValidationError("planner_disposition_invalid") + evidence_ref = _planner_evidence_ref(raw.get("evidence_ref")) + if item_id in seen_ids: + raise ManifestValidationError("planner_duplicate_item_id") + seen_ids.add(item_id) + if verification_scope == SYNTHETIC_SCOPE and disposition != "Unqualified": + raise ManifestValidationError("planner_synthetic_evidence_invalid") + items.append( + { + "id": item_id, + "type": item_type, + "disposition": disposition, + "evidence_ref": evidence_ref, + } + ) + ordered = sorted(items, key=lambda item: tuple(item[field] for field in ("id", "type", "disposition", "evidence_ref"))) + if items != ordered: + raise ManifestValidationError(f"planner_{field}_unsorted") + return items + + +def _validate_planner( + value: Any, + *, + verification_scope: str, + candidate_identity: Mapping[str, Any] | None = None, + route_identity: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Validate and copy the immutable model-visible planner contract. + + The live runner validates the same shape before children start, while the + manifest validator additionally binds all identity inputs to the captured + route and candidate metadata. + """ + + if not isinstance(value, Mapping): + raise ManifestValidationError("planner_invalid") + _require_exact_fields(value, PLANNER_FIELDS, "planner_fields_invalid") + + raw_inputs = value.get("inputs") + if not isinstance(raw_inputs, Mapping): + raise ManifestValidationError("planner_inputs_invalid") + _require_exact_fields(raw_inputs, PLANNER_INPUT_FIELDS, "planner_input_fields_invalid") + inputs = { + "provider": _planner_token(raw_inputs.get("provider"), "planner_provider_invalid"), + "model": _planner_token(raw_inputs.get("model"), "planner_model_invalid"), + "protocol": _planner_token(raw_inputs.get("protocol"), "planner_protocol_invalid"), + "cli_version": raw_inputs.get("cli_version"), + "cli_package_sha256": raw_inputs.get("cli_package_sha256"), + "candidate_sha": raw_inputs.get("candidate_sha"), + "catalog_digest": raw_inputs.get("catalog_digest"), + "route_digest": raw_inputs.get("route_digest"), + } + if not isinstance(inputs["cli_version"], str) or _SEMVER.fullmatch(inputs["cli_version"]) is None: + raise ManifestValidationError("planner_cli_version_invalid") + _require_hex(inputs["cli_package_sha256"], _HEX64, "planner_cli_package_sha_invalid") + _require_hex(inputs["candidate_sha"], _SHA1_OR_HEX64, "planner_candidate_sha_invalid") + _require_hex(inputs["catalog_digest"], _HEX64, "planner_catalog_digest_invalid") + _require_hex(inputs["route_digest"], _HEX64, "planner_route_digest_invalid") + + if candidate_identity is not None: + expected_inputs = { + "cli_version": candidate_identity.get("cli_version"), + "cli_package_sha256": candidate_identity.get("cli_package_sha256"), + "candidate_sha": candidate_identity.get("codexhub_candidate_sha"), + "catalog_digest": candidate_identity.get("catalog_snapshot_sha256"), + "route_digest": candidate_identity.get("route_digest"), + "model": candidate_identity.get("catalog_model_entry_id"), + } + if any(inputs[key] != expected for key, expected in expected_inputs.items()): + raise ManifestValidationError("planner_input_binding_mismatch") + if route_identity is not None: + expected_route = { + "provider": route_identity.get("upstream"), + "model": route_identity.get("model"), + "protocol": route_identity.get("inbound_format"), + "route_digest": route_identity.get("route_digest"), + } + if any(inputs[key] != expected for key, expected in expected_route.items()): + raise ManifestValidationError("planner_route_binding_mismatch") + + raw_core = value.get("core_plan") + if not isinstance(raw_core, Mapping): + raise ManifestValidationError("planner_core_plan_invalid") + _require_exact_fields(raw_core, PLANNER_CORE_PLAN_FIELDS, "planner_core_plan_fields_invalid") + status = raw_core.get("status") + if status not in PLANNER_PLAN_STATES: + raise ManifestValidationError("planner_core_plan_status_invalid") + seen_ids: set[str] = set() + core_items = _validate_planner_items( + raw_core.get("items"), + field="core_plan", + verification_scope=verification_scope, + seen_ids=seen_ids, + ) + hosted_items = _validate_planner_items( + value.get("hosted_only_items"), + field="hosted_only_items", + verification_scope=verification_scope, + seen_ids=seen_ids, + ) + unknown_items = _validate_planner_items( + value.get("unknown_tagged_items"), + field="unknown_tagged_items", + verification_scope=verification_scope, + seen_ids=seen_ids, + ) + if status == "complete" and not core_items: + raise ManifestValidationError("planner_core_plan_empty") + if verification_scope == SYNTHETIC_SCOPE and status == "complete": + raise ManifestValidationError("planner_synthetic_evidence_invalid") + return { + "inputs": inputs, + "core_plan": {"status": status, "items": core_items}, + "hosted_only_items": hosted_items, + "unknown_tagged_items": unknown_items, + } + + +def _default_planner( + candidate: Mapping[str, Any], route: Mapping[str, Any] +) -> dict[str, Any]: + """Return the non-qualifying planner used when a capture has no plan. + + Fixture captures do not claim a model-visible inventory. They still carry + the complete candidate/route identity required by the v2 envelope so the + absence of a captured plan is explicit and cannot be mistaken for an + unbound or legacy planner. + """ + + return { + "inputs": { + "provider": route["upstream"], + "model": route["model"], + "protocol": route["inbound_format"], + "cli_version": candidate["cli_version"], + "cli_package_sha256": candidate["cli_package_sha256"], + "candidate_sha": candidate["codexhub_candidate_sha"], + "catalog_digest": candidate["catalog_snapshot_sha256"], + "route_digest": candidate["route_digest"], + }, + "core_plan": {"status": "partial", "items": []}, + "hosted_only_items": [], + "unknown_tagged_items": [], + } + + +def validate_planner( + value: Any, + *, + verification_scope: str, + candidate_identity: Mapping[str, Any] | None = None, + route_identity: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Public live-plan seam for the shared planner contract.""" + + return _validate_planner( + value, + verification_scope=verification_scope, + candidate_identity=candidate_identity, + route_identity=route_identity, + ) + + def _canonical_digest(value: Any) -> str: encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") return hashlib.sha256(encoded).hexdigest() @@ -761,6 +1017,7 @@ def _manifest_core(manifest: Mapping[str, Any]) -> dict[str, Any]: "candidate_identity": manifest.get("candidate_identity"), "controls": manifest.get("controls"), "identity_control": manifest.get("identity_control"), + "planner": manifest.get("planner"), "qualification": manifest.get("qualification"), } @@ -821,6 +1078,8 @@ def _validate_identity_control(value: Any) -> dict[str, Any]: raise ManifestValidationError("identity_control_controls_invalid") if unclassified_count != len(unclassified_controls): raise ManifestValidationError("identity_control_count_mismatch") + if unclassified_count != 0 or unclassified_controls: + raise ManifestValidationError("identity_control_unclassified") if value.get("replay_cases") != ["identity", "mutation", "deletion", "loss"]: raise ManifestValidationError("identity_control_replay_cases") if value.get("wire_pairing") != "control_label_ordinal_without_wire_identifier": @@ -839,21 +1098,43 @@ def build_manifest( *, candidate_identity: Mapping[str, Any], verification_scope: str = SYNTHETIC_SCOPE, + planner: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Build one deterministic sanitized manifest from control captures.""" if verification_scope not in VERIFICATION_SCOPES: raise ManifestValidationError("verification_scope_invalid") - candidate = _validate_candidate(candidate_identity) controls = [_sanitize_control(capture) for capture in captures] if len({control["name"] for control in controls}) != len(controls): raise ManifestValidationError("duplicate_control_name") missing = sorted(CONTROL_NAME_SET - _required_control_names(controls)) if missing: raise ManifestValidationError("missing_control:" + ",".join(missing)) + route_digests = {control["route_identity"]["route_digest"] for control in controls} + if len(route_digests) != 1: + raise ManifestValidationError("control_route_digest_set_invalid") + route_provenance = {_canonical_digest(control["route_identity"]) for control in controls} + if len(route_provenance) != 1: + raise ManifestValidationError("control_route_provenance_inconsistent") + if not isinstance(candidate_identity, Mapping) or "route_digest" not in candidate_identity: + raise ManifestValidationError("candidate_route_digest_required") + candidate = _validate_candidate(candidate_identity) route_models = {control["route_identity"]["model"] for control in controls} if route_models != {candidate["catalog_model_entry_id"]}: raise ManifestValidationError("control_route_model_set_invalid") + if route_digests != {candidate["route_digest"]}: + raise ManifestValidationError("control_route_digest_mismatch") + planner_input = ( + _default_planner(candidate, controls[0]["route_identity"]) + if planner is None + else planner + ) + planner_data = _validate_planner( + planner_input, + verification_scope=verification_scope, + candidate_identity=candidate, + route_identity=controls[0]["route_identity"], + ) identity_failures = [ control["name"] for control in controls @@ -883,6 +1164,7 @@ def build_manifest( "candidate_identity": candidate, "controls": sorted(controls, key=lambda item: item["name"]), "identity_control": identity_control, + "planner": planner_data, "qualification": qualification, } core["capture_manifest_sha256"] = _canonical_digest(core) @@ -946,10 +1228,20 @@ def reconcile_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: missing = sorted(CONTROL_NAME_SET - set(names)) if missing: mismatches.append("missing_control:" + ",".join(missing)) + route_digests = {control["route_identity"]["route_digest"] for control in canonical_controls} + if len(route_digests) != 1: + mismatches.append("control_route_digest_set_invalid") + route_provenance = { + _canonical_digest(control["route_identity"]) for control in canonical_controls + } + if len(route_provenance) > 1: + mismatches.append("control_route_provenance_inconsistent") if candidate: route_models = {control["route_identity"]["model"] for control in canonical_controls} if route_models != {candidate.get("catalog_model_entry_id")}: mismatches.append("control_route_model_set_invalid") + if route_digests != {candidate.get("route_digest")}: + mismatches.append("control_route_digest_mismatch") try: identity_control = _validate_identity_control(manifest.get("identity_control")) except ManifestValidationError as exc: @@ -965,12 +1257,23 @@ def reconcile_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: mismatches.append("identity_control_consistency") if identity_control["unclassified_core_items"] != len(observed_unclassified): mismatches.append("identity_control_count_consistency") - if manifest.get("verification_scope") not in VERIFICATION_SCOPES: + verification_scope = manifest.get("verification_scope") + if verification_scope not in VERIFICATION_SCOPES: mismatches.append("verification_scope_invalid") + route_identity = canonical_controls[0]["route_identity"] if canonical_controls else None + try: + _validate_planner( + manifest.get("planner"), + verification_scope=verification_scope, + candidate_identity=candidate or None, + route_identity=route_identity, + ) + except ManifestValidationError as exc: + mismatches.append(f"planner:{exc}") try: qualification = _validate_qualification( manifest.get("qualification"), - verification_scope=manifest.get("verification_scope"), + verification_scope=verification_scope, ) except ManifestValidationError as exc: mismatches.append(f"qualification:{exc}") @@ -1016,6 +1319,7 @@ def _parser() -> argparse.ArgumentParser: ) parser.add_argument("--cli-package-sha256", required=True) parser.add_argument("--catalog-snapshot-sha256", required=True) + parser.add_argument("--route-digest", required=True) parser.add_argument("--replay-case", choices=("identity", "mutation", "deletion", "loss"), default="identity") parser.add_argument("--check", action="store_true", help="Reconcile output without writing it") return parser @@ -1037,10 +1341,12 @@ def main(argv: list[str] | None = None) -> int: "catalog_snapshot_sha256": args.catalog_snapshot_sha256, "catalog_model_entry_id": source.get("catalog_model_entry_id", "gpt-5.6-sol"), } + candidate["route_digest"] = args.route_digest manifest = build_manifest( captures, candidate_identity=candidate, verification_scope=args.verification_scope, + planner=source.get("planner"), ) if args.replay_case != "identity": report = reconcile_manifest(replay_manifest(manifest, args.replay_case)) diff --git a/scripts/capture_issue_62_live_evidence.py b/scripts/capture_issue_62_live_evidence.py index 7e52091f..a05eb8cb 100644 --- a/scripts/capture_issue_62_live_evidence.py +++ b/scripts/capture_issue_62_live_evidence.py @@ -34,7 +34,12 @@ _OUTCOMES = frozenset({"complete", "incomplete"}) _FAILURES = frozenset( { + "capture_record_exists", + "capture_record_mutated", + "capture_record_write_failed", "client_cancelled", + "correlation_binding_invalid", + "correlation_token_replayed", "downstream_cancelled", "forwarding_failed", "invalid_content_length", @@ -57,6 +62,8 @@ "schema", "verification_scope", "capture_id", + "run_nonce", + "producer_hmac_sha256", "hop", "outcome", "failure", @@ -80,6 +87,10 @@ ) _CAPTURE_ID = re.compile(r"c[0-9a-f]{32}\Z") _DIGEST = re.compile(r"[0-9a-f]{64}\Z") +_CORRELATION_TOKEN = re.compile(r"[0-9a-f]{32}\Z") +_RUN_NONCE = _CORRELATION_TOKEN +_CORRELATION_BINDING = re.compile(r"([0-9a-f]{32})\.([0-9a-f]{64})\Z") +_CORRELATION_HEADER = "X-CodexHub-Issue62-Capture" class ConfigurationError(ValueError): @@ -107,6 +118,10 @@ class SidecarConfig: connect_timeout_seconds: float read_timeout_seconds: float overall_timeout_seconds: float + # Direct library callers may omit this and receive a fresh nonce. The + # command-line entrypoint requires an explicit nonce so pre/post hops can + # be bound to the same operator-created run. + run_nonce: str | None = None class BodyFingerprint: @@ -238,6 +253,10 @@ def validate_config(config: SidecarConfig) -> bytes: if config.hop not in _HOPS: raise ConfigurationError("hop_invalid") + if config.run_nonce is not None and ( + not isinstance(config.run_nonce, str) or _RUN_NONCE.fullmatch(config.run_nonce) is None + ): + raise ConfigurationError("run_nonce_invalid") try: listen_address = ipaddress.ip_address(config.listen_host) except ValueError as error: @@ -359,6 +378,12 @@ def validate_capture_record(record: Mapping[str, Any], hop: str) -> None: capture_id = record["capture_id"] if not isinstance(capture_id, str) or _CAPTURE_ID.fullmatch(capture_id) is None: raise ArtifactValidationError("capture_id_invalid") + run_nonce = record["run_nonce"] + if not isinstance(run_nonce, str) or _RUN_NONCE.fullmatch(run_nonce) is None: + raise ArtifactValidationError("run_nonce_invalid") + producer_hmac = record["producer_hmac_sha256"] + if not isinstance(producer_hmac, str) or _DIGEST.fullmatch(producer_hmac) is None: + raise ArtifactValidationError("producer_binding_invalid") if record["outcome"] not in _OUTCOMES: raise ArtifactValidationError("record_outcome_invalid") if record["failure"] is not None and record["failure"] not in _FAILURES: @@ -391,24 +416,170 @@ def validate_capture_record(record: Mapping[str, Any], hop: str) -> None: raise ArtifactValidationError("record_completion_invalid") -def write_capture_record(output_dir: Path, hop: str, record: Mapping[str, Any]) -> Path: - """Atomically publish one sanitized record and always remove its partial.""" +def _capture_id_for_token( + key: bytes, correlation_token: str, run_nonce: str | None = None +) -> str: + if not isinstance(key, bytes) or not 32 <= len(key) <= 4096: + raise ValueError("capture_key_invalid") + if ( + not isinstance(correlation_token, str) + or _CORRELATION_TOKEN.fullmatch(correlation_token) is None + ): + raise ValueError("correlation_token_invalid") + context = b"issue62-capture\0" + if run_nonce is not None: + if _RUN_NONCE.fullmatch(run_nonce) is None: + raise ValueError("run_nonce_invalid") + context += run_nonce.encode("ascii") + b"\0" + digest = hmac.new(key, context + correlation_token.encode("ascii"), hashlib.sha256).hexdigest() + return "c" + digest[:32] + + +def _new_correlation_token() -> str: + return uuid.uuid4().hex + + +def _correlation_binding( + key: bytes, correlation_token: str, run_nonce: str | None = None +) -> str: + if ( + not isinstance(correlation_token, str) + or _CORRELATION_TOKEN.fullmatch(correlation_token) is None + ): + raise ValueError("correlation_token_invalid") + context = b"issue62-correlation\0" + if run_nonce is not None: + if _RUN_NONCE.fullmatch(run_nonce) is None: + raise ValueError("run_nonce_invalid") + context += run_nonce.encode("ascii") + b"\0" + signature = hmac.new(key, context + correlation_token.encode("ascii"), hashlib.sha256).hexdigest() + return f"{correlation_token}.{signature}" + + +def _correlation_token_from_header( + key: bytes, value: str | None, run_nonce: str | None = None +) -> str | None: + if not isinstance(value, str): + return None + match = _CORRELATION_BINDING.fullmatch(value) + if match is None: + return None + correlation_token, signature = match.groups() + context = b"issue62-correlation\0" + if run_nonce is not None: + if _RUN_NONCE.fullmatch(run_nonce) is None: + return None + context += run_nonce.encode("ascii") + b"\0" + expected = hmac.new(key, context + correlation_token.encode("ascii"), hashlib.sha256).hexdigest() + if not hmac.compare_digest(signature, expected): + return None + return correlation_token + + +def _canonical_record_bytes(record: Mapping[str, Any]) -> bytes: + payload = dict(record) + payload.pop("producer_hmac_sha256", None) + return json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode( + "ascii" + ) + + +def _render_record_bytes(record: Mapping[str, Any]) -> bytes: + return json.dumps(record, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode( + "ascii" + ) + b"\n" + + +def _producer_hmac_sha256( + key: bytes, record: Mapping[str, Any], run_nonce: str | None = None +) -> str: + if not isinstance(key, bytes) or not 32 <= len(key) <= 4096: + raise ValueError("capture_key_invalid") + nonce = run_nonce if run_nonce is not None else record.get("run_nonce") + hop = record.get("hop") + capture_id = record.get("capture_id") + if ( + not isinstance(nonce, str) + or _RUN_NONCE.fullmatch(nonce) is None + or not isinstance(hop, str) + or hop not in _HOPS + or not isinstance(capture_id, str) + or _CAPTURE_ID.fullmatch(capture_id) is None + ): + raise ValueError("producer_binding_invalid") + material = ( + b"issue62-producer\0" + + nonce.encode("ascii") + + b"\0" + + hop.encode("ascii") + + b"\0" + + capture_id.encode("ascii") + + b"\0" + + _canonical_record_bytes(record) + ) + return hmac.new(key, material, hashlib.sha256).hexdigest() + + +def write_capture_record( + output_dir: Path, + hop: str, + record: Mapping[str, Any], + *, + capture_key: bytes | None = None, + correlation_token: str | None = None, + run_nonce: str | None = None, +) -> Path: + """Atomically publish one live-bound sanitized record and remove its partial.""" validate_capture_record(record, hop) + if capture_key is None or correlation_token is None: + raise ArtifactValidationError("capture_binding_missing") + expected_nonce = run_nonce if run_nonce is not None else record.get("run_nonce") + if not isinstance(expected_nonce, str) or _RUN_NONCE.fullmatch(expected_nonce) is None: + raise ArtifactValidationError("capture_binding_invalid") + try: + expected_capture_id = _capture_id_for_token(capture_key, correlation_token, expected_nonce) + except (TypeError, ValueError): + raise ArtifactValidationError("capture_binding_invalid") from None + if not hmac.compare_digest(str(record["capture_id"]), expected_capture_id): + raise ArtifactValidationError("capture_binding_invalid") + if str(record["run_nonce"]) != expected_nonce: + raise ArtifactValidationError("capture_binding_invalid") + try: + expected_producer_hmac = _producer_hmac_sha256(capture_key, record, expected_nonce) + except (TypeError, ValueError): + raise ArtifactValidationError("producer_binding_invalid") from None + if not hmac.compare_digest(str(record["producer_hmac_sha256"]), expected_producer_hmac): + raise ArtifactValidationError("producer_binding_invalid") directory = Path(output_dir) directory.mkdir(parents=True, exist_ok=True) capture_id = str(record["capture_id"]) target = directory / f"{hop}-{capture_id}.json" - partial = directory / f"{hop}-{capture_id}.partial" - if target.exists(): + if target.exists() or target.is_symlink(): raise ArtifactValidationError("capture_record_exists") - rendered = json.dumps(record, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + "\n" + rendered = _render_record_bytes(record) + # The capture id is deterministic for a correlation token, so two + # concurrent handlers can legitimately race for the same destination. + # Give each writer an independent temporary inode; sharing one ``.partial`` + # path lets a losing writer delete the winner's in-flight file. + partial = directory / f".{hop}-{capture_id}.{uuid.uuid4().hex}.partial" try: with partial.open("x", encoding="ascii", newline="\n") as handle: - handle.write(rendered) + handle.write(rendered.decode("ascii")) handle.flush() os.fsync(handle.fileno()) - os.replace(partial, target) + # Linking the fully written inode into place is atomic and refuses to + # overwrite a record published by another handler. + try: + os.link(partial, target) + except FileExistsError: + raise ArtifactValidationError("capture_record_exists") from None + partial.unlink() + try: + if target.read_bytes() != rendered: + raise ArtifactValidationError("capture_record_mutated") + except OSError: + raise ArtifactValidationError("capture_record_write_failed") from None finally: try: partial.unlink() @@ -431,10 +602,6 @@ def write_capture_record(output_dir: Path, hop: str, record: Mapping[str, Any]) ) -def _capture_id() -> str: - return "c" + uuid.uuid4().hex - - def _content_type_class(value: str | None) -> str: if not value: return "absent" @@ -446,11 +613,19 @@ def _content_type_class(value: str | None) -> str: return "other" -def _empty_record(hop: str, capture_id: str, failure: str) -> dict[str, Any]: - return { +def _empty_record( + hop: str, + capture_id: str, + failure: str, + run_nonce: str, + capture_key: bytes, +) -> dict[str, Any]: + record: dict[str, Any] = { "schema": SCHEMA, "verification_scope": VERIFICATION_SCOPE, "capture_id": capture_id, + "run_nonce": run_nonce, + "producer_hmac_sha256": "0" * 64, "hop": hop, "outcome": "incomplete", "failure": failure, @@ -460,6 +635,8 @@ def _empty_record(hop: str, capture_id: str, failure: str) -> dict[str, Any]: "response": None, "sse": None, } + record["producer_hmac_sha256"] = _producer_hmac_sha256(capture_key, record, run_nonce) + return record def _join_target_path(base_path: str, incoming_path: str) -> str: @@ -471,6 +648,10 @@ def _join_target_path(base_path: str, incoming_path: str) -> str: class _CaptureRequestHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.0" + def do_GET(self) -> None: + owner = self.server.capture_owner # type: ignore[attr-defined] + owner.handle_get(self) + def do_POST(self) -> None: owner = self.server.capture_owner # type: ignore[attr-defined] owner.handle_post(self) @@ -516,6 +697,7 @@ class CaptureSidecarServer: def __init__(self, config: SidecarConfig) -> None: self._config = config self._key = validate_config(config) + self._run_nonce = config.run_nonce or _new_correlation_token() self._target = urlsplit(config.forward_base_url) self._server: ThreadingHTTPServer | None = None self._thread: threading.Thread | None = None @@ -525,6 +707,10 @@ def __init__(self, config: SidecarConfig) -> None: socket.socket, http.client.HTTPConnection | None, ] = {} + self._issued_tokens: set[str] = set() + self._issued_lock = threading.Lock() + self._published_records: dict[Path, bytes] = {} + self._published_lock = threading.Lock() @property def listen_host(self) -> str: @@ -592,7 +778,12 @@ def shutdown(self) -> bool: drained = self._wait_for_active_connections(drain_timeout) if thread is not None and thread is not threading.current_thread(): thread.join(timeout=min(max(0.1, self._config.overall_timeout_seconds), 5.0)) - return drained and (thread is None or not thread.is_alive()) + thread_stopped = thread is None or not thread.is_alive() + if drained and thread_stopped and not self._verify_published_records(): + self._thread_failure = "capture_record_mutated" + self._write_control_failure("capture_record_mutated") + drained = False + return drained and thread_stopped def wait(self) -> bool: thread = self._thread @@ -603,15 +794,50 @@ def wait(self) -> bool: return self._thread_failure is None def _write_control_failure(self, failure: str) -> None: + correlation_token = _new_correlation_token() try: - write_capture_record( + capture_id = _capture_id_for_token(self._key, correlation_token, self._run_nonce) + record = _empty_record( + self._config.hop, + capture_id, + failure, + self._run_nonce, + self._key, + ) + record_path = write_capture_record( self._config.output_dir, self._config.hop, - _empty_record(self._config.hop, _capture_id(), failure), + record, + capture_key=self._key, + correlation_token=correlation_token, + run_nonce=self._run_nonce, ) + self._remember_published_record(record_path, _render_record_bytes(record)) except Exception: return + def _claim_token(self, correlation_token: str) -> bool: + with self._issued_lock: + if correlation_token in self._issued_tokens: + return False + self._issued_tokens.add(correlation_token) + return True + + def _remember_published_record(self, path: Path, rendered: bytes) -> None: + with self._published_lock: + self._published_records[path.resolve()] = bytes(rendered) + + def _verify_published_records(self) -> bool: + with self._published_lock: + snapshots = dict(self._published_records) + for path, expected in snapshots.items(): + try: + if path.read_bytes() != expected: + return False + except OSError: + return False + return True + def _register_client(self, connection: socket.socket) -> None: with self._active_condition: self._active_connections[connection] = None @@ -664,8 +890,81 @@ def _wait_for_active_connections(self, timeout: float) -> bool: self._active_condition.wait(timeout=remaining) return True + def handle_get(self, handler: BaseHTTPRequestHandler) -> None: + """Relay bounded client discovery GETs without creating core evidence. + + Codex CLI refreshes model metadata with ``GET /models`` before it sends + a Responses request. That discovery request is outside the Issue #62 + POST control inventory, but returning 501 would make the client fall + back to incomplete metadata and invalidate the live run. Keep this + lane deliberately narrow: forward the GET, cap the response in + memory, and never persist its path, headers, or body. + """ + + started_at = time.monotonic() + upstream: http.client.HTTPConnection | None = None + try: + connection_type = ( + http.client.HTTPSConnection + if self._target.scheme == "https" + else http.client.HTTPConnection + ) + upstream = connection_type( + self._target.hostname, + self._target.port, + timeout=self._config.connect_timeout_seconds, + ) + self._set_active_upstream(handler.connection, upstream) + headers = { + name: value + for name, value in handler.headers.items() + if name.lower() + not in _HOP_BY_HOP_HEADERS | {"host", "content-length", _CORRELATION_HEADER.lower()} + } + upstream.request("GET", _join_target_path(self._target.path, handler.path), headers=headers) + if upstream.sock is not None: + upstream.sock.settimeout(self._remaining_timeout(started_at)) + upstream_response = upstream.getresponse() + status = int(upstream_response.status) + response_headers = [ + (name, value) + for name, value in upstream_response.getheaders() + if name.lower() not in _HOP_BY_HOP_HEADERS | {"content-length"} + ] + body = bytearray() + while True: + self._check_deadline(started_at) + if upstream.sock is not None: + upstream.sock.settimeout(self._remaining_timeout(started_at)) + chunk = upstream_response.read(64 * 1024) + if not chunk: + break + if len(body) + len(chunk) > self._config.max_response_bytes: + raise ValueError("discovery_response_overflow") + body.extend(chunk) + handler.send_response(status) + for name, value in response_headers: + handler.send_header(name, value) + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Connection", "close") + handler.end_headers() + handler.wfile.write(body) + handler.wfile.flush() + except (TimeoutError, socket.timeout): + self._send_bounded_error(handler, 504) + except (BrokenPipeError, ConnectionResetError, OSError, ValueError): + self._send_bounded_error(handler, 502) + finally: + if upstream is not None: + try: + upstream.close() + except OSError: + pass + self._set_active_upstream(handler.connection, None) + def handle_post(self, handler: BaseHTTPRequestHandler) -> None: - capture_id = _capture_id() + correlation_token = _new_correlation_token() + capture_id = _capture_id_for_token(self._key, correlation_token, self._run_nonce) started_at = time.monotonic() request_fingerprint = BodyFingerprint(self._key, b"request-body") response_fingerprint: BodyFingerprint | None = None @@ -677,6 +976,28 @@ def handle_post(self, handler: BaseHTTPRequestHandler) -> None: request_complete = False response_complete = False try: + if self._config.hop == "post": + bound_token = _correlation_token_from_header( + self._key, + handler.headers.get(_CORRELATION_HEADER), + self._run_nonce, + ) + if bound_token is None: + failure = "correlation_binding_invalid" + self._send_bounded_error(handler, 400) + return + correlation_token = bound_token + capture_id = _capture_id_for_token(self._key, correlation_token, self._run_nonce) + if not self._claim_token(correlation_token): + failure = "correlation_token_replayed" + # Preserve the original capture record and publish this + # rejected attempt under a fresh, run-bound ID. + correlation_token = _new_correlation_token() + capture_id = _capture_id_for_token( + self._key, correlation_token, self._run_nonce + ) + self._send_bounded_error(handler, 409) + return raw_length = handler.headers.get("Content-Length") try: content_length = int(raw_length) if raw_length is not None else -1 @@ -716,8 +1037,13 @@ def handle_post(self, handler: BaseHTTPRequestHandler) -> None: headers = { name: value for name, value in handler.headers.items() - if name.lower() not in _HOP_BY_HOP_HEADERS | {"host", "content-length"} + if name.lower() + not in _HOP_BY_HOP_HEADERS | {"host", "content-length", _CORRELATION_HEADER.lower()} } + if self._config.hop == "pre": + headers[_CORRELATION_HEADER] = _correlation_binding( + self._key, correlation_token, self._run_nonce + ) target_path = _join_target_path(self._target.path, handler.path) upstream.request("POST", target_path, body=request_body, headers=headers) if upstream.sock is not None: @@ -788,6 +1114,8 @@ def handle_post(self, handler: BaseHTTPRequestHandler) -> None: "schema": SCHEMA, "verification_scope": VERIFICATION_SCOPE, "capture_id": capture_id, + "run_nonce": self._run_nonce, + "producer_hmac_sha256": "0" * 64, "hop": self._config.hop, "outcome": outcome, "failure": failure, @@ -809,9 +1137,34 @@ def handle_post(self, handler: BaseHTTPRequestHandler) -> None: else None ), } + record["producer_hmac_sha256"] = _producer_hmac_sha256( + self._key, record, self._run_nonce + ) try: - write_capture_record(self._config.output_dir, self._config.hop, record) + record_path = write_capture_record( + self._config.output_dir, + self._config.hop, + record, + capture_key=self._key, + correlation_token=correlation_token, + run_nonce=self._run_nonce, + ) + self._remember_published_record( + record_path, + _render_record_bytes(record), + ) + except ArtifactValidationError as error: + failure_code = str(error) + if failure_code not in _FAILURES: + failure_code = "capture_record_write_failed" + # A duplicate deterministic capture id is a real failed + # attempt, not a benign no-op. Surface a fresh, sanitized + # failure record so the run cannot silently lose evidence. + self._thread_failure = failure_code + self._write_control_failure(failure_code) except Exception: + self._thread_failure = "capture_record_write_failed" + self._write_control_failure("capture_record_write_failed") return def _check_deadline(self, started_at: float) -> None: @@ -852,6 +1205,7 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--connect-timeout-seconds", type=float, required=True) parser.add_argument("--read-timeout-seconds", type=float, required=True) parser.add_argument("--overall-timeout-seconds", type=float, required=True) + parser.add_argument("--run-nonce", required=True) return parser @@ -873,6 +1227,7 @@ def main(argv: list[str] | None = None) -> int: connect_timeout_seconds=options.connect_timeout_seconds, read_timeout_seconds=options.read_timeout_seconds, overall_timeout_seconds=options.overall_timeout_seconds, + run_nonce=options.run_nonce, ) if config.listen_port == 0: print("listen_port_zero_not_allowed", file=sys.stderr) diff --git a/scripts/run_issue_62_live_control.py b/scripts/run_issue_62_live_control.py index 7b4a6856..cff0ca87 100644 --- a/scripts/run_issue_62_live_control.py +++ b/scripts/run_issue_62_live_control.py @@ -1,10 +1,12 @@ """Run the bounded, sanitized orchestration envelope for Issue #62. This module deliberately separates orchestration from the real upstream -controls. The default command-line mode is ``--fixture`` and therefore cannot -qualify Issue #62. A future live runner can reuse :class:`BoundedPhaseRunner` -with real command/phase callbacks after the exact candidate and route have been -reviewed. +controls. The default command-line mode remains ``--fixture`` and therefore +cannot qualify Issue #62. An explicitly enabled ``--enable-live-control`` +mode accepts a sanitized, operator-supplied plan, binds its candidate/catalog/ +route files, runs real CLI and sidecar commands through +:class:`BoundedPhaseRunner`, and still leaves qualification closed for +independent review. The harness has three non-negotiable properties: @@ -25,17 +27,22 @@ import argparse from dataclasses import dataclass from datetime import datetime, timezone +import hashlib +import hmac import json import os from pathlib import Path import re import signal +import shutil +import secrets +import stat import subprocess import sys import tempfile import threading import time -from typing import Callable, Literal, Sequence +from typing import Any, Callable, Literal, Mapping, NoReturn, Sequence SCHEMA_VERSION = "codexhub.issue62.live-control.v1" @@ -56,9 +63,102 @@ "process_start_failed", "stale_artifact", "termination_failed", + # Live-plan validation codes are intentionally fixed and safe to + # expose in the operator result. They never include paths, command + # arguments, or exception text. + "live_control_plan_missing", + "live_control_plan_invalid", + "candidate_binding_mismatch", + "plan_path_invalid", + "plan_path_outside_isolation", + "plan_path_linked", + "environment_invalid", + "cli_executable_binding_mismatch", + "helper_executable_binding_mismatch", + "planner_plan_incomplete", + "planner_disposition_invalid", + "candidate_sha_binding_mismatch", + "cli_package_binding_mismatch", + "catalog_binding_mismatch", + "route_binding_mismatch", + "control_labels_incomplete", + "sidecar_capture_missing", + "sidecar_capture_incomplete", + "identity_replay_incomplete", + "identity_replay_artifact_missing", + "identity_replay_artifact_invalid", + "manifest_reconcile_failed", } ) +LIVE_PLAN_SCHEMA = "codexhub.issue62.live-control-plan.v1" +LIVE_SCOPE = "authorized_live_control" +CONTROL_NAMES = ( + "streaming_text", + "streaming_function_history", + "non_streaming_text", + "choice_auto", + "choice_none", + "terminal_success", + "terminal_error", + "error_json", +) +CONTROL_NAME_SET = frozenset(CONTROL_NAMES) +LIVE_PLAN_FIELDS = frozenset( + { + "schema", + "verification_scope", + "isolation_root", + "candidate_identity", + "binding", + "catalog_model_entry_id", + "cli", + "sidecars", + "controls", + "replays", + "environment", + "planner", + } +) +LIVE_ENV_KEYS = frozenset( + {"PATH", "SystemRoot", "ComSpec", "TEMP", "TMP", "PATHEXT", "PYTHONPATH"} +) +LOCAL_ENV_PATH_KEYS = frozenset({"PATH", "TEMP", "TMP", "PYTHONPATH"}) +SENSITIVE_ENV_KEYS = frozenset( + { + "CODEX_HOME", + "OPENAI_API_KEY", + "OLLAMA_API_KEY", + "CODEX_AUTH", + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "APPDATA", + "LOCALAPPDATA", + "XDG_CONFIG_HOME", + } +) +CASE_ENVIRONMENT_PATHS = { + "HOME": "home", + "USERPROFILE": "userprofile", + "APPDATA": "appdata", + "LOCALAPPDATA": "localappdata", + "CODEX_HOME": "codex-home", + "XDG_CONFIG_HOME": "xdg-config", +} +PLANNER_FIELDS = frozenset( + {"inputs", "core_plan", "hosted_only_items", "unknown_tagged_items"} +) + + +class LiveControlValidationError(ValueError): + """A deterministic, path-free live-control plan validation failure.""" + + def __init__(self, code: str) -> None: + self.code = code if code in ALLOWED_ERROR_CODES else "live_control_plan_invalid" + super().__init__(self.code) + class HarnessFailure(RuntimeError): """An internal failure represented by a safe, allow-listed code.""" @@ -151,6 +251,15 @@ class CommandResult: terminated: bool = True +@dataclass(frozen=True) +class BackgroundProcess: + """A bounded child kept alive while a control command executes.""" + + phase: str + process: subprocess.Popen[bytes] + tracked_pids: frozenset[int] = frozenset() + + def _validate_timeout(timeout_seconds: float) -> float: if not MIN_TIMEOUT_SECONDS <= timeout_seconds <= MAX_TIMEOUT_SECONDS: raise ValueError("timeout must be between 30 and 60 seconds") @@ -185,6 +294,7 @@ def _count_extra_artifacts(run_root: Path) -> int: entry.name in ALLOWED_ARTIFACT_NAMES and entry.is_file() and not entry.is_symlink() + and os.stat(entry).st_nlink == 1 ): continue extras += 1 @@ -222,36 +332,588 @@ def _write_sanitized_json(target: Path, payload: dict[str, object]) -> None: raise HarnessFailure("journal_write_failed") from error -def _terminate_process(process: subprocess.Popen[bytes]) -> bool: +def _posix_process_tree_pids(root_pid: int) -> set[int] | None: + proc_root = Path("/proc") + if not proc_root.is_dir(): + return None + parents: dict[int, int] = {} + process_groups: dict[int, int] = {} try: - if process.poll() is not None: - return True - if os.name == "nt": - # ``terminate`` only signals the direct child on Windows. The - # Gateway/CLI can spawn grandchildren, so use taskkill's tree mode - # and suppress all command output. - subprocess.run( - ["taskkill", "/PID", str(int(process.pid)), "/T", "/F"], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - timeout=5, - ) - else: - os.killpg(int(process.pid), signal.SIGTERM) - process.wait(timeout=5) + for entry in proc_root.iterdir(): + if not entry.name.isdigit(): + continue + try: + stat_text = (entry / "stat").read_text(encoding="ascii") + closing_paren = stat_text.rfind(")") + fields = stat_text[closing_paren + 2 :].split() + pid = int(entry.name) + parents[pid] = int(fields[1]) + process_groups[pid] = int(fields[2]) + except (OSError, UnicodeError, ValueError, IndexError): + continue + except OSError: + return None + + result = {root_pid} + pending = [root_pid] + while pending: + parent = pending.pop() + for pid, ppid in parents.items(): + if ppid == parent and pid not in result: + result.add(pid) + pending.append(pid) + # A session leader can exit while its descendants remain in the same + # process group. Keep those descendants in the readback set even when + # the kernel has reparented them to init. + result.update(pid for pid, group in process_groups.items() if group == root_pid) + return result + + +def _windows_process_parent_map() -> dict[int, int] | None: + if os.name != "nt": + return None + try: + import ctypes + from ctypes import wintypes + + class ProcessEntry32W(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ctypes.c_size_t), + ("th32ModuleID", wintypes.DWORD), + ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), + ("pcPriClassBase", wintypes.LONG), + ("dwFlags", wintypes.DWORD), + ("szExeFile", wintypes.WCHAR * 260), + ] + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] + kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE + kernel32.Process32FirstW.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(ProcessEntry32W), + ] + kernel32.Process32FirstW.restype = wintypes.BOOL + kernel32.Process32NextW.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(ProcessEntry32W), + ] + kernel32.Process32NextW.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + snapshot = kernel32.CreateToolhelp32Snapshot(0x00000002, 0) + invalid_handle = ctypes.c_void_p(-1).value + if snapshot == invalid_handle: + return None + try: + entry = ProcessEntry32W() + entry.dwSize = ctypes.sizeof(ProcessEntry32W) + first = kernel32.Process32FirstW(snapshot, ctypes.byref(entry)) + if not first: + return None + result: dict[int, int] = {} + while True: + result[int(entry.th32ProcessID)] = int(entry.th32ParentProcessID) + if not kernel32.Process32NextW(snapshot, ctypes.byref(entry)): + break + return result + finally: + kernel32.CloseHandle(snapshot) + except Exception: + return None + + +def _close_windows_handle(handle: object | None) -> None: + if handle is None or os.name != "nt": + return + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.CloseHandle(handle) + except Exception: + pass + + +def _create_windows_job() -> object | None: + """Create a kill-on-close job so descendants survive root exit tracking.""" + + if os.name != "nt": + return None + try: + import ctypes + from ctypes import wintypes + + class BasicLimitInformation(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_longlong), + ("PerJobUserTimeLimit", ctypes.c_longlong), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + class IoCounters(ctypes.Structure): + _fields_ = [(name, ctypes.c_ulonglong) for name in ( + "ReadOperationCount", + "WriteOperationCount", + "OtherOperationCount", + "ReadTransferCount", + "WriteTransferCount", + "OtherTransferCount", + )] + + class ExtendedLimitInformation(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", BasicLimitInformation), + ("IoInfo", IoCounters), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR] + kernel32.CreateJobObjectW.restype = wintypes.HANDLE + kernel32.SetInformationJobObject.argtypes = [ + wintypes.HANDLE, + wintypes.INT, + wintypes.LPVOID, + wintypes.DWORD, + ] + kernel32.SetInformationJobObject.restype = wintypes.BOOL + handle = kernel32.CreateJobObjectW(None, None) + if not handle: + return None + info = ExtendedLimitInformation() + # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + info.BasicLimitInformation.LimitFlags = 0x00002000 + if not kernel32.SetInformationJobObject( + handle, + 9, # JobObjectExtendedLimitInformation + ctypes.byref(info), + ctypes.sizeof(info), + ): + _close_windows_handle(handle) + return None + return handle + except Exception: + return None + + +def _resume_windows_process_threads(pid: int) -> bool: + """Resume a process launched with CREATE_SUSPENDED.""" + + if os.name != "nt": return True + try: + import ctypes + from ctypes import wintypes + + class ThreadEntry32(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ThreadID", wintypes.DWORD), + ("th32OwnerProcessID", wintypes.DWORD), + ("tpBasePri", wintypes.LONG), + ("tpDeltaPri", wintypes.LONG), + ("dwFlags", wintypes.DWORD), + ] + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] + kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE + kernel32.Thread32First.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(ThreadEntry32), + ] + kernel32.Thread32First.restype = wintypes.BOOL + kernel32.Thread32Next.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(ThreadEntry32), + ] + kernel32.Thread32Next.restype = wintypes.BOOL + kernel32.OpenThread.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel32.OpenThread.restype = wintypes.HANDLE + kernel32.ResumeThread.argtypes = [wintypes.HANDLE] + kernel32.ResumeThread.restype = wintypes.DWORD + snapshot = kernel32.CreateToolhelp32Snapshot(0x00000004, 0) # TH32CS_SNAPTHREAD + if snapshot == ctypes.c_void_p(-1).value: + return False + resumed = False + try: + entry = ThreadEntry32() + entry.dwSize = ctypes.sizeof(ThreadEntry32) + if not kernel32.Thread32First(snapshot, ctypes.byref(entry)): + return False + while True: + if int(entry.th32OwnerProcessID) == pid: + thread = kernel32.OpenThread( + 0x0002 | 0x0040, # THREAD_SUSPEND_RESUME | THREAD_QUERY_INFORMATION + False, + entry.th32ThreadID, + ) + if thread: + try: + previous = kernel32.ResumeThread(thread) + if previous != 0xFFFFFFFF: + while previous > 1: + previous = kernel32.ResumeThread(thread) + resumed = True + finally: + _close_windows_handle(thread) + if not kernel32.Thread32Next(snapshot, ctypes.byref(entry)): + break + return resumed + finally: + _close_windows_handle(snapshot) + except Exception: + return False + + +def _attach_windows_job(process: subprocess.Popen[bytes], job: object) -> bool: + """Assign and resume a suspended real Popen child.""" + + if os.name != "nt": + return False + try: + import ctypes + from ctypes import wintypes + + process_handle = getattr(process, "_handle") + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.AssignProcessToJobObject.argtypes = [ + wintypes.HANDLE, + wintypes.HANDLE, + ] + kernel32.AssignProcessToJobObject.restype = wintypes.BOOL + if not kernel32.AssignProcessToJobObject(job, process_handle): + _resume_windows_process_threads(int(process.pid)) + _close_windows_handle(job) + return False + if not _resume_windows_process_threads(int(process.pid)): + kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel32.TerminateJobObject.restype = wintypes.BOOL + kernel32.TerminateJobObject(job, 1) + _close_windows_handle(job) + raise OSError("process resume failed") + setattr(process, "_issue62_job_handle", job) + setattr(process, "_issue62_tree_readback_authoritative", True) + return True + except Exception: + _close_windows_handle(job) + raise + + +def _terminate_windows_job(job: object) -> bool: + if os.name != "nt": + return False + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel32.TerminateJobObject.restype = wintypes.BOOL + return bool(kernel32.TerminateJobObject(job, 1)) except Exception: + return False + + +def _windows_job_active_processes(job: object) -> int | None: + if os.name != "nt": + return None + try: + import ctypes + from ctypes import wintypes + + class AccountingInformation(ctypes.Structure): + _fields_ = [ + ("TotalUserTime", ctypes.c_longlong), + ("TotalKernelTime", ctypes.c_longlong), + ("ThisPeriodTotalUserTime", ctypes.c_longlong), + ("ThisPeriodTotalKernelTime", ctypes.c_longlong), + ("TotalPageFaultCount", wintypes.DWORD), + ("TotalProcesses", wintypes.DWORD), + ("ActiveProcesses", wintypes.DWORD), + ("TotalTerminatedProcesses", wintypes.DWORD), + ] + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.QueryInformationJobObject.argtypes = [ + wintypes.HANDLE, + wintypes.INT, + wintypes.LPVOID, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ] + kernel32.QueryInformationJobObject.restype = wintypes.BOOL + info = AccountingInformation() + returned = wintypes.DWORD() + if not kernel32.QueryInformationJobObject( + job, + 1, # JobObjectBasicAccountingInformation + ctypes.byref(info), + ctypes.sizeof(info), + ctypes.byref(returned), + ): + return None + return int(info.ActiveProcesses) + except Exception: + return None + + +def _process_tree_pids(root_pid: int) -> set[int] | None: + """Return the root and currently observable descendants, if available.""" + + if os.name == "nt": + parents = _windows_process_parent_map() + if parents is None: + return None + else: + return _posix_process_tree_pids(root_pid) + + result = {root_pid} + pending = [root_pid] + while pending: + parent = pending.pop() + for pid, ppid in parents.items(): + if ppid == parent and pid not in result: + result.add(pid) + pending.append(pid) + return result + + +def _stabilize_process_tree( + process: subprocess.Popen[bytes], *, timeout_seconds: float = 0.25 +) -> frozenset[int]: + """Observe descendants briefly so cleanup still has their PIDs if the root exits.""" + + root_pid = int(process.pid) + observed = {root_pid} + if not hasattr(process, "_handle"): + return frozenset(observed) + deadline = time.perf_counter() + timeout_seconds + while True: + tree = _process_tree_pids(root_pid) + if tree: + observed.update(tree) + if len(observed) > 1 or process.poll() is not None or time.perf_counter() >= deadline: + return frozenset(observed) + time.sleep(0.01) + + +def _process_tree_is_gone( + process: subprocess.Popen[bytes], tracked_pids: set[int] +) -> bool: + """Read back both the root and descendants before reporting success.""" + + try: + if process.poll() is None: + return False + except Exception: + return False + + job = getattr(process, "_issue62_job_handle", None) + if job is not None: + active = _windows_job_active_processes(job) + return active == 0 + if os.name == "nt" and getattr( + process, "_issue62_tree_readback_authoritative", True + ) is False: + return False + + current = _process_tree_pids(int(process.pid)) + if current is None: + # An unavailable readback is not proof of cleanup. + return False + if current - {int(process.pid)}: + return False + if os.name == "nt": + # Parent IDs can be lost when Windows reparents a descendant after the + # root exits. Check every PID observed before teardown as well. + parents = _windows_process_parent_map() + if parents is None: + return False + if any(pid in parents for pid in tracked_pids if pid != int(process.pid)): + return False + return int(process.pid) not in parents + + # A daemonized child can leave its original process group and be + # reparented after the root exits. The observed PID set is the only + # conservative evidence available on POSIX without a cgroup/pidfd + # supervisor; any still-live observed PID keeps cleanup fail-closed. + for pid in tracked_pids: + if pid == int(process.pid): + continue try: - if os.name == "nt": - process.kill() + os.kill(int(pid), 0) + except ProcessLookupError: + continue + except OSError: + return False + return False + + # ``/proc`` retains the process group even after the session leader exits; + # use the kernel's group existence check as a second readback path. + try: + os.killpg(int(process.pid), 0) + except ProcessLookupError: + return True + except OSError: + return False + return False + + +def _taskkill(pid: int, *, tree: bool) -> None: + command = ["taskkill", "/PID", str(int(pid))] + if tree: + command.append("/T") + command.append("/F") + result = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=5, + ) + # Test doubles may intentionally return no CompletedProcess. A real + # subprocess result with a non-zero code remains a hard stop failure. + if result is not None and getattr(result, "returncode", 0) != 0: + raise OSError("taskkill failed") + + +def _terminate_process( + process: subprocess.Popen[bytes], *, tracked_pids: set[int] | None = None +) -> bool: + """Stop a process tree and return true only after a positive readback.""" + + try: + root_pid = int(process.pid) + except Exception: + return False + known_pids = set(tracked_pids or ()) + known_pids.add(root_pid) + initial_tree = _process_tree_pids(root_pid) + if initial_tree is not None: + known_pids.update(initial_tree) + job = getattr(process, "_issue62_job_handle", None) + + try: + parent_alive = process.poll() is None + except Exception: + parent_alive = False + + try: + if os.name == "nt": + if job is not None: + if not _terminate_windows_job(job): + raise OSError("job termination failed") + elif parent_alive: + # ``taskkill /T`` is the primary Windows tree primitive while + # the root is still open. + _taskkill(root_pid, tree=True) else: - os.killpg(int(process.pid), signal.SIGKILL) - process.wait(timeout=5) + # A dead root cannot be passed to ``taskkill /T`` reliably. + # Kill the descendants observed in the readback snapshot by + # exact PID, then verify that no tree member remains. + for pid in sorted(known_pids - {root_pid}, reverse=True): + _taskkill(pid, tree=False) + else: + # All runner children are session leaders, so the process group + # remains addressable even if the root has already exited. + os.killpg(root_pid, signal.SIGTERM) + process.wait(timeout=5) + if _process_tree_is_gone(process, known_pids): + if job is not None: + _close_windows_handle(job) + setattr(process, "_issue62_job_handle", None) return True - except Exception: - return False + except Exception: + pass + + # Escalate once, but keep the same tracked PID set and require the same + # readback. A successful parent kill alone is never sufficient. + try: + if os.name == "nt": + if job is not None: + _terminate_windows_job(job) + else: + current_tree = _process_tree_pids(root_pid) + descendants = (current_tree or set()) | known_pids + for pid in sorted(descendants - {root_pid}, reverse=True): + _taskkill(pid, tree=False) + if process.poll() is None: + process.kill() + else: + os.killpg(root_pid, signal.SIGKILL) + process.wait(timeout=5) + except Exception: + pass + result = _process_tree_is_gone(process, known_pids) + if job is not None: + _close_windows_handle(job) + setattr(process, "_issue62_job_handle", None) + return result + + +def _windows_launch_options( + process_factory: Callable[..., subprocess.Popen[bytes]], +) -> tuple[int, bool, object | None]: + if os.name != "nt": + return 0, True, None + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) + job: object | None = None + # Test doubles and injected factories are not suspended. Only use the + # job launch handshake with the stdlib Popen implementation. + if getattr(process_factory, "__module__", None) == "subprocess": + job = _create_windows_job() + if job is not None: + creationflags |= getattr(subprocess, "CREATE_SUSPENDED", 0x00000004) + return creationflags, False, job + + +def _finish_windows_launch( + process: subprocess.Popen[bytes], job: object | None +) -> None: + if os.name != "nt": + return + if job is None: + # Test doubles do not expose a native process handle. A real launch + # without a Job Object can still use the Toolhelp parent map as an + # authoritative fallback; if that inventory is unavailable, fail + # closed instead of treating the wrapper PID as the tree. + if hasattr(process, "_handle"): + setattr( + process, + "_issue62_tree_readback_authoritative", + _windows_process_parent_map() is not None, + ) + return + if not _attach_windows_job(process, job): + # Assignment can be unavailable under a constrained Windows token; + # retain the process and use the bounded taskkill/readback fallback + # only when the parent inventory is available. + setattr( + process, + "_issue62_tree_readback_authoritative", + _windows_process_parent_map() is not None, + ) + return class BoundedPhaseRunner: @@ -268,15 +930,32 @@ def __init__( *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, process_factory: Callable[..., subprocess.Popen[bytes]] | None = None, + environment: Mapping[str, str] | None = None, + working_directory: Path | None = None, ) -> None: _ensure_fresh_run_root(run_root) self.run_root = run_root self.identity = identity self.timeout_seconds = _validate_timeout(timeout_seconds) self._process_factory = process_factory or subprocess.Popen + self._working_directory = str(working_directory) if working_directory is not None else None + self._case_environment_root = run_root / "case-env" + self._environment = _safe_environment( + environment or {}, + working_directory=Path(working_directory) + if working_directory is not None + else None, + case_root=self._case_environment_root, + ) self.journal = SanitizedPhaseJournal(run_root, identity) self._active_process: subprocess.Popen[bytes] | None = None + self._active_process_pids: frozenset[int] = frozenset() self._active_lock = threading.Lock() + self._foreground_processes: list[ + tuple[subprocess.Popen[bytes], frozenset[int]] + ] = [] + self._background_processes: dict[str, BackgroundProcess] = {} + self._background_lock = threading.Lock() self._cancel_requested = False self._cleanup_called = False self._children_terminated = True @@ -287,9 +966,148 @@ def _phase_name(phase: str) -> str: raise HarnessFailure("phase_exception") return phase - def _set_active(self, process: subprocess.Popen[bytes] | None) -> None: + def _set_active( + self, + process: subprocess.Popen[bytes] | None, + tracked_pids: frozenset[int] = frozenset(), + ) -> None: with self._active_lock: self._active_process = process + self._active_process_pids = tracked_pids if process is not None else frozenset() + + @staticmethod + def _validate_argv(argv: Sequence[str]) -> bool: + return bool(argv) and all( + isinstance(item, str) and bool(item) and "\x00" not in item for item in argv + ) + + def _prepare_case_environment(self) -> None: + try: + if _is_reparse(self._case_environment_root) or ( + self._case_environment_root.exists() + and not self._case_environment_root.is_dir() + ): + raise OSError + self._case_environment_root.mkdir(parents=True, exist_ok=True) + for directory in CASE_ENVIRONMENT_PATHS.values(): + target = self._case_environment_root / directory + if _is_reparse(target) or (target.exists() and not target.is_dir()): + raise OSError + target.mkdir(parents=True, exist_ok=True) + except OSError as error: + raise HarnessFailure("process_start_failed") from error + + def start_background(self, phase: str, argv: Sequence[str]) -> BackgroundProcess: + """Start one bounded sidecar process without retaining its output.""" + + phase_name = self._phase_name(phase) + self.journal.append( + marker=f"{phase_name}_started", status="started", status_code="phase_started" + ) + if not self._validate_argv(argv): + self.journal.append( + marker=f"{phase_name}_completed", + status="failed", + status_code="process_start_failed", + ) + raise HarnessFailure("process_start_failed") + self._prepare_case_environment() + creationflags, start_new_session, job = _windows_launch_options( + self._process_factory + ) + try: + process = self._process_factory( + list(argv), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=self._environment, + cwd=self._working_directory, + close_fds=True, + creationflags=creationflags, + start_new_session=start_new_session, + shell=False, + ) + except Exception as error: + _close_windows_handle(job) + self.journal.append( + marker=f"{phase_name}_completed", + status="failed", + status_code="process_start_failed", + ) + raise HarnessFailure("process_start_failed") from error + try: + _finish_windows_launch(process, job) + except Exception as error: + try: + process.kill() + except Exception: + pass + self.journal.append( + marker=f"{phase_name}_completed", + status="failed", + status_code="process_start_failed", + ) + raise HarnessFailure("process_start_failed") from error + tracked_pids = _stabilize_process_tree(process) + handle = BackgroundProcess(phase_name, process, tracked_pids) + with self._background_lock: + if phase_name in self._background_processes: + # A duplicate phase would make cleanup ambiguous. Terminate + # the just-started process before failing closed. + _terminate_process(process, tracked_pids=set(tracked_pids)) + raise HarnessFailure("phase_exception") + self._background_processes[phase_name] = handle + return handle + + def stop_background(self, handle: BackgroundProcess, *, status_code: str = "cancelled") -> bool: + """Stop one background child and emit its terminal journal marker.""" + + with self._background_lock: + current = self._background_processes.pop(handle.phase, None) + if current is None: + return True + started = time.monotonic() + terminated = _terminate_process( + current.process, tracked_pids=set(current.tracked_pids) + ) + self._children_terminated = self._children_terminated and terminated + code = status_code if terminated else "termination_failed" + self.journal.append( + marker=f"{current.phase}_completed", + status="completed" if terminated else "failed", + status_code=code, + duration_ms=int((time.monotonic() - started) * 1000), + exit_code=(int(current.process.poll()) if current.process.poll() is not None else None), + ) + return terminated + + def _stop_background_processes(self) -> int: + with self._background_lock: + handles = list(self._background_processes.values()) + failures = 0 + for handle in handles: + if not self.stop_background(handle): + failures += 1 + return failures + + def _stop_foreground_processes(self) -> int: + """Recheck every completed foreground tree during final cleanup.""" + + with self._active_lock: + active = self._active_process + failures = 0 + for process, tracked_pids in self._foreground_processes: + if process is active: + # ``cleanup`` called ``cancel`` first; its result already + # contributes to the failure count when the active process + # could not be stopped. + continue + terminated = _terminate_process(process, tracked_pids=set(tracked_pids)) + self._children_terminated = self._children_terminated and terminated + if not terminated: + failures += 1 + return failures def mark_phase( self, @@ -351,29 +1169,44 @@ def finish(result: CommandResult) -> CommandResult: return finish(CommandResult("cancelled", "cancelled", terminated=True)) if self._cancel_requested: return finish(CommandResult("cancelled", "cancelled", terminated=True)) - if not argv or any(not isinstance(item, str) or not item for item in argv): + if not self._validate_argv(argv): + return finish(CommandResult("failed", "process_start_failed", terminated=True)) + try: + self._prepare_case_environment() + except HarnessFailure: return finish(CommandResult("failed", "process_start_failed", terminated=True)) - creationflags = 0 - start_new_session = False - if os.name == "nt": - creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) - else: - start_new_session = True + creationflags, start_new_session, job = _windows_launch_options( + self._process_factory + ) try: process = self._process_factory( list(argv), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=self._environment, + cwd=self._working_directory, + close_fds=True, creationflags=creationflags, start_new_session=start_new_session, shell=False, ) except Exception: + _close_windows_handle(job) + return finish(CommandResult("failed", "process_start_failed", terminated=True)) + try: + _finish_windows_launch(process, job) + except Exception: + try: + process.kill() + except Exception: + pass return finish(CommandResult("failed", "process_start_failed", terminated=True)) - self._set_active(process) + tracked_pids = _stabilize_process_tree(process) + self._foreground_processes.append((process, tracked_pids)) + self._set_active(process, tracked_pids) try: deadline = time.monotonic() + timeout while True: @@ -427,9 +1260,10 @@ def cancel(self) -> bool: self._cancel_requested = True with self._active_lock: process = self._active_process + tracked_pids = self._active_process_pids if process is None: return True - terminated = _terminate_process(process) + terminated = _terminate_process(process, tracked_pids=set(tracked_pids)) self._children_terminated = self._children_terminated and terminated return terminated @@ -450,12 +1284,16 @@ def cleanup(self, actions: Sequence[CleanupAction] = ()) -> dict[str, object]: self.journal.append(marker="cleanup_started", status="started", status_code="phase_started") if not self.cancel(): failures += 1 + failures += self._stop_foreground_processes() + failures += self._stop_background_processes() for action in reversed(tuple(actions)): try: if action() is False: failures += 1 except Exception: failures += 1 + if not _remove_directory(self._case_environment_root): + failures += 1 extra = _count_extra_artifacts(self.run_root) if extra: failures += 1 @@ -531,6 +1369,1207 @@ def append( self.records.append(record) +def _live_plan_fail(code: str) -> NoReturn: + raise LiveControlValidationError(code) + + +def _validate_plan_argv(value: Any, code: str = "live_control_plan_invalid") -> list[str]: + if not isinstance(value, list) or not value or any( + not isinstance(item, str) + or not item + or "\x00" in item + or Path(item).is_absolute() + or re.match(r"^(?:[A-Za-z]:|[\\/]{2})", item) is not None + or ".." in Path(item).parts + for item in value + ): + _live_plan_fail(code) + # Return a fresh list so callers cannot mutate the source object while a + # plan is executing. + return list(value) + + +def _validate_plan_path(value: Any) -> str: + """Validate a relative plan path before it is joined to an isolated root.""" + + if not isinstance(value, str) or not value or "\x00" in value: + _live_plan_fail("plan_path_invalid") + candidate = Path(value) + # ``Path.is_absolute`` covers native Windows/POSIX roots; the explicit + # drive/UNC checks cover paths written for the other platform. + if candidate.is_absolute() or re.match(r"^(?:[A-Za-z]:|[\\/]{2})", value): + _live_plan_fail("plan_path_invalid") + if any(part in {"..", "."} for part in candidate.parts): + _live_plan_fail("plan_path_invalid") + return value + + +def _is_reparse(path: Path) -> bool: + try: + stat_result = os.lstat(path) + mode = stat_result.st_mode + if stat.S_ISLNK(mode): + return True + attributes = getattr(stat_result, "st_file_attributes", 0) + return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) + except OSError: + return False + + +def _resolve_isolated_path( + value: Any, + *, + isolated_root: Path, + must_exist: bool = False, + allow_missing_parents: bool = False, +) -> str: + relative = _validate_plan_path(value) + try: + root = isolated_root.resolve(strict=True) + except OSError: + _live_plan_fail("plan_path_outside_isolation") + if _is_reparse(root): + _live_plan_fail("plan_path_linked") + target = root / relative + try: + resolved = target.resolve(strict=must_exist) + resolved.relative_to(root) + except (OSError, ValueError): + _live_plan_fail("plan_path_outside_isolation") + current = root + parts = Path(relative).parts + for index, part in enumerate(parts): + current = current / part + exists = current.exists() + if not exists: + if (index != len(parts) - 1 and not allow_missing_parents) or must_exist: + _live_plan_fail("plan_path_outside_isolation") + continue + if _is_reparse(current): + _live_plan_fail("plan_path_linked") + try: + mode = os.lstat(current).st_mode + if current.is_file() and getattr(os.stat(current), "st_nlink", 1) > 1: + _live_plan_fail("plan_path_linked") + if stat.S_ISREG(mode) and index != len(parts) - 1: + _live_plan_fail("plan_path_outside_isolation") + except OSError: + _live_plan_fail("plan_path_outside_isolation") + return str(resolved) + + +def _environment_value_is_local( + key: str, value: str, *, working_directory: Path | None = None +) -> bool: + """Reject host path injection in child environment overrides.""" + + if key not in LOCAL_ENV_PATH_KEYS: + return True + if working_directory is None: + return False + root = working_directory.resolve(strict=False) if working_directory is not None else None + for entry in value.split(os.pathsep): + if not entry: + continue + candidate = Path(entry) + if candidate.is_absolute() or re.match(r"^(?:[A-Za-z]:|[\\/]{2})", entry): + if root is None: + return False + try: + candidate.resolve(strict=False).relative_to(root) + except (OSError, ValueError, RuntimeError): + return False + elif ".." in candidate.parts: + return False + return True + + +def _validate_environment( + value: Any, *, isolated_root: Path | None = None +) -> dict[str, str]: + if not isinstance(value, Mapping): + _live_plan_fail("environment_invalid") + normalized: dict[str, str] = {} + for key, item in value.items(): + if ( + not isinstance(key, str) + or key in SENSITIVE_ENV_KEYS + or key not in LIVE_ENV_KEYS + or not isinstance(item, str) + or "\x00" in item + or len(item) > 4096 + or not _environment_value_is_local( + key, item, working_directory=isolated_root + ) + ): + _live_plan_fail("environment_invalid") + normalized[key] = item + return normalized + + +def _safe_environment( + overrides: Mapping[str, str], + *, + working_directory: Path | None = None, + case_root: Path | None = None, +) -> dict[str, str]: + """Build a minimal child environment with case-local home state.""" + + result: dict[str, str] = {} + for key in ("SystemRoot", "ComSpec"): + value = os.environ.get(key) + if value: + result[key] = value + for key, value in overrides.items(): + if not _environment_value_is_local( + key, value, working_directory=working_directory + ): + continue + result[key] = value + # Never copy sensitive values from arbitrary direct callers. The public + # runner constructor is also used by fixture tests, so this function must + # enforce the credential/home boundary itself rather than relying on the + # live-plan validator. + for key in SENSITIVE_ENV_KEYS: + result.pop(key, None) + if case_root is not None: + root = case_root.resolve(strict=False) + for key, directory in CASE_ENVIRONMENT_PATHS.items(): + result[key] = str(root / directory) + return result + + +def _validate_executable_spec( + value: Any, + *, + isolated_root: Path | None, + error_code: str, + extra_fields: frozenset[str] = frozenset(), +) -> dict[str, Any]: + expected = frozenset( + {"argv", "executable_file", "executable_sha256", "argv_file_digests"} + ) | extra_fields + if not isinstance(value, Mapping) or frozenset(value) != expected: + _live_plan_fail(error_code) + argv = _validate_plan_argv(value.get("argv")) + executable_file = _validate_plan_path(value.get("executable_file")) + digest = value.get("executable_sha256") + if not isinstance(digest, str) or re.fullmatch(r"[0-9a-fA-F]{64}", digest) is None: + _live_plan_fail(error_code) + if argv[0] != executable_file: + _live_plan_fail(error_code) + raw_files = value.get("argv_file_digests") + if not isinstance(raw_files, Mapping): + _live_plan_fail(error_code) + file_digests: dict[str, str] = {} + for path, file_digest in raw_files.items(): + if not isinstance(path, str) or not isinstance(file_digest, str): + _live_plan_fail(error_code) + if re.fullmatch(r"[0-9a-fA-F]{64}", file_digest) is None: + _live_plan_fail(error_code) + if isolated_root is not None: + resolved_file = _resolve_isolated_path( + path, isolated_root=isolated_root, must_exist=True + ) + if _file_sha256(resolved_file) != file_digest.lower(): + _live_plan_fail(error_code) + file_digests[path] = file_digest.lower() + if isolated_root is not None: + resolved = _resolve_isolated_path( + executable_file, isolated_root=isolated_root, must_exist=True + ) + if _file_sha256(resolved) != digest.lower(): + _live_plan_fail(error_code) + return { + "argv": argv, + "executable_file": executable_file, + "executable_sha256": digest.lower(), + "argv_file_digests": file_digests, + **{ + key: value[key] + for key in extra_fields + if key in value + }, + } + + +def _file_sha256(path: str) -> str: + try: + target = Path(path) + if target.is_symlink() or not target.is_file() or os.stat(target).st_nlink != 1: + raise OSError + digest = hashlib.sha256() + with target.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + except OSError: + return "" + + +def _validate_live_binding( + identity: CandidateIdentity, + binding: Mapping[str, Any], + *, + isolated_root: Path | None, +) -> dict[str, str]: + expected_fields = frozenset( + {"candidate_sha_file", "cli_package_file", "catalog_file", "route_file"} + ) + if frozenset(binding) != expected_fields: + _live_plan_fail("candidate_binding_mismatch") + if isolated_root is None: + paths = {key: _validate_plan_path(binding[key]) for key in expected_fields} + else: + paths = { + key: _resolve_isolated_path( + binding[key], isolated_root=isolated_root, must_exist=True + ) + for key in expected_fields + } + try: + candidate_text = Path(paths["candidate_sha_file"]).read_text(encoding="ascii").strip() + except (OSError, UnicodeError): + _live_plan_fail("candidate_sha_binding_mismatch") + if candidate_text.lower() != identity.candidate_sha: + _live_plan_fail("candidate_sha_binding_mismatch") + checks = ( + ("cli_package_file", identity.cli_package_sha256, "cli_package_binding_mismatch"), + ("catalog_file", identity.catalog_digest, "catalog_binding_mismatch"), + ("route_file", identity.route_digest, "route_binding_mismatch"), + ) + for field, expected, code in checks: + if _file_sha256(paths[field]) != expected: + _live_plan_fail(code) + return paths + + +def _validate_sidecar_spec(value: Any, *, isolated_root: Path | None) -> dict[str, Any]: + if not isinstance(value, Mapping) or frozenset(value) != frozenset( + { + "argv", + "executable_file", + "executable_sha256", + "argv_file_digests", + "output_dir", + "hmac_key_file", + "hmac_key_sha256", + } + ): + _live_plan_fail("helper_executable_binding_mismatch") + executable = _validate_executable_spec( + { + key: value[key] + for key in ( + "argv", + "executable_file", + "executable_sha256", + "argv_file_digests", + ) + }, + isolated_root=isolated_root, + error_code="helper_executable_binding_mismatch", + ) + output_dir = _validate_plan_path(value.get("output_dir")) + hmac_key_file = _validate_plan_path(value.get("hmac_key_file")) + hmac_key_sha256 = value.get("hmac_key_sha256") + if not isinstance(hmac_key_sha256, str) or re.fullmatch( + r"[0-9a-fA-F]{64}", hmac_key_sha256 + ) is None: + _live_plan_fail("helper_executable_binding_mismatch") + normalized_key_path = Path(hmac_key_file).as_posix().lower() + key_option_bound = any( + item == "--hmac-key-file" + and index + 1 < len(executable["argv"]) + and Path(executable["argv"][index + 1]).as_posix().lower() == normalized_key_path + for index, item in enumerate(executable["argv"]) + ) + if not key_option_bound: + _live_plan_fail("helper_executable_binding_mismatch") + if isolated_root is not None: + output_dir = _resolve_isolated_path( + output_dir, isolated_root=isolated_root, allow_missing_parents=True + ) + hmac_key_path = _resolve_isolated_path( + hmac_key_file, isolated_root=isolated_root, must_exist=True + ) + if _file_sha256(hmac_key_path) != hmac_key_sha256.lower(): + _live_plan_fail("helper_executable_binding_mismatch") + return { + **executable, + "output_dir": output_dir, + "hmac_key_file": hmac_key_file, + "hmac_key_sha256": hmac_key_sha256.lower(), + } + + +def _validate_sidecars( + value: Any, *, isolated_root: Path | None, run_root: Path | None +) -> dict[str, list[dict[str, Any]]]: + if not isinstance(value, Mapping) or frozenset(value) != frozenset({"pre", "post"}): + _live_plan_fail("live_control_plan_invalid") + result: dict[str, list[dict[str, Any]]] = {} + output_dirs: set[str] = set() + hmac_key_bindings: set[tuple[str, str]] = set() + for hop in ("pre", "post"): + raw = value.get(hop) + specs = raw if isinstance(raw, list) else [raw] + if not specs or len(specs) > len(CONTROL_NAMES): + _live_plan_fail("live_control_plan_invalid") + normalized = [ + _validate_sidecar_spec(item, isolated_root=isolated_root) for item in specs + ] + for spec in normalized: + hmac_key_bindings.add( + (str(spec["hmac_key_file"]), str(spec["hmac_key_sha256"])) + ) + directory_path = Path(spec["output_dir"]) + if run_root is not None: + try: + directory_path.resolve().relative_to(run_root.resolve()) + except ValueError: + _live_plan_fail("plan_path_outside_isolation") + directory = str(directory_path.resolve()) + if directory in output_dirs or any( + Path(directory).is_relative_to(Path(existing)) + or Path(existing).is_relative_to(Path(directory)) + for existing in output_dirs + ): + _live_plan_fail("live_control_plan_invalid") + output_dirs.add(directory) + result[hop] = normalized + if len(hmac_key_bindings) != 1: + _live_plan_fail("helper_executable_binding_mismatch") + return result + + +def load_live_control_plan( + source: Path | str | Mapping[str, Any], + *, + isolated_root: Path | None = None, + run_root: Path | None = None, +) -> dict[str, Any]: + """Load and independently bind an explicitly authorized live plan. + + This function performs no subprocess or network work. It is deliberately + strict: a plan must carry all candidate/catalog/route digest bindings and + exactly the eight semantic control labels before the live runner can start. + """ + + if isinstance(source, (str, Path)): + source_path = Path(source) + if ( + source_path.is_symlink() + or not source_path.exists() + or not source_path.is_file() + or os.stat(source_path).st_nlink != 1 + ): + _live_plan_fail("live_control_plan_missing") + try: + payload = json.loads(source_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + _live_plan_fail("live_control_plan_invalid") + else: + payload = source + if not isinstance(payload, Mapping) or frozenset(payload) != LIVE_PLAN_FIELDS: + _live_plan_fail("live_control_plan_invalid") + if payload.get("schema") != LIVE_PLAN_SCHEMA or payload.get("verification_scope") != LIVE_SCOPE: + _live_plan_fail("live_control_plan_invalid") + if payload.get("isolation_root") != ".": + _live_plan_fail("plan_path_invalid") + if isolated_root is not None: + try: + isolated_root = isolated_root.resolve(strict=True) + except OSError: + _live_plan_fail("plan_path_outside_isolation") + + raw_identity = payload.get("candidate_identity") + if not isinstance(raw_identity, Mapping): + _live_plan_fail("candidate_binding_mismatch") + identity_fields = frozenset( + {"candidate_sha", "cli_version", "cli_package_sha256", "catalog_digest", "route_digest"} + ) + if frozenset(raw_identity) not in (identity_fields, identity_fields | {"cli_source_commit"}): + _live_plan_fail("candidate_binding_mismatch") + try: + identity = CandidateIdentity( + candidate_sha=raw_identity["candidate_sha"], + cli_version=raw_identity["cli_version"], + cli_package_sha256=raw_identity["cli_package_sha256"], + catalog_digest=raw_identity["catalog_digest"], + route_digest=raw_identity["route_digest"], + cli_source_commit=raw_identity.get("cli_source_commit"), + ) + except (KeyError, TypeError, ValueError): + _live_plan_fail("candidate_binding_mismatch") + + binding = payload.get("binding") + if not isinstance(binding, Mapping): + _live_plan_fail("candidate_binding_mismatch") + normalized_binding = _validate_live_binding( + identity, binding, isolated_root=isolated_root + ) + + model = payload.get("catalog_model_entry_id") + if ( + not isinstance(model, str) + or re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", model) is None + or model != "gpt-5.6-sol" + ): + _live_plan_fail("catalog_binding_mismatch") + + cli = payload.get("cli") + if not isinstance(cli, Mapping) or frozenset(cli) != frozenset( + { + "argv", + "executable_file", + "executable_sha256", + "argv_file_digests", + "cli_version", + } + ): + _live_plan_fail("cli_executable_binding_mismatch") + if cli.get("cli_version") != identity.cli_version: + _live_plan_fail("cli_executable_binding_mismatch") + normalized_cli = _validate_executable_spec( + cli, + isolated_root=isolated_root, + error_code="cli_executable_binding_mismatch", + extra_fields=frozenset({"cli_version"}), + ) + normalized_cli["cli_version"] = cli["cli_version"] + normalized_sidecars = _validate_sidecars( + payload.get("sidecars"), isolated_root=isolated_root, run_root=run_root + ) + normalized_environment = _validate_environment( + payload.get("environment"), isolated_root=isolated_root + ) + planner = _validate_live_planner( + payload.get("planner"), + identity=identity, + model=model, + ) + + controls = payload.get("controls") + if not isinstance(controls, list) or len(controls) != len(CONTROL_NAMES): + _live_plan_fail("control_labels_incomplete") + normalized_controls: list[dict[str, Any]] = [] + names: list[str] = [] + record_paths: dict[str, set[str]] = {"pre": set(), "post": set()} + allowed_control_fields = frozenset({"name", "args", "capture", "pre_record", "post_record"}) + for item in controls: + if not isinstance(item, Mapping) or not frozenset(item).issubset(allowed_control_fields): + _live_plan_fail("live_control_plan_invalid") + name = item.get("name") + if not isinstance(name, str) or name not in CONTROL_NAME_SET: + _live_plan_fail("control_labels_incomplete") + names.append(name) + raw_args = item.get("args", []) + normalized: dict[str, Any] = { + "name": name, + "args": _validate_plan_argv(raw_args, "live_control_plan_invalid") + if raw_args != [] + else [], + "capture": item.get("capture", {}), + } + if not isinstance(normalized["capture"], Mapping): + _live_plan_fail("live_control_plan_invalid") + for field in ("pre_record", "post_record"): + if field in item: + if isolated_root is None: + normalized[field] = _validate_plan_path(item[field]) + else: + normalized[field] = _resolve_isolated_path( + item[field], isolated_root=isolated_root, allow_missing_parents=True + ) + normalized_path = str(Path(normalized[field]).resolve()) + if normalized_path in record_paths[field.removesuffix("_record")]: + _live_plan_fail("sidecar_capture_incomplete") + record_paths[field.removesuffix("_record")].add(normalized_path) + normalized_controls.append(normalized) + if set(names) != CONTROL_NAME_SET or len(set(names)) != len(names): + _live_plan_fail("control_labels_incomplete") + + replays = payload.get("replays") + replay_names = frozenset({"identity", "mutation", "deletion", "loss"}) + if not isinstance(replays, Mapping) or frozenset(replays) != replay_names: + _live_plan_fail("identity_replay_incomplete") + normalized_replays: dict[str, dict[str, Any]] = {} + for name in replay_names: + spec = replays.get(name) + if not isinstance(spec, Mapping) or frozenset(spec) != frozenset( + { + "argv", + "executable_file", + "executable_sha256", + "argv_file_digests", + "artifact_file", + "case", + } + ) or spec.get("case") != name: + _live_plan_fail("identity_replay_incomplete") + replay = _validate_executable_spec( + spec, + isolated_root=isolated_root, + error_code="helper_executable_binding_mismatch", + extra_fields=frozenset({"artifact_file", "case"}), + ) + artifact_file = _validate_plan_path(replay.get("artifact_file")) + if isolated_root is not None: + artifact_file = _resolve_isolated_path( + artifact_file, isolated_root=isolated_root, allow_missing_parents=True + ) + if run_root is not None: + try: + Path(artifact_file).resolve().relative_to(run_root.resolve()) + except ValueError: + _live_plan_fail("plan_path_outside_isolation") + replay["artifact_file"] = artifact_file + normalized_replays[name] = replay + + return { + "schema": LIVE_PLAN_SCHEMA, + "verification_scope": LIVE_SCOPE, + "isolation_root": ".", + "candidate_identity": identity.as_dict(), + "binding": normalized_binding, + "catalog_model_entry_id": model, + "cli": normalized_cli, + "sidecars": normalized_sidecars, + "controls": normalized_controls, + "replays": normalized_replays, + "environment": normalized_environment, + "planner": planner, + } + + +def _load_manifest_builder() -> Any: + # The evidence scripts are intentionally loosely coupled. Importing the + # builder lazily keeps fixture mode usable from a source checkout and does + # not add any production routing dependency. + try: + from build_issue_62_control_manifest import ( # type: ignore[import-not-found] + ManifestValidationError, + build_manifest, + reconcile_manifest, + replay_manifest, + ) + except ImportError as error: + raise HarnessFailure("phase_exception") from error + return ManifestValidationError, build_manifest, reconcile_manifest, replay_manifest + + +def _validate_live_planner( + value: Any, + *, + identity: CandidateIdentity, + model: str, +) -> dict[str, Any]: + """Validate and bind the v2 planner before any child process starts.""" + + if not isinstance(value, Mapping) or frozenset(value) != PLANNER_FIELDS: + _live_plan_fail("planner_plan_incomplete") + try: + from build_issue_62_control_manifest import ( # type: ignore[import-not-found] + ManifestValidationError, + validate_planner, + ) + except ImportError: + _live_plan_fail("live_control_plan_invalid") + + candidate_identity = { + "cli_version": identity.cli_version, + "cli_package_sha256": identity.cli_package_sha256, + "codexhub_candidate_sha": identity.candidate_sha, + "catalog_snapshot_sha256": identity.catalog_digest, + "catalog_model_entry_id": model, + "route_digest": identity.route_digest, + } + route_identity = { + "upstream": "official", + "model": model, + "inbound_format": "responses", + "route_digest": identity.route_digest, + } + try: + return validate_planner( + value, + verification_scope=LIVE_SCOPE, + candidate_identity=candidate_identity, + route_identity=route_identity, + ) + except ManifestValidationError as error: + # Keep live-runner failures fixed, path-free, and independent of the + # manifest module's internal validation vocabulary. + code = str(error) + if code == "planner_input_binding_mismatch": + _live_plan_fail("candidate_binding_mismatch") + if code == "planner_route_binding_mismatch": + _live_plan_fail("route_binding_mismatch") + if code in {"planner_disposition_invalid", "planner_synthetic_evidence_invalid"}: + _live_plan_fail("planner_disposition_invalid") + _live_plan_fail("planner_plan_incomplete") + + +def _prepare_capture_dirs(sidecars: Mapping[str, Sequence[Mapping[str, Any]]]) -> None: + for specs in sidecars.values(): + for spec in specs: + target = Path(str(spec["output_dir"])) + try: + if target.exists(): + if target.is_symlink() or not target.is_dir() or any(target.iterdir()): + raise OSError + else: + target.mkdir(parents=True, exist_ok=False) + except OSError: + raise LiveControlValidationError("sidecar_capture_missing") + + +def _record_path_for_control( + control: Mapping[str, Any], + *, + hop: str, + specs: Sequence[Mapping[str, Any]], + index: int, +) -> Path: + def capture_files(root: Path) -> list[Path]: + try: + if root.is_symlink() or not root.is_dir(): + raise OSError + entries = list(root.iterdir()) + except OSError: + raise LiveControlValidationError("sidecar_capture_missing") + if len(entries) > len(CONTROL_NAMES) or any( + entry.is_symlink() + or entry.is_dir() + or entry.suffix.lower() != ".json" + or os.stat(entry).st_nlink != 1 + for entry in entries + ): + raise LiveControlValidationError("sidecar_capture_incomplete") + return sorted(entry for entry in entries if entry.is_file()) + + explicit = control.get(f"{hop}_record") + if explicit is not None: + path = Path(str(explicit)) + for spec in specs: + root = Path(str(spec["output_dir"])) + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + continue + if path.resolve() not in {candidate.resolve() for candidate in capture_files(root)}: + raise LiveControlValidationError("sidecar_capture_missing") + return path + raise LiveControlValidationError("sidecar_capture_missing") + if len(specs) == len(CONTROL_NAMES): + root = Path(str(specs[index]["output_dir"])) + candidates = capture_files(root) + if len(candidates) == 1: + return candidates[0] + raise LiveControlValidationError("sidecar_capture_missing") + if len(specs) == 1: + root = Path(str(specs[0]["output_dir"])) + candidates = capture_files(root) + if len(candidates) == len(CONTROL_NAMES): + return candidates[index] + raise LiveControlValidationError("sidecar_capture_missing") + + +def _read_sidecar_record( + path: Path, + *, + hop: str, + run_nonce: str, + capture_key: bytes, +) -> dict[str, Any]: + try: + if path.is_symlink() or not path.is_file() or os.stat(path).st_nlink != 1: + raise OSError + record = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + raise LiveControlValidationError("sidecar_capture_incomplete") + try: + _ManifestValidationError, _build_manifest, _reconcile_manifest, _replay_manifest = ( + _load_manifest_builder() + ) + from build_issue_62_control_manifest import sanitize_sidecar_record # type: ignore[import-not-found] + from capture_issue_62_live_evidence import _producer_hmac_sha256 # type: ignore[import-not-found] + + # Validate against the capture-only schema, but keep the raw + # transport metadata in memory for ``build_manifest``. The builder + # drops capture IDs/hop fields before writing the canonical manifest. + if record.get("run_nonce") != run_nonce: + raise LiveControlValidationError("sidecar_capture_incomplete") + expected_producer_hmac = _producer_hmac_sha256(capture_key, record, run_nonce) + if not hmac.compare_digest(str(record.get("producer_hmac_sha256")), expected_producer_hmac): + raise LiveControlValidationError("sidecar_capture_incomplete") + sanitize_sidecar_record(record, expected_hop=hop) + return dict(record) + except Exception as error: + if isinstance(error, LiveControlValidationError): + raise + raise LiveControlValidationError("sidecar_capture_incomplete") from error + + +def _build_live_controls( + plan: Mapping[str, Any], + *, + run_nonce: str, + isolated_root: Path | None = None, +) -> list[dict[str, Any]]: + sidecars = plan["sidecars"] + try: + key_file = str(sidecars["pre"][0]["hmac_key_file"]) + key_path = Path( + _resolve_isolated_path( + key_file, isolated_root=isolated_root, must_exist=True + ) + if isolated_root is not None + else key_file + ) + capture_key = key_path.read_bytes() + except (KeyError, OSError, TypeError): + raise LiveControlValidationError("sidecar_capture_incomplete") from None + if not 32 <= len(capture_key) <= 4096: + raise LiveControlValidationError("sidecar_capture_incomplete") + controls: list[dict[str, Any]] = [] + for index, control in enumerate(plan["controls"]): + semantic = dict(control.get("capture", {})) + semantic["name"] = control["name"] + semantic["pre"] = _read_sidecar_record( + _record_path_for_control(control, hop="pre", specs=sidecars["pre"], index=index), + hop="pre", + run_nonce=run_nonce, + capture_key=capture_key, + ) + semantic["post"] = _read_sidecar_record( + _record_path_for_control(control, hop="post", specs=sidecars["post"], index=index), + hop="post", + run_nonce=run_nonce, + capture_key=capture_key, + ) + controls.append(semantic) + return controls + + +def _manifest_candidate(plan: Mapping[str, Any]) -> dict[str, Any]: + identity = plan["candidate_identity"] + return { + "codexhub_candidate_sha": identity["candidate_sha"], + "cli_version": identity["cli_version"], + "cli_source_commit": identity.get("cli_source_commit"), + "cli_source_commit_status": ( + "published" if identity.get("cli_source_commit") else "not_published_by_registry" + ), + "cli_package_sha256": identity["cli_package_sha256"], + "catalog_snapshot_sha256": identity["catalog_digest"], + "catalog_model_entry_id": plan["catalog_model_entry_id"], + "route_digest": identity["route_digest"], + } + + +def _runtime_argv( + spec: Mapping[str, Any], *, isolated_root: Path, run_nonce: str | None = None +) -> list[str]: + executable = _resolve_isolated_path( + spec["executable_file"], isolated_root=isolated_root, must_exist=True + ) + for path, expected in spec.get("argv_file_digests", {}).items(): + resolved = _resolve_isolated_path(path, isolated_root=isolated_root, must_exist=True) + if _file_sha256(resolved) != expected: + raise LiveControlValidationError("helper_executable_binding_mismatch") + if "hmac_key_file" in spec or "hmac_key_sha256" in spec: + if "hmac_key_file" not in spec or "hmac_key_sha256" not in spec: + raise LiveControlValidationError("helper_executable_binding_mismatch") + key_path = _resolve_isolated_path( + spec["hmac_key_file"], isolated_root=isolated_root, must_exist=True + ) + if _file_sha256(key_path) != spec["hmac_key_sha256"]: + raise LiveControlValidationError("helper_executable_binding_mismatch") + argv = list(spec["argv"]) + argv[0] = executable + if run_nonce is not None: + try: + nonce_index = argv.index("--run-nonce") + except ValueError: + argv.extend(["--run-nonce", run_nonce]) + else: + if nonce_index + 1 >= len(argv): + argv.append(run_nonce) + else: + argv[nonce_index + 1] = run_nonce + return argv + + +def _remove_capture_dirs( + sidecars: Mapping[str, Sequence[Mapping[str, Any]]], *, run_root: Path +) -> bool: + failures = 0 + root = run_root.resolve() + for specs in sidecars.values(): + for spec in specs: + target = Path(str(spec["output_dir"])).resolve() + try: + target.relative_to(root) + if target == root: + raise OSError + if target.is_symlink(): + target.unlink() + elif target.exists(): + if not _remove_directory(target): + raise OSError + except (OSError, ValueError): + failures += 1 + return failures == 0 + + +def _remove_replay_artifacts( + replays: Mapping[str, Mapping[str, Any]], *, run_root: Path +) -> bool: + failures = 0 + root = run_root.resolve() + for replay in replays.values(): + target = Path(str(replay["artifact_file"])).resolve() + try: + target.relative_to(root) + if target.is_symlink(): + target.unlink() + elif target.exists(): + target.unlink() + except (OSError, ValueError): + failures += 1 + return failures == 0 + + +def _remove_directory(path: Path) -> bool: + try: + if path.is_symlink(): + path.unlink() + elif path.exists(): + if not path.is_dir(): + return False + for index, _nested in enumerate(path.rglob("*"), start=1): + if index >= MAX_NESTED_ARTIFACT_SCAN: + return False + shutil.rmtree(path) + return not path.exists() + except OSError: + return False + + +def _ensure_replay_artifacts_fresh( + replays: Mapping[str, Mapping[str, Any]], *, run_root: Path +) -> None: + """Reject a reused replay output before any child process starts.""" + + root = run_root.resolve() + seen: set[Path] = set() + for replay in replays.values(): + target = Path(str(replay["artifact_file"])).resolve() + try: + target.relative_to(root) + except ValueError: + raise LiveControlValidationError("plan_path_outside_isolation") + if target in seen or target.exists() or target.is_symlink(): + raise LiveControlValidationError("stale_artifact") + seen.add(target) + + +def _validate_replay_artifact( + path: Path, + *, + case: str, + candidate_sha: str, + manifest_sha: str, +) -> str: + # Diagnostic kept path-free; only existence is observed. + # (The caller redacts all path values from journal artifacts.) + try: + if path.is_symlink() or not path.is_file(): + raise OSError + artifact_sha = _file_sha256(str(path)) + if re.fullmatch(r"[0-9a-f]{64}", artifact_sha) is None: + raise OSError + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + raise LiveControlValidationError("identity_replay_artifact_missing") + expected_fields = frozenset( + {"schema", "case", "candidate_sha", "capture_manifest_sha256", "wire_replay", "outcome"} + ) + if not isinstance(value, Mapping) or frozenset(value) != expected_fields: + raise LiveControlValidationError("identity_replay_artifact_invalid") + if ( + value.get("schema") != "codexhub.issue62.identity-replay.v1" + or value.get("case") != case + or value.get("candidate_sha") != candidate_sha + or value.get("capture_manifest_sha256") != manifest_sha + or value.get("wire_replay") is not True + or value.get("outcome") != ("accepted" if case == "identity" else "rejected") + ): + raise LiveControlValidationError("identity_replay_artifact_invalid") + return artifact_sha + + +def run_live_control( + plan: Path | str | Mapping[str, Any], + *, + run_root: Path, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + manifest_out: Path | None = None, + isolated_root: Path | None = None, + cancellation_event: threading.Event | None = None, +) -> dict[str, Any]: + """Execute an explicitly supplied live plan through bounded children. + + The result is intentionally non-qualifying even when every command, + capture, and negative replay check succeeds. An independent maintainer + review is still required before any Issue #62 claim can be made. + """ + + try: + if isolated_root is None: + raise LiveControlValidationError("plan_path_outside_isolation") + raw_isolation = Path(isolated_root) + if _is_reparse(raw_isolation): + raise LiveControlValidationError("plan_path_linked") + isolation = raw_isolation.resolve(strict=True) + if isinstance(plan, (str, Path)): + raw_plan_path = Path(plan) + if ( + raw_plan_path.is_symlink() + or not raw_plan_path.is_file() + or os.stat(raw_plan_path).st_nlink != 1 + ): + raise LiveControlValidationError("plan_path_linked") + plan_path = raw_plan_path.resolve(strict=True) + plan_path.relative_to(isolation) + run_path = Path(run_root) + if not run_path.is_absolute(): + run_path = isolation / run_path + if _is_reparse(run_path): + raise LiveControlValidationError("plan_path_linked") + run_path = run_path.resolve(strict=False) + run_path.relative_to(isolation) + if run_path == isolation or _is_reparse(run_path): + raise LiveControlValidationError("plan_path_outside_isolation") + except (OSError, ValueError, RuntimeError): + raise LiveControlValidationError("plan_path_outside_isolation") + try: + loaded = load_live_control_plan( + plan, isolated_root=isolation, run_root=run_path + ) + identity = CandidateIdentity(**loaded["candidate_identity"]) + except LiveControlValidationError: + raise + except (OSError, ValueError, RuntimeError): + raise LiveControlValidationError("live_control_plan_invalid") + output_manifest: Path | None = None + if manifest_out is not None: + try: + output_manifest = Path(manifest_out) + if not output_manifest.is_absolute(): + output_manifest = isolation / output_manifest + if output_manifest.is_symlink(): + raise LiveControlValidationError("plan_path_linked") + output_manifest = output_manifest.resolve(strict=False) + output_manifest.relative_to(isolation) + try: + output_manifest.relative_to(run_path) + except ValueError: + pass + else: + raise LiveControlValidationError("plan_path_outside_isolation") + if output_manifest.exists(): + raise LiveControlValidationError("stale_artifact") + except LiveControlValidationError: + raise + except (OSError, ValueError, RuntimeError): + raise LiveControlValidationError("plan_path_outside_isolation") + case_temp = run_path / "temp" + child_environment = dict(loaded["environment"]) + child_environment["TEMP"] = str(case_temp) + child_environment["TMP"] = str(case_temp) + try: + runner = BoundedPhaseRunner( + run_path, + identity, + timeout_seconds=timeout_seconds, + environment=child_environment, + working_directory=isolation, + ) + except HarnessFailure: + raise + except (OSError, ValueError, RuntimeError): + raise LiveControlValidationError("live_control_plan_invalid") + handles: list[BackgroundProcess] = [] + # Every invocation gets a fresh correlation namespace. The value is + # injected into both sidecar argv vectors and never enters sanitized + # orchestration artifacts except through the sidecar producer binding. + run_nonce = secrets.token_hex(16) + result_status = "completed" + result_code = "ok" + manifest: dict[str, Any] | None = None + manifest_reconciled = False + replay_results: dict[str, str] = {} + cleanup_actions: tuple[CleanupAction, ...] = ( + lambda: _remove_capture_dirs(loaded["sidecars"], run_root=run_path), + lambda: _remove_replay_artifacts(loaded["replays"], run_root=run_path), + lambda: _remove_directory(case_temp), + ) + + def is_cancelled() -> bool: + return cancellation_event is not None and cancellation_event.is_set() + + try: + runner.mark_phase("binding_validated") + _ensure_replay_artifacts_fresh(loaded["replays"], run_root=run_path) + if is_cancelled(): + result_status, result_code = "cancelled", "cancelled" + else: + try: + case_temp.mkdir(parents=False, exist_ok=False) + except OSError: + result_status, result_code = "failed", "stale_artifact" + if result_status == "completed": + _prepare_capture_dirs(loaded["sidecars"]) + for hop in ("pre", "post"): + for index, spec in enumerate(loaded["sidecars"][hop]): + try: + handles.append( + runner.start_background( + f"{hop}_sidecar_{index}", + _runtime_argv( + spec, isolated_root=isolation, run_nonce=run_nonce + ), + ) + ) + except HarnessFailure as error: + result_status, result_code = "failed", error.code + break + if result_status != "completed": + break + + if result_status == "completed" and any( + handle.process.poll() is not None for handle in handles + ): + result_status, result_code = "failed", "sidecar_capture_incomplete" + if result_status == "completed": + for control in loaded["controls"]: + if is_cancelled(): + result_status, result_code = "cancelled", "cancelled" + break + result = runner.run_subprocess( + f"control_{control['name']}", + [ + *_runtime_argv(loaded["cli"], isolated_root=isolation), + *control["args"], + ], + cancellation_event=cancellation_event, + ) + if result.status != "completed": + result_status, result_code = result.status, result.status_code + break + + # Stop sidecars before reading their atomic records. Cleanup repeats + # this operation defensively for cancellation/error paths. + for handle in reversed(handles): + if not runner.stop_background(handle) and result_status == "completed": + result_status, result_code = "failed", "termination_failed" + + if result_status == "completed": + try: + controls = _build_live_controls( + loaded, run_nonce=run_nonce, isolated_root=isolation + ) + _ManifestValidationError, build_manifest, reconcile_manifest, replay_manifest = ( + _load_manifest_builder() + ) + manifest = build_manifest( + controls, + candidate_identity=_manifest_candidate(loaded), + verification_scope=LIVE_SCOPE, + planner=loaded["planner"], + ) + report = reconcile_manifest(manifest) + if not report["reconciled"]: + result_status, result_code = "failed", "manifest_reconcile_failed" + else: + manifest_reconciled = True + if output_manifest is not None: + _write_sanitized_json(output_manifest, manifest) + except LiveControlValidationError as error: + result_status, result_code = "failed", error.code + except Exception: + result_status, result_code = "failed", "manifest_reconcile_failed" + + if result_status == "completed" and manifest is not None: + for case in ("identity", "mutation", "deletion", "loss"): + replay = replay_manifest(manifest, case) + report = reconcile_manifest(replay) + expected_reconciled = case == "identity" + replay_results[case] = "pass" if report["reconciled"] is expected_reconciled else "fail" + if replay_results[case] != "pass": + result_status, result_code = "failed", "identity_replay_incomplete" + break + if result_status == "completed": + for case in ("identity", "mutation", "deletion", "loss"): + if is_cancelled(): + result_status, result_code = "cancelled", "cancelled" + break + replay_result = runner.run_subprocess( + f"replay_{case}", + _runtime_argv(loaded["replays"][case], isolated_root=isolation), + cancellation_event=cancellation_event, + ) + if replay_result.status != "completed": + result_status, result_code = "failed", "identity_replay_incomplete" + break + replay = loaded["replays"][case] + artifact_sha = _validate_replay_artifact( + Path(replay["artifact_file"]), + case=case, + candidate_sha=identity.candidate_sha, + manifest_sha=str(manifest["capture_manifest_sha256"]), + ) + replay_results[case] = f"artifact_bound:{artifact_sha}" + except LiveControlValidationError as error: + result_status, result_code = "failed", error.code + except HarnessFailure as error: + result_status, result_code = "failed", error.code + except Exception: + # Keep unexpected manifest/replay errors fail-closed and path-free. + result_status, result_code = "failed", "phase_exception" + finally: + try: + receipt = runner.cleanup(actions=cleanup_actions) + except HarnessFailure: + receipt = { + "cleanup_attempted": True, + "cleanup_completed": False, + "cleanup_failure_count": 1, + "child_processes_terminated": False, + "resources_released": False, + } + if receipt.get("cleanup_completed") is not True and result_status == "completed": + result_status, result_code = "failed", "cleanup_incomplete" + + output: dict[str, Any] = { + "completed": result_status == "completed", + "ready_for_issue62": False, + "status": result_status, + "status_code": result_code, + "cleanup_completed": receipt.get("cleanup_completed") is True, + "manifest_reconciled": manifest_reconciled, + "replay": replay_results, + "reason": "independent_review_required", + "planner": loaded["planner"], + } + if manifest is not None: + output["capture_manifest_sha256"] = manifest.get("capture_manifest_sha256") + return output + + def _fixture_identity(args: argparse.Namespace) -> CandidateIdentity: return CandidateIdentity( candidate_sha=args.candidate_sha, @@ -545,21 +2584,89 @@ def _fixture_identity(args: argparse.Namespace) -> CandidateIdentity: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--run-root", type=Path, required=True) - parser.add_argument( - "--fixture", choices=("success", "timeout", "nonzero", "cancelled", "cleanup-failure"), required=True + parser.add_argument("--isolated-root", type=Path, help="Fresh-root parent for relative plan paths") + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument( + "--fixture", choices=("success", "timeout", "nonzero", "cancelled", "cleanup-failure") ) + mode.add_argument( + "--enable-live-control", + action="store_true", + help="Run an explicitly supplied authorized live-control plan.", + ) + parser.add_argument("--plan", type=Path, help="Sanitized live-control plan JSON") + parser.add_argument("--manifest-out", type=Path, help="Sanitized live manifest output") parser.add_argument("--timeout-seconds", type=float, default=DEFAULT_TIMEOUT_SECONDS) - parser.add_argument("--candidate-sha", required=True) - parser.add_argument("--cli-version", required=True) - parser.add_argument("--cli-package-sha256", required=True) - parser.add_argument("--catalog-digest", required=True) - parser.add_argument("--route-digest", required=True) + parser.add_argument("--candidate-sha") + parser.add_argument("--cli-version") + parser.add_argument("--cli-package-sha256") + parser.add_argument("--catalog-digest") + parser.add_argument("--route-digest") parser.add_argument("--cli-source-commit") return parser def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) + if args.enable_live_control: + if args.plan is None: + result = { + "completed": False, + "ready_for_issue62": False, + "status": "failed", + "status_code": "live_control_plan_missing", + "cleanup_completed": False, + "reason": "live_control_plan_missing", + } + print(json.dumps(result, sort_keys=True)) + return 2 + try: + result = run_live_control( + args.plan, + run_root=args.run_root, + timeout_seconds=args.timeout_seconds, + manifest_out=args.manifest_out, + isolated_root=args.isolated_root, + ) + except LiveControlValidationError as error: + result = { + "completed": False, + "ready_for_issue62": False, + "status": "failed", + "status_code": error.code, + "cleanup_completed": False, + "reason": error.code, + } + except HarnessFailure as error: + result = { + "completed": False, + "ready_for_issue62": False, + "status": "failed", + "status_code": error.code, + "cleanup_completed": False, + "reason": error.code, + } + except Exception: + result = { + "completed": False, + "ready_for_issue62": False, + "status": "failed", + "status_code": "phase_exception", + "cleanup_completed": False, + "reason": "phase_exception", + } + print(json.dumps(result, sort_keys=True)) + return 0 if result.get("completed") is True and result.get("cleanup_completed") is True else 2 + + fixture_fields = ( + "candidate_sha", + "cli_version", + "cli_package_sha256", + "catalog_digest", + "route_digest", + ) + if any(getattr(args, field) is None for field in fixture_fields): + raise SystemExit("fixture mode requires candidate identity arguments") identity = _fixture_identity(args) runner = BoundedPhaseRunner( args.run_root, diff --git a/tests/test_issue62_bounded_phase_runner.py b/tests/test_issue62_bounded_phase_runner.py index c48c6f57..95e16560 100644 --- a/tests/test_issue62_bounded_phase_runner.py +++ b/tests/test_issue62_bounded_phase_runner.py @@ -58,6 +58,40 @@ def test_success_writes_only_sanitized_phase_status_and_cleanup_receipt(tmp_path assert all("argv" not in record and "command" not in record for record in markers) +def test_direct_runner_drops_sensitive_and_host_path_overrides(tmp_path: Path) -> None: + runner = BoundedPhaseRunner( + tmp_path, + IDENTITY, + environment={ + "HOME": "C:/host/home", + "OPENAI_API_KEY": "must-not-pass", + "PATH": "C:/host/bin", + "PYTHONPATH": "../host-modules", + }, + working_directory=tmp_path, + ) + + isolated_home_keys = ( + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "CODEX_HOME", + "XDG_CONFIG_HOME", + ) + isolated_home_paths = { + key: Path(runner._environment[key]).resolve() + for key in isolated_home_keys + } + assert len(set(isolated_home_paths.values())) == len(isolated_home_keys) + assert all(path.is_relative_to(tmp_path.resolve()) for path in isolated_home_paths.values()) + assert all("host" not in str(path).lower() for path in isolated_home_paths.values()) + assert "OPENAI_API_KEY" not in runner._environment + assert "PATH" not in runner._environment + assert "PYTHONPATH" not in runner._environment + assert runner.cleanup()["cleanup_completed"] is True + + class _FakeProcess: def __init__(self, return_code: int | None = None) -> None: self.return_code = return_code @@ -94,6 +128,120 @@ def wait(self, timeout: float | None = None) -> int: return self.return_code +class _ExitedParentWithDescendant(_FakeProcess): + """A root whose PID is exited while its tree still needs teardown.""" + + def __init__(self) -> None: + super().__init__(return_code=0) + self.tree_stop_attempted = False + + +def test_exited_root_still_attempts_tree_stop_and_readback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + process = _ExitedParentWithDescendant() + calls: list[tuple[object, dict[str, object]]] = [] + readback: list[int] = [] + monkeypatch.setattr("run_issue_62_live_control.os.name", "nt") + monkeypatch.setattr( + "run_issue_62_live_control.subprocess.run", + lambda *args, **kwargs: ( + calls.append((args[0], kwargs)) or type("Result", (), {"returncode": 0})() + ), + ) + monkeypatch.setattr( + "run_issue_62_live_control._process_tree_pids", + lambda root_pid: {int(root_pid), 54321}, + ) + monkeypatch.setattr( + "run_issue_62_live_control._process_tree_is_gone", + lambda tracked_process, tracked_pids: readback.append(int(tracked_process.pid)) or True, + ) + + assert _terminate_process(process) is True + assert calls[0][0] == ["taskkill", "/PID", "54321", "/F"] + assert readback == [12345] + + +def test_tree_readback_failure_cannot_report_cleanup_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + process = _ExitedParentWithDescendant() + monkeypatch.setattr("run_issue_62_live_control.os.name", "nt") + monkeypatch.setattr( + "run_issue_62_live_control.subprocess.run", + lambda *args, **kwargs: type("Result", (), {"returncode": 0})(), + ) + monkeypatch.setattr( + "run_issue_62_live_control._process_tree_is_gone", + lambda tracked_process, tracked_pids: False, + ) + + assert _terminate_process(process) is False + + +def test_cleanup_rechecks_completed_foreground_process_tree( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + process = _FakeProcess(return_code=0) + monkeypatch.setattr( + "run_issue_62_live_control.subprocess.Popen", + lambda *args, **kwargs: process, + ) + monkeypatch.setattr( + "run_issue_62_live_control._process_tree_is_gone", + lambda tracked_process, tracked_pids: False, + ) + + runner = BoundedPhaseRunner(tmp_path, IDENTITY) + result = runner.run_subprocess("cli", ["fixture"]) + assert result.status == "completed" + + receipt = runner.cleanup() + assert receipt["cleanup_completed"] is False + assert receipt["child_processes_terminated"] is False + + +def test_exited_parent_with_live_descendant_is_cleaned_before_success( + tmp_path: Path, +) -> None: + runner = BoundedPhaseRunner(tmp_path, IDENTITY) + probe_root = Path(runner._environment["HOME"]) + survivor_marker = probe_root / "survivor-marker" + pid_file = probe_root / "descendant.pid" + grandchild_code = ( + "import pathlib, time; " + "time.sleep(1.5); " + f"pathlib.Path({str(survivor_marker)!r}).write_text('survived', encoding='ascii')" + ) + parent_code = ( + "import pathlib, subprocess, sys; " + "child = subprocess.Popen([sys.executable, '-c', sys.argv[2]], " + "stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, " + "close_fds=True); " + "pathlib.Path(sys.argv[1]).write_text(str(child.pid), encoding='ascii')" + ) + handle = runner.start_background( + "descendant", + [sys.executable, "-c", parent_code, str(pid_file), grandchild_code], + ) + process = handle.process + try: + deadline = time.monotonic() + 5 + while not pid_file.exists() and time.monotonic() < deadline: + time.sleep(0.01) + assert pid_file.exists() + process.wait(timeout=5) + + assert _terminate_process(process, tracked_pids=set(handle.tracked_pids)) is True + time.sleep(2) + assert not survivor_marker.exists() + finally: + if process.poll() is None: + _terminate_process(process, tracked_pids=set(handle.tracked_pids)) + runner.cleanup() + + def test_timeout_is_terminal_and_cleanup_is_fail_closed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: process = _FakeProcess() diff --git a/tests/test_issue_62_control_manifest.py b/tests/test_issue_62_control_manifest.py index fe74b7e7..7cdd3b6a 100644 --- a/tests/test_issue_62_control_manifest.py +++ b/tests/test_issue_62_control_manifest.py @@ -48,7 +48,9 @@ def _sidecar(name: str, hop: str, *, streaming: bool, status: int, terminal: str record: dict[str, object] = { "schema": "codexhub.issue62.live-evidence-lane.v1", "verification_scope": "capture_only_not_qualification", - "capture_id": "c" + ("a" if hop == "pre" else "b") * 32, + "capture_id": "c" + hashlib.sha256(name.encode("ascii")).hexdigest()[:32], + "run_nonce": "a" * 32, + "producer_hmac_sha256": "b" * 64, "hop": hop, "outcome": "complete", "failure": None, @@ -150,6 +152,7 @@ def _control( "behavior_profile": "official_codex_app_http_passthrough", "inbound_format": "responses", "upstream_format": "responses", + "route_digest": "d" * 64, }, } @@ -292,6 +295,56 @@ def _candidate() -> dict[str, str]: "cli_package_sha256": "b" * 64, "catalog_snapshot_sha256": "c" * 64, "catalog_model_entry_id": "gpt-5.6-sol", + "route_digest": "d" * 64, + } + + +def _planner(*, verification_scope: str = manifest.SYNTHETIC_SCOPE) -> dict[str, object]: + candidate = _candidate() + live = verification_scope == manifest.LIVE_SCOPE + item_disposition = "preserved" if live else "Unqualified" + return { + "inputs": { + "provider": "official", + "model": candidate["catalog_model_entry_id"], + "protocol": "responses", + "cli_version": candidate["cli_version"], + "cli_package_sha256": candidate["cli_package_sha256"], + "candidate_sha": candidate["codexhub_candidate_sha"], + "catalog_digest": candidate["catalog_snapshot_sha256"], + "route_digest": candidate["route_digest"], + }, + "core_plan": { + "status": "complete" if live else "partial", + "items": ( + [ + { + "id": "core-message", + "type": "message", + "disposition": item_disposition, + "evidence_ref": "fixtures/issue-62.json#core.message", + } + ] + if live + else [] + ), + }, + "hosted_only_items": [ + { + "id": "hosted-tools", + "type": "hosted_tool", + "disposition": item_disposition if live else "Unqualified", + "evidence_ref": "fixtures/issue-62.json#hosted.tools", + } + ], + "unknown_tagged_items": [ + { + "id": "unknown-sentinel", + "type": "unknown", + "disposition": item_disposition if live else "Unqualified", + "evidence_ref": "fixtures/issue-62.json#unknown.sentinel", + } + ], } @@ -333,6 +386,16 @@ def test_manifest_rejects_pre_post_digest_mismatch() -> None: manifest.build_manifest(controls, candidate_identity=_candidate()) +def test_manifest_rejects_pre_post_capture_correlation_mismatch() -> None: + controls = _controls() + controls[0]["post"]["capture_id"] = "c" + ("f" * 32) # type: ignore[index] + + with pytest.raises( + manifest.ManifestValidationError, match="control_capture_correlation_mismatch" + ): + manifest.build_manifest(controls, candidate_identity=_candidate()) + + def test_manifest_rejects_incomplete_body_and_sse_fingerprints() -> None: controls = _controls() for side in (controls[0]["pre"], controls[0]["post"]): # type: ignore[index] @@ -413,6 +476,8 @@ def test_manifest_cli_emits_sanitized_fixture_and_replay_report(tmp_path: Path) "b" * 64, "--catalog-snapshot-sha256", "c" * 64, + "--route-digest", + "d" * 64, ] assert manifest.main(args) == 0 written = json.loads(output_path.read_text(encoding="utf-8")) @@ -467,6 +532,100 @@ def test_manifest_reconcile_recomputes_identity_control_from_controls() -> None: assert "identity_control_count_consistency" in report["mismatches"] +def test_manifest_rejects_any_unclassified_identity_item() -> None: + controls = _controls() + controls[0]["identity"] = dict(controls[0]["identity"]) + controls[0]["identity"]["response_ref_preserved"] = False + with pytest.raises(manifest.ManifestValidationError, match="control_identity_unclassified"): + manifest.build_manifest(controls, candidate_identity=_candidate()) + + +def test_manifest_binds_route_digest_and_planner_dispositions() -> None: + built = manifest.build_manifest( + _controls(), + candidate_identity=_candidate(), + planner=_planner(), + ) + + assert built["candidate_identity"]["route_digest"] == "d" * 64 + assert built["planner"] == _planner() + assert { + control["route_identity"]["route_digest"] for control in built["controls"] + } == {"d" * 64} + assert manifest.reconcile_manifest(built) == {"reconciled": True, "mismatches": []} + + +def test_manifest_accepts_canonical_lowercase_preserved_disposition() -> None: + planner = _planner(verification_scope=manifest.LIVE_SCOPE) + planner["hosted_only_items"][0]["disposition"] = "preserved" # type: ignore[index] + planner["unknown_tagged_items"][0]["disposition"] = "local_consume" # type: ignore[index] + + built = manifest.build_manifest( + _controls(), + candidate_identity=_candidate(), + verification_scope=manifest.LIVE_SCOPE, + planner=planner, + ) + + assert built["planner"] == planner + + +def test_manifest_reconciliation_rejects_route_or_planner_provenance_drift() -> None: + built = manifest.build_manifest( + _controls(), + candidate_identity=_candidate(), + planner=_planner(), + ) + + route_forgery = copy.deepcopy(built) + route_forgery["candidate_identity"]["route_digest"] = "e" * 64 + route_forgery["capture_manifest_sha256"] = manifest._canonical_digest( + manifest._manifest_core(route_forgery) + ) + report = manifest.reconcile_manifest(route_forgery) + assert report["reconciled"] is False + assert "control_route_digest_mismatch" in report["mismatches"] + + planner_forgery = copy.deepcopy(built) + planner_forgery["planner"]["unknown_tagged_items"][0]["disposition"] = "not_captured" + planner_forgery["capture_manifest_sha256"] = manifest._canonical_digest( + manifest._manifest_core(planner_forgery) + ) + report = manifest.reconcile_manifest(planner_forgery) + assert report["reconciled"] is False + assert any("planner" in mismatch for mismatch in report["mismatches"]) + + +def test_manifest_requires_route_digest_in_canonical_provenance() -> None: + built = manifest.build_manifest( + _controls(), + candidate_identity=_candidate(), + planner=_planner(), + ) + built["candidate_identity"].pop("route_digest") + + report = manifest.reconcile_manifest(built) + + assert report["reconciled"] is False + assert any("candidate_identity_fields_invalid" in mismatch for mismatch in report["mismatches"]) + + +def test_manifest_requires_explicit_candidate_route_digest() -> None: + candidate = _candidate() + candidate.pop("route_digest") + + with pytest.raises(manifest.ManifestValidationError, match="candidate_route_digest_required"): + manifest.build_manifest(_controls(), candidate_identity=candidate, planner=_planner()) + + +def test_manifest_rejects_explicit_candidate_route_digest_mismatch() -> None: + candidate = _candidate() + candidate["route_digest"] = "e" * 64 + + with pytest.raises(manifest.ManifestValidationError, match="control_route_digest_mismatch"): + manifest.build_manifest(_controls(), candidate_identity=candidate, planner=_planner()) + + @pytest.mark.parametrize( ("control_name", "mutate", "expected"), [ @@ -501,3 +660,148 @@ def test_manifest_binds_control_labels_to_contract( report = manifest.reconcile_manifest(forged) assert report["reconciled"] is False assert any(expected in mismatch for mismatch in report["mismatches"]) + + +def _planner_route_context() -> tuple[dict[str, object], dict[str, object]]: + candidate = _candidate() + return ( + candidate, + { + "upstream": "official", + "model": "gpt-5.6-sol", + "inbound_format": "responses", + "route_digest": candidate["route_digest"], + }, + ) + + +def test_planner_v2_validates_exact_shape_and_bindings() -> None: + candidate, route = _planner_route_context() + planner = _planner(verification_scope=manifest.LIVE_SCOPE) + + normalized = manifest.validate_planner( + planner, + verification_scope=manifest.LIVE_SCOPE, + candidate_identity=candidate, + route_identity=route, + ) + + assert normalized == planner + assert set(normalized) == { + "inputs", + "core_plan", + "hosted_only_items", + "unknown_tagged_items", + } + + +@pytest.mark.parametrize( + "mutate", + [ + lambda planner: planner.update({"extra": True}), + lambda planner: planner["inputs"].update({"extra": True}), + lambda planner: planner["core_plan"]["items"][0].update({"extra": True}), + ], +) +def test_planner_v2_rejects_extra_fields(mutate: object) -> None: + candidate, route = _planner_route_context() + planner = _planner(verification_scope=manifest.LIVE_SCOPE) + mutate(planner) # type: ignore[operator] + + with pytest.raises(manifest.ManifestValidationError): + manifest.validate_planner( + planner, + verification_scope=manifest.LIVE_SCOPE, + candidate_identity=candidate, + route_identity=route, + ) + + +def test_planner_v2_rejects_duplicate_and_unsorted_items() -> None: + candidate, route = _planner_route_context() + planner = _planner(verification_scope=manifest.LIVE_SCOPE) + planner["hosted_only_items"][0]["id"] = "core-message" # type: ignore[index] + with pytest.raises(manifest.ManifestValidationError, match="planner_duplicate_item_id"): + manifest.validate_planner( + planner, + verification_scope=manifest.LIVE_SCOPE, + candidate_identity=candidate, + route_identity=route, + ) + + planner = _planner(verification_scope=manifest.LIVE_SCOPE) + planner["core_plan"]["items"].append( # type: ignore[index] + { + "id": "aaa", + "type": "message", + "disposition": "preserved", + "evidence_ref": "fixtures/issue-62.json#core.aaa", + } + ) + with pytest.raises(manifest.ManifestValidationError, match="planner_core_plan_unsorted"): + manifest.validate_planner( + planner, + verification_scope=manifest.LIVE_SCOPE, + candidate_identity=candidate, + route_identity=route, + ) + + +@pytest.mark.parametrize("evidence_ref", ["", "https://example.invalid/evidence#item", "../evidence.json#item", "evidence.json#.."]) +def test_planner_v2_requires_relative_nonempty_evidence_refs(evidence_ref: str) -> None: + candidate, route = _planner_route_context() + planner = _planner(verification_scope=manifest.LIVE_SCOPE) + planner["core_plan"]["items"][0]["evidence_ref"] = evidence_ref # type: ignore[index] + + with pytest.raises(manifest.ManifestValidationError, match="planner_evidence_ref_invalid"): + manifest.validate_planner( + planner, + verification_scope=manifest.LIVE_SCOPE, + candidate_identity=candidate, + route_identity=route, + ) + + +def test_planner_v2_rejects_identity_or_route_binding_mismatch() -> None: + candidate, route = _planner_route_context() + planner = _planner(verification_scope=manifest.LIVE_SCOPE) + mismatched_candidate = dict(candidate) + mismatched_candidate["route_digest"] = "e" * 64 + with pytest.raises(manifest.ManifestValidationError, match="planner_input_binding_mismatch"): + manifest.validate_planner( + planner, + verification_scope=manifest.LIVE_SCOPE, + candidate_identity=mismatched_candidate, + route_identity=route, + ) + + mismatched_route = dict(route) + mismatched_route["inbound_format"] = "chat" + with pytest.raises(manifest.ManifestValidationError, match="planner_route_binding_mismatch"): + manifest.validate_planner( + planner, + verification_scope=manifest.LIVE_SCOPE, + candidate_identity=candidate, + route_identity=mismatched_route, + ) + + +def test_planner_v2_keeps_synthetic_evidence_non_qualifying() -> None: + candidate, route = _planner_route_context() + planner = _planner(verification_scope=manifest.SYNTHETIC_SCOPE) + planner["core_plan"]["status"] = "complete" # type: ignore[index] + planner["core_plan"]["items"] = [ # type: ignore[index] + { + "id": "core-message", + "type": "message", + "disposition": "preserved", + "evidence_ref": "fixtures/issue-62.json#core.message", + } + ] + with pytest.raises(manifest.ManifestValidationError, match="planner_synthetic_evidence_invalid"): + manifest.validate_planner( + planner, + verification_scope=manifest.SYNTHETIC_SCOPE, + candidate_identity=candidate, + route_identity=route, + ) diff --git a/tests/test_issue_62_live_control_orchestration.py b/tests/test_issue_62_live_control_orchestration.py new file mode 100644 index 00000000..ac47512f --- /dev/null +++ b/tests/test_issue_62_live_control_orchestration.py @@ -0,0 +1,441 @@ +from __future__ import annotations + +import hashlib +import importlib +import json +import os +from pathlib import Path +import shutil +import sys + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +from run_issue_62_live_control import ( # noqa: E402 + CONTROL_NAMES, + LiveControlValidationError, + load_live_control_plan, + main, + run_live_control, +) + + +def _sha(value: str) -> str: + return hashlib.sha256(value.encode("ascii")).hexdigest() + + +def _binding_files(tmp_path: Path) -> dict[str, str]: + (tmp_path / "inputs").mkdir() + files = { + "candidate_sha_file": tmp_path / "inputs" / "candidate-sha", + "cli_package_file": tmp_path / "inputs" / "cli-package.tgz", + "catalog_file": tmp_path / "inputs" / "catalog.json", + "route_file": tmp_path / "inputs" / "route.json", + } + files["candidate_sha_file"].write_text("a" * 40 + "\n", encoding="ascii") + files["cli_package_file"].write_bytes(b"package") + files["catalog_file"].write_bytes(b"catalog") + files["route_file"].write_bytes(b"route") + return {key: str(value.relative_to(tmp_path)) for key, value in files.items()} + + +def _plan(tmp_path: Path) -> dict[str, object]: + binding = _binding_files(tmp_path) + capture_key = tmp_path / "inputs" / "capture.key" + capture_key.write_bytes(b"k" * 32) + capture_key_file = str(capture_key.relative_to(tmp_path)) + capture_key_sha256 = hashlib.sha256(capture_key.read_bytes()).hexdigest() + (tmp_path / "helpers").mkdir() + (tmp_path / "run").mkdir() + tools_dir = tmp_path / "tools" + tools_dir.mkdir() + if os.name == "nt": + # A copied setup-python interpreter is not reliably launchable on a + # fresh Hosted image (its loader can reject the relocated DLL set). + # Use the system command host as the bound executable instead; all + # scripts and the timeout helper remain copied under this root. + executable_path = tools_dir / "cmd.exe" + shutil.copy2(Path(os.environ["SystemRoot"]) / "System32" / "cmd.exe", executable_path) + shutil.copy2(Path(os.environ["SystemRoot"]) / "System32" / "PING.EXE", tools_dir / "PING.EXE") + (tmp_path / "helpers" / "cli.cmd").write_text( + "@echo off\nexit /b 0\n", encoding="ascii" + ) + (tmp_path / "helpers" / "sidecar-empty.cmd").write_text( + "@echo off\nping 127.0.0.1 -n 300 >nul\n", encoding="ascii" + ) + (tmp_path / "helpers" / "replay.cmd").write_text( + "@echo off\n" + "set \"case=%~1\"\n" + "set \"candidate=%~2\"\n" + "set \"manifest=%~3\"\n" + "set \"output=%~4\"\n" + "if /I \"%case%\"==\"identity\" (set \"outcome=accepted\") else (set \"outcome=rejected\")\n" + ">\"%output%\" echo {\"schema\":\"codexhub.issue62.identity-replay.v1\",\"case\":\"%case%\",\"candidate_sha\":\"%candidate%\",\"capture_manifest_sha256\":\"%manifest%\",\"wire_replay\":true,\"outcome\":\"%outcome%\"}\n", + encoding="ascii", + ) + executable_file = str(executable_path.relative_to(tmp_path)) + environment = { + "PATH": str(tools_dir), + "PATHEXT": ".COM;.EXE;.BAT;.CMD", + } + cli_argv = [executable_file, "/d", "/c", "helpers\\cli.cmd"] + sidecar_argv = [ + executable_file, + "/d", + "/c", + "helpers\\sidecar-empty.cmd", + "--hmac-key-file", + capture_key_file, + ] + replay_argv = lambda case: [ + executable_file, + "/d", + "/c", + "helpers\\replay.cmd", + case, + "a" * 40, + "0" * 64, + f"run/replay-{case}.json", + ] + helper_names = {"cli": "cli.cmd", "sidecar": "sidecar-empty.cmd", "replay": "replay.cmd"} + else: + executable_path = tools_dir / "python" + shutil.copy2(sys.executable, executable_path) + (tmp_path / "helpers" / "cli.py").write_text("raise SystemExit(0)\n", encoding="utf-8") + (tmp_path / "helpers" / "sidecar-empty.py").write_text( + "import time\n" + "time.sleep(300)\n", + encoding="utf-8", + ) + (tmp_path / "helpers" / "replay.py").write_text( + "import json, pathlib, sys\n" + "case, candidate, manifest, output = sys.argv[1:5]\n" + "path = pathlib.Path(output)\n" + "path.parent.mkdir(parents=True, exist_ok=True)\n" + "path.write_text(json.dumps({'schema':'codexhub.issue62.identity-replay.v1', 'case':case, 'candidate_sha':candidate, 'capture_manifest_sha256':manifest, 'wire_replay':True, 'outcome':'accepted' if case == 'identity' else 'rejected'}, sort_keys=True, separators=(',', ':')) + '\\n', encoding='utf-8')\n", + encoding="utf-8", + ) + executable_file = str(executable_path.relative_to(tmp_path)) + environment = {} + cli_argv = [executable_file, "helpers/cli.py"] + sidecar_argv = [ + executable_file, + "helpers/sidecar-empty.py", + "--hmac-key-file", + capture_key_file, + ] + replay_argv = lambda case: [ + executable_file, "helpers/replay.py", case, "a" * 40, "0" * 64, f"run/replay-{case}.json" + ] + helper_names = {"cli": "cli.py", "sidecar": "sidecar-empty.py", "replay": "replay.py"} + executable_digest = hashlib.sha256(executable_path.read_bytes()).hexdigest() + executable = { + "executable_file": executable_file, + "executable_sha256": executable_digest, + } + file_digest = lambda name: hashlib.sha256( + (tmp_path / "helpers" / name).read_bytes() + ).hexdigest() + return { + "schema": "codexhub.issue62.live-control-plan.v1", + "verification_scope": "authorized_live_control", + "isolation_root": ".", + "candidate_identity": { + "candidate_sha": "a" * 40, + "cli_version": "0.146.0", + "cli_package_sha256": _sha("package"), + "catalog_digest": _sha("catalog"), + "route_digest": _sha("route"), + }, + "binding": binding, + "catalog_model_entry_id": "gpt-5.6-sol", + "environment": environment, + "planner": { + "inputs": { + "provider": "official", + "model": "gpt-5.6-sol", + "protocol": "responses", + "cli_version": "0.146.0", + "cli_package_sha256": _sha("package"), + "candidate_sha": "a" * 40, + "catalog_digest": _sha("catalog"), + "route_digest": _sha("route"), + }, + "core_plan": { + "status": "complete", + "items": [ + { + "id": "core-message", + "type": "message", + "disposition": "preserved", + "evidence_ref": "fixtures/issue-62.json#core.message", + } + ], + }, + "hosted_only_items": [ + { + "id": "hosted-tools", + "type": "hosted_tool", + "disposition": "Unqualified", + "evidence_ref": "fixtures/issue-62.json#hosted.tools", + } + ], + "unknown_tagged_items": [ + { + "id": "unknown-sentinel", + "type": "unknown", + "disposition": "Unqualified", + "evidence_ref": "fixtures/issue-62.json#unknown.sentinel", + } + ], + }, + "cli": { + "argv": cli_argv, + **executable, + "argv_file_digests": {f"helpers/{helper_names['cli']}": file_digest(helper_names["cli"])}, + "cli_version": "0.146.0", + }, + "sidecars": { + "pre": { + "argv": sidecar_argv, + "output_dir": "run/pre", + **executable, + "argv_file_digests": { + f"helpers/{helper_names['sidecar']}": file_digest(helper_names["sidecar"]) + }, + "hmac_key_file": capture_key_file, + "hmac_key_sha256": capture_key_sha256, + }, + "post": { + "argv": sidecar_argv, + "output_dir": "run/post", + **executable, + "argv_file_digests": { + f"helpers/{helper_names['sidecar']}": file_digest(helper_names["sidecar"]) + }, + "hmac_key_file": capture_key_file, + "hmac_key_sha256": capture_key_sha256, + }, + }, + "controls": [ + {"name": name, "args": [], "capture": {}} + for name in CONTROL_NAMES + ], + "replays": { + case: { + "argv": replay_argv(case), + **executable, + "argv_file_digests": {f"helpers/{helper_names['replay']}": file_digest(helper_names["replay"])}, + "artifact_file": f"run/replay-{case}.json", + "case": case, + } + for case in ("identity", "mutation", "deletion", "loss") + }, + } + + +def test_missing_live_plan_uses_fixed_fail_closed_code(tmp_path: Path) -> None: + with pytest.raises(LiveControlValidationError, match="live_control_plan_missing"): + load_live_control_plan(tmp_path / "missing.json") + + +def test_cli_live_mode_reports_missing_plan_without_starting_children(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + assert main(["--run-root", str(tmp_path / "run"), "--enable-live-control"]) == 2 + output = json.loads(capsys.readouterr().out) + assert output["status_code"] == "live_control_plan_missing" + assert output["ready_for_issue62"] is False + + +def test_live_plan_binds_candidate_and_route_catalog_files(tmp_path: Path) -> None: + plan = _plan(tmp_path) + loaded = load_live_control_plan(plan, isolated_root=tmp_path) + assert loaded["candidate_identity"]["candidate_sha"] == "a" * 40 + + (tmp_path / "inputs" / "route.json").write_bytes(b"changed") + with pytest.raises(LiveControlValidationError, match="route_binding_mismatch"): + load_live_control_plan(plan, isolated_root=tmp_path) + + +def test_live_plan_accepts_documented_disposition_vocabulary(tmp_path: Path) -> None: + plan = _plan(tmp_path) + plan["planner"]["hosted_only_items"][0]["disposition"] = "preserved" # type: ignore[index] + plan["planner"]["unknown_tagged_items"][0]["disposition"] = "reversibly_adapted" # type: ignore[index] + loaded = load_live_control_plan(plan, isolated_root=tmp_path) + assert loaded["planner"]["hosted_only_items"][0]["disposition"] == "preserved" # type: ignore[index] + + +def test_live_plan_binds_v2_planner_identity_before_children(tmp_path: Path) -> None: + plan = _plan(tmp_path) + plan["planner"]["inputs"]["candidate_sha"] = "b" * 40 # type: ignore[index] + + with pytest.raises(LiveControlValidationError, match="candidate_binding_mismatch"): + load_live_control_plan(plan, isolated_root=tmp_path) + + +def test_live_plan_rejects_non_official_v2_planner_route(tmp_path: Path) -> None: + plan = _plan(tmp_path) + plan["planner"]["inputs"]["protocol"] = "chat" # type: ignore[index] + + with pytest.raises(LiveControlValidationError, match="route_binding_mismatch"): + load_live_control_plan(plan, isolated_root=tmp_path) + + +def test_live_plan_requires_exactly_eight_control_labels(tmp_path: Path) -> None: + plan = _plan(tmp_path) + plan["controls"] = list(plan["controls"])[1:] + with pytest.raises(LiveControlValidationError, match="control_labels_incomplete"): + load_live_control_plan(plan, isolated_root=tmp_path) + + +def test_live_execution_requires_complete_pre_and_post_sidecar_records(tmp_path: Path) -> None: + result = run_live_control( + _plan(tmp_path), + run_root=tmp_path / "run", + timeout_seconds=30, + isolated_root=tmp_path, + ) + assert result["ready_for_issue62"] is False + assert result["status_code"] in {"sidecar_capture_missing", "sidecar_capture_incomplete"} + receipt = json.loads((tmp_path / "run" / "cleanup-receipt.json").read_text(encoding="utf-8")) + assert receipt["cleanup_attempted"] is True + assert receipt["cleanup_completed"] is True + + +def test_live_execution_binds_all_controls_and_keeps_qualification_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = importlib.import_module("test_issue_62_control_manifest") + live_control = importlib.import_module("run_issue_62_live_control") + capture_sidecar = importlib.import_module("capture_issue_62_live_evidence") + run_nonce = "a" * 32 + monkeypatch.setattr(live_control.secrets, "token_hex", lambda _length: run_nonce) + plan = _plan(tmp_path) + controls = source._controls() + pre_dir = tmp_path / "inputs" / "pre-records" + post_dir = tmp_path / "inputs" / "post-records" + pre_dir.mkdir() + post_dir.mkdir() + payloads: dict[str, Path] = {} + for index, control in enumerate(controls): + pre = tmp_path / f"pre-{index}.json" + post = tmp_path / f"post-{index}.json" + pre_record = dict(control["pre"]) + post_record = dict(control["post"]) + for record in (pre_record, post_record): + record["run_nonce"] = run_nonce + record["producer_hmac_sha256"] = "0" * 64 + record["producer_hmac_sha256"] = capture_sidecar._producer_hmac_sha256( + b"k" * 32, record, run_nonce + ) + pre.write_text(json.dumps(pre_record), encoding="utf-8") + post.write_text(json.dumps(post_record), encoding="utf-8") + payloads[f"pre-{index}"] = pre + payloads[f"post-{index}"] = post + semantic = { + key: value + for key, value in control.items() + if key not in {"name", "pre", "post"} + } + # The live plan binds route provenance to the candidate's route + # file. Reuse that digest for the synthetic control fixture so + # the test exercises replay failure rather than an intentional + # route-provenance mismatch. + semantic["route_identity"] = dict(semantic["route_identity"]) + semantic["route_identity"]["route_digest"] = plan["candidate_identity"]["route_digest"] + plan["controls"][index] = { + "name": control["name"], + "args": [], + "capture": semantic, + "pre_record": f"run/pre-{index}/pre-c{index}.json", + "post_record": f"run/post-{index}/post-c{index}.json", + } + if os.name == "nt": + sidecar_name = "sidecar.cmd" + sidecar_script = tmp_path / "helpers" / sidecar_name + sidecar_script.write_text( + "@echo off\n" + "copy /y \"%~2\" \"%~1\\%~3\" >nul\n" + "ping 127.0.0.1 -n 300 >nul\n", + encoding="ascii", + ) + sidecar_prefix = [plan["cli"]["executable_file"], "/d", "/c", "helpers\\sidecar.cmd"] + else: + sidecar_name = "sidecar.py" + sidecar_script = tmp_path / "helpers" / sidecar_name + sidecar_script.write_text( + "import pathlib, shutil, sys, time\n" + "out = pathlib.Path(sys.argv[1]); src = pathlib.Path(sys.argv[2])\n" + "shutil.copyfile(src, out / pathlib.Path(sys.argv[3]).name)\n" + "time.sleep(300)\n", + encoding="utf-8", + ) + sidecar_prefix = [plan["cli"]["executable_file"], "helpers/sidecar.py"] + for hop in ("pre", "post"): + specs = plan["sidecars"][hop] + source_paths = [payloads[f"{hop}-{index}"] for index in range(8)] + # One sidecar per control gives deterministic pairing without relying + # on opaque capture-id ordering. + plan["sidecars"][hop] = [ + { + "argv": [ + *sidecar_prefix, + f"run/{hop}-{index}", + str(source_paths[index].relative_to(tmp_path)), + f"{hop}-c{index}.json", + "--hmac-key-file", + "inputs/capture.key", + ], + "output_dir": f"run/{hop}-{index}", + "executable_file": plan["cli"]["executable_file"], + "executable_sha256": plan["cli"]["executable_sha256"], + "argv_file_digests": { + f"helpers/{sidecar_name}": hashlib.sha256( + sidecar_script.read_bytes() + ).hexdigest() + }, + "hmac_key_file": "inputs/capture.key", + "hmac_key_sha256": hashlib.sha256( + (tmp_path / "inputs" / "capture.key").read_bytes() + ).hexdigest(), + } + for index in range(8) + ] + for index in range(8): + plan["controls"][index][f"{hop}_record"] = ( + f"run/{hop}-{index}/{hop}-c{index}.json" + ) + # Re-run plan validation after replacing the sidecar specs. + result = run_live_control( + plan, + run_root=tmp_path / "run", + timeout_seconds=30, + isolated_root=tmp_path, + ) + assert result["completed"] is False + assert result["ready_for_issue62"] is False + assert result["status_code"] == "identity_replay_artifact_invalid" + assert result["cleanup_completed"] is True + assert result["manifest_reconciled"] is True + assert not (tmp_path / "run" / "pre-0").exists() + assert not (tmp_path / "run" / "post-0").exists() + assert not (tmp_path / "run" / "replay-identity.json").exists() + + +def test_live_plan_rejects_host_credentials_and_duplicate_capture_paths(tmp_path: Path) -> None: + plan = _plan(tmp_path) + plan["environment"] = {"OPENAI_API_KEY": "must-not-pass"} + with pytest.raises(LiveControlValidationError, match="environment_invalid"): + load_live_control_plan(plan, isolated_root=tmp_path) + + duplicate_root = tmp_path / "duplicate" + duplicate_root.mkdir() + plan = _plan(duplicate_root) + plan["controls"][1]["pre_record"] = "run/shared.json" + plan["controls"][0]["pre_record"] = "run/shared.json" + with pytest.raises(LiveControlValidationError, match="sidecar_capture_incomplete"): + load_live_control_plan(plan, isolated_root=duplicate_root) diff --git a/tests/test_issue_62_live_evidence_sidecar.py b/tests/test_issue_62_live_evidence_sidecar.py index 72456162..892d90c0 100644 --- a/tests/test_issue_62_live_evidence_sidecar.py +++ b/tests/test_issue_62_live_evidence_sidecar.py @@ -28,14 +28,54 @@ SPEC.loader.exec_module(sidecar) KEY = b"h" * 32 +CORRELATION_TOKEN = "1" * 32 +RUN_NONCE = "a" * 32 + + +def _capture_id(correlation_token: str = CORRELATION_TOKEN) -> str: + return sidecar._capture_id_for_token(KEY, correlation_token, RUN_NONCE) + + +def _bound_record(record: dict[str, object], *, key: bytes = KEY) -> dict[str, object]: + record["run_nonce"] = RUN_NONCE + record["producer_hmac_sha256"] = "0" * 64 + record["producer_hmac_sha256"] = sidecar._producer_hmac_sha256(key, record, RUN_NONCE) + return record + + +def test_capture_id_is_deterministically_bound_to_correlation_token() -> None: + assert _capture_id() == _capture_id(CORRELATION_TOKEN) + assert _capture_id() != _capture_id("2" * 32) + + +def test_correlation_binding_is_bound_to_the_current_run_nonce() -> None: + header = sidecar._correlation_binding(KEY, CORRELATION_TOKEN, RUN_NONCE) + + assert sidecar._correlation_token_from_header(KEY, header, RUN_NONCE) == CORRELATION_TOKEN + assert sidecar._correlation_token_from_header(KEY, header, "b" * 32) is None class _FakeUpstreamHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.0" + def do_GET(self) -> None: + self.server.observed_get_path = self.path # type: ignore[attr-defined] + self.server.observed_get_headers = { # type: ignore[attr-defined] + name.lower(): value for name, value in self.headers.items() + } + body = b'{"data":[]}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def do_POST(self) -> None: length = int(self.headers["Content-Length"]) self.server.observed_request = self.rfile.read(length) # type: ignore[attr-defined] + self.server.observed_headers = { # type: ignore[attr-defined] + name.lower(): value for name, value in self.headers.items() + } body = self.server.response_body # type: ignore[attr-defined] delay = self.server.response_delay # type: ignore[attr-defined] if delay: @@ -69,6 +109,9 @@ def _fake_upstream( server = ThreadingHTTPServer(("127.0.0.1", 0), _FakeUpstreamHandler) server.response_body = response_body # type: ignore[attr-defined] server.observed_request = None # type: ignore[attr-defined] + server.observed_headers = {} # type: ignore[attr-defined] + server.observed_get_path = None # type: ignore[attr-defined] + server.observed_get_headers = {} # type: ignore[attr-defined] server.response_delay = response_delay # type: ignore[attr-defined] server.response_chunk_size = chunk_size # type: ignore[attr-defined] server.chunk_delay = chunk_delay # type: ignore[attr-defined] @@ -82,6 +125,35 @@ def _fake_upstream( thread.join(timeout=2) +def test_get_model_refresh_is_forwarded_without_creating_capture_evidence( + tmp_path: Path, + hmac_key_file: Path, +) -> None: + with _fake_upstream(b"ignored") as upstream: + config = _sidecar_config( + tmp_path, + hmac_key_file, + f"http://127.0.0.1:{upstream.server_address[1]}", + ) + with _running_sidecar(config) as server: + connection = http.client.HTTPConnection( + server.listen_host, server.listen_port, timeout=2 + ) + connection.request( + "GET", + "/models?client_version=0.146.0", + headers={"Authorization": "Bearer forbidden-token"}, + ) + response = connection.getresponse() + body = response.read() + connection.close() + + assert response.status == 200 + assert body == b'{"data":[]}' + assert upstream.observed_get_path == "/models?client_version=0.146.0" # type: ignore[attr-defined] + assert not list(config.output_dir.glob("*.json")) + + @contextmanager def _running_sidecar(config): server = sidecar.CaptureSidecarServer(config) @@ -127,6 +199,7 @@ def _sidecar_config( "connect_timeout_seconds": 1.0, "read_timeout_seconds": 1.0, "overall_timeout_seconds": 3.0, + "run_nonce": RUN_NONCE, } values.update(overrides) return sidecar.SidecarConfig(**values) @@ -153,6 +226,7 @@ def base_config(tmp_path: Path, hmac_key_file: Path): connect_timeout_seconds=1.0, read_timeout_seconds=1.0, overall_timeout_seconds=2.0, + run_nonce=RUN_NONCE, ) @@ -211,10 +285,10 @@ def test_config_rejects_arbitrarily_large_positive_integer_timeout( def test_atomic_record_is_sanitized_and_leaves_no_partial(tmp_path: Path) -> None: - record = { + record = _bound_record({ "schema": "codexhub.issue62.live-evidence-lane.v1", "verification_scope": "capture_only_not_qualification", - "capture_id": "c" + "1" * 32, + "capture_id": _capture_id(), "hop": "pre", "outcome": "complete", "failure": None, @@ -233,9 +307,15 @@ def test_atomic_record_is_sanitized_and_leaves_no_partial(tmp_path: Path) -> Non "complete": True, }, "sse": None, - } + }) - record_path = sidecar.write_capture_record(tmp_path, "pre", record) + record_path = sidecar.write_capture_record( + tmp_path, + "pre", + record, + capture_key=KEY, + correlation_token=CORRELATION_TOKEN, + ) assert json.loads(record_path.read_text(encoding="utf-8")) == record assert record_path.name.startswith("pre-") @@ -243,6 +323,106 @@ def test_atomic_record_is_sanitized_and_leaves_no_partial(tmp_path: Path) -> Non assert not list(tmp_path.glob("*.partial")) +def test_atomic_record_does_not_overwrite_same_correlation_record( + tmp_path: Path, +) -> None: + record = _bound_record({ + "schema": "codexhub.issue62.live-evidence-lane.v1", + "verification_scope": "capture_only_not_qualification", + "capture_id": _capture_id(), + "hop": "pre", + "outcome": "complete", + "failure": None, + "status": 200, + "content_type_class": "json", + "request": {"bytes": 1, "sha256": "a" * 64, "hmac_sha256": "b" * 64, "complete": True}, + "response": {"bytes": 1, "sha256": "c" * 64, "hmac_sha256": "d" * 64, "complete": True}, + "sse": None, + }) + + first = sidecar.write_capture_record( + tmp_path, + "pre", + record, + capture_key=KEY, + correlation_token=CORRELATION_TOKEN, + ) + with pytest.raises(sidecar.ArtifactValidationError, match="capture_record_exists"): + sidecar.write_capture_record( + tmp_path, + "pre", + record, + capture_key=KEY, + correlation_token=CORRELATION_TOKEN, + ) + assert json.loads(first.read_text(encoding="utf-8")) == record + assert not list(tmp_path.glob("*.partial")) + + +def test_atomic_record_rejects_prebuilt_record_without_live_capture_binding(tmp_path: Path) -> None: + record = _bound_record({ + "schema": "codexhub.issue62.live-evidence-lane.v1", + "verification_scope": "capture_only_not_qualification", + "capture_id": _capture_id("2" * 32), + "hop": "pre", + "outcome": "complete", + "failure": None, + "status": 200, + "content_type_class": "json", + "request": { + "bytes": 2, + "sha256": "a" * 64, + "hmac_sha256": "b" * 64, + "complete": True, + }, + "response": { + "bytes": 3, + "sha256": "c" * 64, + "hmac_sha256": "d" * 64, + "complete": True, + }, + "sse": None, + }) + + with pytest.raises(sidecar.ArtifactValidationError, match="capture_binding_missing"): + sidecar.write_capture_record(tmp_path, "pre", record) + + +def test_atomic_record_rejects_capture_id_from_different_live_correlation(tmp_path: Path) -> None: + record = _bound_record({ + "schema": "codexhub.issue62.live-evidence-lane.v1", + "verification_scope": "capture_only_not_qualification", + "capture_id": _capture_id("2" * 32), + "hop": "pre", + "outcome": "complete", + "failure": None, + "status": 200, + "content_type_class": "json", + "request": { + "bytes": 2, + "sha256": "a" * 64, + "hmac_sha256": "b" * 64, + "complete": True, + }, + "response": { + "bytes": 3, + "sha256": "c" * 64, + "hmac_sha256": "d" * 64, + "complete": True, + }, + "sse": None, + }) + + with pytest.raises(sidecar.ArtifactValidationError, match="capture_binding_invalid"): + sidecar.write_capture_record( + tmp_path, + "pre", + record, + capture_key=KEY, + correlation_token=CORRELATION_TOKEN, + ) + + def test_atomic_record_rejects_unapproved_fields(tmp_path: Path) -> None: with pytest.raises(sidecar.ArtifactValidationError, match="record_fields_invalid"): sidecar.write_capture_record( @@ -252,6 +432,8 @@ def test_atomic_record_rejects_unapproved_fields(tmp_path: Path) -> None: "schema": "codexhub.issue62.live-evidence-lane.v1", "verification_scope": "capture_only_not_qualification", "capture_id": "c" + "2" * 32, + "run_nonce": RUN_NONCE, + "producer_hmac_sha256": "0" * 64, "hop": "pre", "outcome": "incomplete", "failure": "forwarding_failed", @@ -274,6 +456,8 @@ def test_atomic_record_rejects_inconsistent_sse_completion(tmp_path: Path) -> No "schema": "codexhub.issue62.live-evidence-lane.v1", "verification_scope": "capture_only_not_qualification", "capture_id": "c" + "3" * 32, + "run_nonce": RUN_NONCE, + "producer_hmac_sha256": "0" * 64, "hop": "post", "outcome": "incomplete", "failure": "sse_terminal_missing", @@ -323,6 +507,8 @@ def test_complete_record_requires_complete_response_and_sse_when_streaming( "schema": "codexhub.issue62.live-evidence-lane.v1", "verification_scope": "capture_only_not_qualification", "capture_id": "c" + ("4" if response is None else "5") * 32, + "run_nonce": RUN_NONCE, + "producer_hmac_sha256": "0" * 64, "hop": "pre", "outcome": "complete", "failure": None, @@ -370,6 +556,8 @@ def test_cli_rejects_ephemeral_port( "1", "--overall-timeout-seconds", "2", + "--run-nonce", + RUN_NONCE, ] ) @@ -494,7 +682,9 @@ def test_two_hops_capture_matching_complete_request_response_and_sse( connect_timeout_seconds=1.0, read_timeout_seconds=1.0, overall_timeout_seconds=3.0, + run_nonce=RUN_NONCE, ) + with _running_sidecar(post_config) as post: pre_config = dataclasses.replace( post_config, @@ -515,6 +705,7 @@ def test_two_hops_capture_matching_complete_request_response_and_sse( headers={ "Authorization": "Bearer forbidden-token", "Content-Type": "application/json", + "X-CodexHub-Issue62-Capture": ("0" * 32) + "." + ("f" * 64), }, ) response = connection.getresponse() @@ -529,10 +720,12 @@ def test_two_hops_capture_matching_complete_request_response_and_sse( pre_record = _read_only_record(pre_output) post_record = _read_only_record(post_output) assert pre_record["request"] == post_record["request"] + assert pre_record["capture_id"] == post_record["capture_id"] assert pre_record["response"] == post_record["response"] assert pre_record["sse"]["sequence_sha256"] == post_record["sse"]["sequence_sha256"] # type: ignore[index] assert pre_record["sse"]["sequence_hmac_sha256"] == post_record["sse"]["sequence_hmac_sha256"] # type: ignore[index] assert pre_record["outcome"] == post_record["outcome"] == "complete" + assert "x-codexhub-issue62-capture" not in upstream.observed_headers # type: ignore[attr-defined] serialized = json.dumps([pre_record, post_record], sort_keys=True) for sensitive in ( "private prompt", @@ -548,6 +741,68 @@ def test_two_hops_capture_matching_complete_request_response_and_sse( assert not list(tmp_path.rglob("*.partial")) +def test_atomic_record_rejects_a_mutated_producer_snapshot(tmp_path: Path) -> None: + record = _bound_record({ + "schema": "codexhub.issue62.live-evidence-lane.v1", + "verification_scope": "capture_only_not_qualification", + "capture_id": _capture_id(), + "hop": "pre", + "outcome": "complete", + "failure": None, + "status": 200, + "content_type_class": "json", + "request": {"bytes": 1, "sha256": "a" * 64, "hmac_sha256": "b" * 64, "complete": True}, + "response": {"bytes": 1, "sha256": "c" * 64, "hmac_sha256": "d" * 64, "complete": True}, + "sse": None, + }) + record["status"] = 201 + + with pytest.raises(sidecar.ArtifactValidationError, match="producer_binding_invalid"): + sidecar.write_capture_record( + tmp_path, + "pre", + record, + capture_key=KEY, + correlation_token=CORRELATION_TOKEN, + ) + + +@pytest.mark.parametrize( + "correlation_header", + [None, ("0" * 32) + "." + ("f" * 64)], +) +def test_post_rejects_missing_or_invalid_correlation_before_forwarding( + correlation_header: str | None, + tmp_path: Path, + hmac_key_file: Path, +) -> None: + with _fake_upstream(b'data: {"type":"response.completed"}\n\n') as upstream: + upstream_url = f"http://127.0.0.1:{upstream.server_address[1]}" + config = _sidecar_config( + tmp_path, + hmac_key_file, + upstream_url, + hop="post", + ) + with _running_sidecar(config) as server: + connection = http.client.HTTPConnection(server.listen_host, server.listen_port, timeout=2) + headers = {} + if correlation_header is not None: + headers["X-CodexHub-Issue62-Capture"] = correlation_header + connection.request("POST", "/responses", body=b"{}", headers=headers) + response = connection.getresponse() + assert response.status == 400 + response.read() + connection.close() + + assert upstream.observed_request is None # type: ignore[attr-defined] + record = _read_only_record(config.output_dir) + assert record["outcome"] == "incomplete" + assert record["failure"] == "correlation_binding_invalid" + assert "X-CodexHub-Issue62-Capture" not in json.dumps(record, sort_keys=True) + assert not list(tmp_path.rglob("*.partial")) + + def test_request_overflow_fails_closed_before_forwarding( tmp_path: Path, hmac_key_file: Path, @@ -779,6 +1034,117 @@ def explode(*_args: object, **_kwargs: object) -> None: assert not list(tmp_path.rglob("*.partial")) +def test_post_rejects_a_header_issued_by_an_older_run( + tmp_path: Path, + hmac_key_file: Path, +) -> None: + response_body = b'data: {"type":"response.completed"}\n\n' + with _fake_upstream(response_body) as upstream: + upstream_url = f"http://127.0.0.1:{upstream.server_address[1]}" + config = _sidecar_config( + tmp_path, + hmac_key_file, + upstream_url, + hop="post", + run_nonce="b" * 32, + ) + old_header = sidecar._correlation_binding( + hmac_key_file.read_bytes(), CORRELATION_TOKEN, RUN_NONCE + ) + with _running_sidecar(config) as server: + connection = http.client.HTTPConnection(server.listen_host, server.listen_port, timeout=2) + connection.request( + "POST", + "/responses", + body=b"{}", + headers={"X-CodexHub-Issue62-Capture": old_header}, + ) + response = connection.getresponse() + assert response.status == 400 + response.read() + connection.close() + + assert upstream.observed_request is None # type: ignore[attr-defined] + record = _read_only_record(config.output_dir) + assert record["failure"] == "correlation_binding_invalid" + + +def test_post_rejects_reusing_a_token_within_the_current_run( + tmp_path: Path, + hmac_key_file: Path, +) -> None: + response_body = b'data: {"type":"response.completed"}\n\n' + with _fake_upstream(response_body) as upstream: + upstream_url = f"http://127.0.0.1:{upstream.server_address[1]}" + config = _sidecar_config( + tmp_path, + hmac_key_file, + upstream_url, + hop="post", + ) + header = sidecar._correlation_binding( + hmac_key_file.read_bytes(), CORRELATION_TOKEN, RUN_NONCE + ) + with _running_sidecar(config) as server: + for expected_status in (200, 409): + connection = http.client.HTTPConnection(server.listen_host, server.listen_port, timeout=2) + connection.request( + "POST", + "/responses", + body=b"{}", + headers={"X-CodexHub-Issue62-Capture": header}, + ) + response = connection.getresponse() + assert response.status == expected_status + response.read() + connection.close() + + records = [json.loads(path.read_text(encoding="utf-8")) for path in config.output_dir.glob("*.json")] + assert len(records) == 2 + assert any(record["failure"] == "correlation_token_replayed" for record in records) + assert upstream.observed_request == b"{}" # type: ignore[attr-defined] + + +def test_shutdown_fails_closed_when_a_published_record_is_mutated( + tmp_path: Path, + hmac_key_file: Path, +) -> None: + response_body = b'data: {"type":"response.completed"}\n\n' + with _fake_upstream(response_body) as upstream: + config = _sidecar_config( + tmp_path, + hmac_key_file, + f"http://127.0.0.1:{upstream.server_address[1]}", + ) + server = sidecar.CaptureSidecarServer(config) + server.start() + connection = http.client.HTTPConnection(server.listen_host, server.listen_port, timeout=2) + connection.request("POST", "/responses", body=b"{}") + response = connection.getresponse() + response.read() + connection.close() + record_path = next(config.output_dir.glob("*.json")) + record_path.write_text(record_path.read_text(encoding="utf-8").replace('"status":200', '"status":201'), encoding="utf-8") + assert server.shutdown() is False + + records = [json.loads(path.read_text(encoding="utf-8")) for path in config.output_dir.glob("*.json")] + assert any(record["failure"] == "capture_record_mutated" for record in records) + + +def test_capture_write_failure_is_published_as_sanitized_record( + tmp_path: Path, + hmac_key_file: Path, +) -> None: + config = _sidecar_config(tmp_path, hmac_key_file, "http://127.0.0.1:1") + server = sidecar.CaptureSidecarServer(config) + server._write_control_failure("capture_record_exists") + + record = _read_only_record(config.output_dir) + assert record["outcome"] == "incomplete" + assert record["failure"] == "capture_record_exists" + assert not list(tmp_path.rglob("*.partial")) + + def test_cli_configuration_error_does_not_echo_sensitive_values( tmp_path: Path, hmac_key_file: Path, @@ -808,10 +1174,12 @@ def test_cli_configuration_error_does_not_echo_sensitive_values( "1", "--read-timeout-seconds", "1", - "--overall-timeout-seconds", - "2", - ] - ) + "--overall-timeout-seconds", + "2", + "--run-nonce", + RUN_NONCE, + ] + ) assert result == 2 stderr = capsys.readouterr().err.strip() @@ -860,6 +1228,8 @@ def interrupt_wait(_server: object) -> bool: "1", "--overall-timeout-seconds", "2", + "--run-nonce", + RUN_NONCE, ] ) @@ -906,6 +1276,8 @@ def _enabled_cli_args(tmp_path: Path, hmac_key_file: Path, port: int) -> list[st "1", "--overall-timeout-seconds", "2", + "--run-nonce", + RUN_NONCE, ]