diff --git a/docs/adr/0001-capability-tiered-agentic-execution-harness.md b/docs/adr/0001-capability-tiered-agentic-execution-harness.md index fd12149..2072633 100644 --- a/docs/adr/0001-capability-tiered-agentic-execution-harness.md +++ b/docs/adr/0001-capability-tiered-agentic-execution-harness.md @@ -952,8 +952,10 @@ pass without executing code. authenticated broker loop is implemented. The release-only signer now covers the complete one-folder dependency closure with inert resource entries and refuses to produce metadata without an external key; signed-worker end-to-end broker - execution remains blocked. Collect opt-in aggregate reliability metrics, never - content, only after the provider is sandbox-qualified. + packaged-worker qualification now passes the fixed transform, hostile decoder, + and in-flight cancellation corpus. Collect opt-in aggregate reliability metrics, + never content, only after the remaining release gates close and the provider is + sandbox-qualified. **Implementation gate:** parser, artifact-boundary, qualification-provider, worker-provenance, sandbox-qualification, and native-launcher-policy regression @@ -964,6 +966,11 @@ executed inside the worker, OS sandbox qualification, signed provenance, broker identity, resource/watchdog accounting, external review, and fixed-function provider lifecycle health must pass before any provider is enabled. +The 2026-07-28 packaged qualification evidence closes the hostile decoder corpus, +signed provenance, broker identity, and in-flight cancellation portions of this +gate. Parser fuzzing, artifact security review, resource/watchdog accounting, +external review, and production lifecycle health remain open. + ### Phase 3 — `scratch.auto.v1` arbitrary WebAssembly code - Add the narrow compute tool to qualified tool-capable models. diff --git a/docs/adr/0001-phase2-evidence.md b/docs/adr/0001-phase2-evidence.md index 20a71de..ec57dbc 100644 --- a/docs/adr/0001-phase2-evidence.md +++ b/docs/adr/0001-phase2-evidence.md @@ -1,7 +1,7 @@ # ADR-0001 Phase 2 evidence log - **Phase:** 2 — signed image recipes and calculator/check primitives -- **Status:** Typed contract, signed-manifest verification, native broker transport, authenticated worker loop, signed bundle installation, trusted artifact boundary, signed worker launch/broker qualification, and packaged provider transform qualification complete; watchdog/hostile-decoder evidence, external review, and lifecycle release gates remain open +- **Status:** Typed contract, signed-manifest verification, native broker transport, authenticated worker loop, signed bundle installation, trusted artifact boundary, signed worker launch/broker qualification, and packaged transform/hostile-decoder/cancellation qualification complete; parser fuzzing, resource/watchdog accounting, artifact security review, external review, and lifecycle release gates remain open - **Scope:** Provider-independent contracts plus a qualification-only fixed-function core - **Source decision:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Contract ADR:** [Phase 2 typed recipe and primitive contract](0001-phase2-recipe-contract.md) @@ -24,9 +24,9 @@ | Native named-pipe adapter/DACL/peer-token binding | **Complete (transport-only)** | Protected local pipe, expected PID, OS token identity, X25519/HKDF handshake, direction keys, and close-on-error lifecycle are covered by native broker tests. | | User-artifact copy-in, output validation, and publication | **Complete (boundary only)** | Explicit owner/turn grants, bounded stable snapshots, link/reparse/hardlink/sparse/ADS rejection, byte-derived MIME policy, exact output claims, quarantine, hash/size limits, atomic repository publication, rollback, and cleanup categories are covered by `tests/test_phase2_artifact_boundary.py`. | | Fixed-function image provider core | **Complete (qualification-only)** | `RecipeImageProvider` validates allowlisted PNG/JPEG/WebP bytes, verifies/loads one frame with Pillow bomb/resource limits, applies only parsed steps, strips metadata, revalidates encoded output, checks cancellation, and remains disabled until external sandbox health passes. | -| Windows recipe sandbox qualification harness | **Complete (signed launch/broker/provider transform)** | `recipe_worker_e2e_qualification.py` signs a disposable package with an in-memory key, installs/verifies one immutable generation, binds the live AppContainer identity to the broker, and exercises the fixed PNG grayscale corpus through `collect_output`. The native broker uses bounded availability polling before reads so the cancellation reader remains live while the packaged provider transforms. | +| Windows recipe sandbox qualification harness | **Complete (signed launch/broker/hostile/cancellation)** | `recipe_worker_e2e_qualification.py` signs a disposable package with an in-memory key, installs/verifies one immutable generation, binds the live AppContainer identity to the broker, and exercises the fixed PNG transform, truncated-PNG decoder rejection, active-SVG decoder rejection, and in-flight cancellation corpus. The native broker uses bounded availability polling before reads so the cancellation reader remains live while the packaged provider transforms. | | Suspended native launcher/resource policy | **Complete (factory + binder + ACL cleanup + qualification evidence)** | `NativeWin32ProcessFactory` grants only inherited read/execute access to the fresh AppContainer SID on the verified package root, applies and verifies Job Object policy before resume, and removes the per-launch ACE during cleanup. `NativeBrokerIdentityBinder` pins the live server to the worker PID/AppContainer SID and launcher cleanup closes it on failure. | -| OS sandbox provider and provider-produced image outputs | **Blocked / release gate** | Signed installation, provenance, AppContainer/job identity, broker handshake, `prepare`, `input_chunk`, `input_complete`, and `collect_output` are live-qualified inside the packaged worker. The remaining gate is watchdog/cancellation and hostile-decoder evidence, external review, and production lifecycle wiring. | +| OS sandbox provider and provider-produced image outputs | **Blocked / release gate** | Signed installation, provenance, AppContainer/job identity, broker handshake, `prepare`, `input_chunk`, `input_complete`, `collect_output`, hostile decoder rejection, and in-flight cancellation acknowledgement are live-qualified inside the packaged worker. The remaining gate is parser fuzzing, artifact security review, resource/watchdog accounting, external review, and production lifecycle wiring. | ## Security invariants @@ -96,6 +96,9 @@ 26. Native broker reads poll bounded pipe availability before synchronous reads so a cancellation reader cannot starve the provider transform; pipe errors still fail closed and do not bypass framing, authentication, or sequence checks. +27. Hostile decoder bytes are rejected inside the signed worker, and cancellation is + sent only after `input_complete` over the authenticated broker; a missing or + ambiguous terminal response remains a blocked qualification result. ## Re-run target @@ -150,3 +153,16 @@ closed the broker, binder, worker, and profile; no `recipe_worker.exe` process remained. This closes the packaged provider-transform qualification gate but does not close the remaining watchdog/hostile-decoder, external-review, or lifecycle release gates. + +**Packaged hostile/cancellation qualification result (2026-07-28):** The full +`recipe_worker_e2e_qualification.py --json --timeout-seconds 10 --strict` run +passed all four signed-worker cases: normal fixed PNG transform with +`collect_output`, truncated PNG rejection, active SVG rejection, and an +in-flight eight-step bounded transform that returned `cancel_ack` after +`input_complete`. Each case used a fresh authenticated broker binding and +disposable worker; the run reported no worker process or binding cleanup +residue. The optional `--case cancellation` path reproduces the cancellation +gate in isolation. These results close the packaged hostile-decoder and +cancellation qualification evidence, but do not enable the provider or close +parser fuzzing, resource/watchdog accounting, artifact security review, +external review, or production lifecycle health gates. diff --git a/tools/execution_spikes/README.md b/tools/execution_spikes/README.md index 43c7618..a3b2d9d 100644 --- a/tools/execution_spikes/README.md +++ b/tools/execution_spikes/README.md @@ -68,25 +68,29 @@ disposable end-to-end check: ```powershell python tools/execution_spikes/recipe_worker_e2e_qualification.py --json python tools/execution_spikes/recipe_worker_e2e_qualification.py --json --strict +# Optional: rerun one release-gate case in isolation. +python tools/execution_spikes/recipe_worker_e2e_qualification.py --case cancellation --json --strict ``` It creates an in-memory ephemeral Ed25519 trust root, signs the already-built one-folder package, installs one immutable generation, verifies provenance, launches the worker through the native AppContainer/job-policy factory, and -exercises only the fixed 4x3 PNG grayscale corpus over the authenticated broker. -It accepts no user files, model text, commands, or production trust material. -`--strict` returns exit code `2` unless every stage passes. Full package closure -verification can take several minutes on Windows; the protocol timeout applies -after launch and is fail-closed. - -The current evidence result passes the packaged provider transform: +exercises a fixed PNG transform, truncated-PNG and active-SVG hostile decoder +cases, and an in-flight eight-step cancellation corpus over the authenticated +broker. It accepts no user files, model text, commands, or production trust +material. `--strict` returns exit code `2` unless every selected stage passes. +Use `--case` to rerun one case during diagnosis; the default runs the full +corpus. Full package closure verification can take several minutes on Windows; +the protocol timeout applies after launch and is fail-closed. + +The current evidence result passes the complete packaged-worker corpus: signed installation, provenance, AppContainer/job identity binding, broker -handshake, `prepare`, `input_chunk`, `input_complete`, and `collect_output` all -complete through the authenticated broker. The native read path polls -`PeekNamedPipe` with a bounded 5 ms wait before `ReadFile`, keeping the worker's -cancellation reader live without starving the provider transform. The harness -still does not close the remaining watchdog/hostile-decoder, external-review, or -production-lifecycle release gates. +handshake, normal `collect_output`, hostile decoder rejection for truncated PNG +and active SVG bytes, and an in-flight `cancel_ack` after `input_complete`. The +native read path polls `PeekNamedPipe` with a bounded 5 ms wait before `ReadFile`, +keeping the worker's cancellation reader live without starving the provider +transform. Parser fuzzing, resource/watchdog accounting, artifact security review, +external review, and production-lifecycle release gates remain open. ## What the probes prove diff --git a/tools/execution_spikes/recipe_worker_e2e_qualification.py b/tools/execution_spikes/recipe_worker_e2e_qualification.py index ceb61b5..ddb3ac7 100644 --- a/tools/execution_spikes/recipe_worker_e2e_qualification.py +++ b/tools/execution_spikes/recipe_worker_e2e_qualification.py @@ -3,8 +3,9 @@ This probe is deliberately separate from Cortex runtime wiring. It signs the already-built fixed worker with an in-memory ephemeral Ed25519 key, installs it into a disposable store, launches it through the reviewed AppContainer factory, -and exercises only the fixed PNG grayscale protocol corpus. No user files, -model text, commands, paths, or production trust roots are accepted. +and exercises a fixed PNG transform, hostile decoder corpus, and in-flight +cancellation protocol corpus. No user files, model text, commands, paths, or +production trust roots are accepted. """ from __future__ import annotations @@ -46,6 +47,7 @@ from cortex_backend.execution.native_win32 import NativeWin32ProcessFactory # noqa: E402 from cortex_backend.execution.recipes import parse_image_transform # noqa: E402 from cortex_backend.execution.worker_protocol import ( # noqa: E402 + WorkerCancel, WorkerCollect, WorkerInputChunk, WorkerInputComplete, @@ -56,6 +58,11 @@ DEFAULT_TIMEOUT_SECONDS = 20.0 +# The worker protocol permits 48 KiB chunks, but base64 plus the authenticated +# broker envelope must also fit within its 64 KiB frame ceiling. Keep this +# qualification transport bound conservative so the test exercises the worker +# rather than failing at the outer framing layer. +QUALIFICATION_CHUNK_BYTES = 32 * 1024 _TOKEN_QUERY = 0x0008 _TOKEN_USER = 1 _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 @@ -158,6 +165,35 @@ def _fixed_png() -> bytes: image.close() +def _slow_png() -> bytes: + """Return a deterministic bounded image large enough for cancellation timing.""" + + image = Image.new("RGB", (4, 3), (120, 80, 40)) + try: + with BytesIO() as stream: + image.save(stream, format="PNG") + return stream.getvalue() + finally: + image.close() + + +def _plan(*, artifact_id: str, slow: bool = False) -> Any: + steps: list[dict[str, Any]] = [{"op": "grayscale"}] + if slow: + steps = [ + {"op": "resize", "width": 3500 - (index % 2), "height": 3500 - (index % 2)} + for index in range(8) + ] + return parse_image_transform( + { + "schema_version": "artifact.transform.v1", + "input_artifact_id": artifact_id, + "steps": steps, + "output_format": "png", + } + ) + + def _receive_with_timeout( connection: NativeBrokerConnection, timeout_seconds: float, @@ -241,145 +277,261 @@ def _install_ephemeral(source_root: Path, store_root: Path) -> SignedBundleInsta return installer -def qualify(source_root: Path, *, timeout_seconds: float) -> dict[str, Any]: +def _close_worker_case( + connection: NativeBrokerConnection | None, + worker: Any, + binder: NativeBrokerIdentityBinder, + workspace: Path, +) -> None: + """Boundedly close one disposable worker, broker binding, and process tree.""" + + if connection is not None: + _bounded_cleanup(connection.close) + _bounded_cleanup(binder.close_binding) + if worker is not None: + _bounded_cleanup(worker.close) + try: + executable = _process_executable(worker.process_id) + if executable is not None and executable.is_relative_to(workspace.resolve()): + subprocess.run( + ["taskkill", "/PID", str(worker.process_id), "/T", "/F"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except Exception: + pass + + +def qualify( + source_root: Path, + *, + timeout_seconds: float, + case_name: str | None = None, +) -> dict[str, Any]: if not source_root.is_dir(): return {"status": "blocked", "code": "package_missing", "stages": []} workspace = Path(mkdtemp(prefix="cortex-worker-e2e-qualification-")) stages: list[str] = [] - connection: NativeBrokerConnection | None = None - worker = None + cases: dict[str, dict[str, Any]] = {} binder: NativeBrokerIdentityBinder | None = None try: installer = _install_ephemeral(source_root, workspace / "store") stages.extend(["signed_ephemeral_manifest", "installed_immutable_generation"]) verify_active_worker(installer) stages.append("provenance_verified") - principal = sha256(os.urandom(32)).hexdigest() - job_id = f"qualification-{uuid4().hex[:12]}" - binding = BrokerWorkerBinding( - pipe_name=rf"\\.\pipe\cortex-qualification-{uuid4().hex}", - broker_process_id=os.getpid(), - installation_principal_id=principal, - job_id=job_id, - ) binder = NativeBrokerIdentityBinder(allowed_user_sids=frozenset({_current_user_sid()})) launcher = NativeWorkerLauncher( installer, process_factory=NativeWin32ProcessFactory(), broker_binder=binder, ) - worker = launcher.launch(binding) - stages.append("appcontainer_job_policy_and_identity_bound") - accepted: Queue[object] = Queue(maxsize=1) - def accept() -> None: + def run_case( + case_name: str, + content: bytes, + plan: Any, + *, + mode: str, + ) -> None: + principal = sha256(os.urandom(32)).hexdigest() + job_id = f"qualification-{case_name}-{uuid4().hex[:12]}" + request_id = f"qualification-{case_name}" + binding = BrokerWorkerBinding( + pipe_name=rf"\\.\pipe\cortex-qualification-{uuid4().hex}", + broker_process_id=os.getpid(), + installation_principal_id=principal, + job_id=job_id, + ) + connection: NativeBrokerConnection | None = None + worker: Any = None + case_stages: list[str] = [] try: - accepted.put( - binder.accept( - owner_for_job=lambda value: principal if value == job_id else None + worker = launcher.launch(binding) + case_stages.append("appcontainer_job_policy_and_identity_bound") + accepted: Queue[object] = Queue(maxsize=1) + + def accept() -> None: + try: + accepted.put( + binder.accept( + owner_for_job=lambda value: principal if value == job_id else None + ) + ) + except Exception as error: # pragma: no cover - native failure path. + accepted.put(error) + + Thread(target=accept, name="cortex-qualification-accept", daemon=True).start() + try: + value = accepted.get(timeout=timeout_seconds) + except Exception: + raise TimeoutError("broker handshake timeout") from None + if isinstance(value, Exception): + raise value + connection = value + case_stages.append("authenticated_broker_handshake") + + digest = sha256(content).hexdigest() + prepare = WorkerPrepare( + schema_version="recipe.worker.prepare.v1", + request_id=request_id, + job_id=job_id, + plan=plan, + input_size=len(content), + input_sha256=digest, + input_mime_type="image/png", + ) + connection.send_message(_message("prepare", prepare, principal=principal, job_id=job_id)) + response = _receive_with_timeout(connection, timeout_seconds) + if _response_code(response, "prepare") != "recipe.worker.ack.v1": + raise ValueError("prepare acknowledgement invalid") + case_stages.append("prepare_ack") + + offset = 0 + chunk_number = 0 + while offset < len(content): + part = content[offset : offset + QUALIFICATION_CHUNK_BYTES] + chunk = WorkerInputChunk( + schema_version="recipe.worker.input_chunk.v1", + request_id=request_id, + job_id=job_id, + offset=offset, + data=base64.urlsafe_b64encode(part).decode("ascii").rstrip("="), + sha256=sha256(part).hexdigest(), ) + connection.send_message( + _message("input_chunk", chunk, principal=principal, job_id=job_id) + ) + response = _receive_with_timeout(connection, timeout_seconds) + if _response_code(response, "input_chunk") != "recipe.worker.ack.v1": + raise ValueError("input acknowledgement invalid") + offset += len(part) + chunk_number += 1 + case_stages.append("input_chunk_ack") + + complete = WorkerInputComplete( + schema_version="recipe.worker.input_complete.v1", + request_id=request_id, + job_id=job_id, + input_size=len(content), + input_sha256=digest, ) - except Exception as error: # pragma: no cover - native failure path. - accepted.put(error) - - Thread(target=accept, name="cortex-qualification-accept", daemon=True).start() - try: - value = accepted.get(timeout=timeout_seconds) - except Exception: - raise TimeoutError("broker handshake timeout") from None - if isinstance(value, Exception): - raise value - connection = value - stages.append("authenticated_broker_handshake") - - content = _fixed_png() - request_id = "qualification-request" - plan = parse_image_transform( - { - "schema_version": "artifact.transform.v1", - "input_artifact_id": "qualification-artifact", - "steps": [{"op": "grayscale"}], - "output_format": "png", - } - ) - prepare = WorkerPrepare( - schema_version="recipe.worker.prepare.v1", - request_id=request_id, - job_id=job_id, - plan=plan, - input_size=len(content), - input_sha256=sha256(content).hexdigest(), - input_mime_type="image/png", - ) - connection.send_message(_message("prepare", prepare, principal=principal, job_id=job_id)) - response = _receive_with_timeout(connection, timeout_seconds) - if _response_code(response, "prepare") != "recipe.worker.ack.v1": - raise ValueError("prepare acknowledgement invalid") - stages.append("prepare_ack") - - chunk = WorkerInputChunk( - schema_version="recipe.worker.input_chunk.v1", - request_id=request_id, - job_id=job_id, - offset=0, - data=base64.urlsafe_b64encode(content).decode("ascii").rstrip("="), - sha256=sha256(content).hexdigest(), - ) - connection.send_message(_message("input_chunk", chunk, principal=principal, job_id=job_id)) - response = _receive_with_timeout(connection, timeout_seconds) - if _response_code(response, "input_chunk") != "recipe.worker.ack.v1": - raise ValueError("input acknowledgement invalid") - stages.append("input_chunk_ack") - - complete = WorkerInputComplete( - schema_version="recipe.worker.input_complete.v1", - request_id=request_id, - job_id=job_id, - input_size=len(content), - input_sha256=sha256(content).hexdigest(), - ) - connection.send_message(_message("input_complete", complete, principal=principal, job_id=job_id)) - response = _receive_with_timeout(connection, timeout_seconds) - if response.operation != "input_complete": - raise ValueError("input completion response invalid") - stages.append("input_complete_result") - - collect = WorkerCollect( - schema_version="recipe.worker.collect.v1", - request_id=request_id, - job_id=job_id, - offset=0, - max_bytes=48 * 1024, + connection.send_message( + _message("input_complete", complete, principal=principal, job_id=job_id) + ) + if mode == "cancel": + case_stages.append("input_complete_sent") + cancel = WorkerCancel( + schema_version="recipe.worker.cancel.v1", + request_id=request_id, + job_id=job_id, + reason="user", + ) + connection.send_message( + _message("cancel", cancel, principal=principal, job_id=job_id) + ) + response = _receive_with_timeout(connection, timeout_seconds) + if response.operation == "cancel" and _response_code(response, "cancel") == "recipe.worker.ack.v1": + case_stages.append("cancel_ack") + elif ( + response.operation == "input_complete" + and response.body.get("schema_version") == "recipe.worker.error.v1" + and response.body.get("code") == "cancelled" + ): + case_stages.append("cancelled_result") + else: + raise ValueError( + "cancellation was not acknowledged before completion:" + f" operation={response.operation}" + f" schema={response.body.get('schema_version')}" + f" code={response.body.get('code')}" + ) + cases[case_name] = {"status": "passed", "stages": case_stages, "chunks": chunk_number} + stages.extend(f"{case_name}:{stage}" for stage in case_stages) + return + + response = _receive_with_timeout(connection, timeout_seconds) + if mode == "hostile": + if ( + response.operation != "input_complete" + or response.body.get("schema_version") != "recipe.worker.error.v1" + or response.body.get("code") not in {"invalid_input", "decode_failed", "unsupported_format"} + ): + raise ValueError("hostile decoder input was not rejected safely") + case_stages.append("hostile_input_rejected") + cases[case_name] = {"status": "passed", "stages": case_stages, "chunks": chunk_number} + stages.extend(f"{case_name}:{stage}" for stage in case_stages) + return + + if response.operation != "input_complete": + raise ValueError("input completion response invalid") + case_stages.append("input_complete_result") + collect = WorkerCollect( + schema_version="recipe.worker.collect.v1", + request_id=request_id, + job_id=job_id, + offset=0, + max_bytes=QUALIFICATION_CHUNK_BYTES, + ) + connection.send_message(_message("collect", collect, principal=principal, job_id=job_id)) + response = _receive_with_timeout(connection, timeout_seconds) + if _response_code(response, "collect") != "recipe.worker.output_chunk.v1": + raise ValueError("output response invalid") + case_stages.append("collect_output") + cases[case_name] = {"status": "passed", "stages": case_stages, "chunks": chunk_number} + stages.extend(f"{case_name}:{stage}" for stage in case_stages) + except TimeoutError: + cases[case_name] = { + "status": "blocked", + "stages": case_stages, + "error_type": "TimeoutError", + "error": "worker response timeout", + } + raise + except Exception as error: + cases[case_name] = { + "status": "blocked", + "stages": case_stages, + "error_type": type(error).__name__, + "error": str(error), + } + raise + finally: + _close_worker_case(connection, worker, binder, workspace) + + selected_cases = ( + ("transform", _fixed_png(), _plan(artifact_id="qualification-transform"), "success"), + ( + "hostile_truncated_png", + b"\x89PNG\r\n\x1a\ntruncated", + _plan(artifact_id="qualification-hostile-truncated"), + "hostile", + ), + ( + "hostile_active_svg", + b"", + _plan(artifact_id="qualification-hostile-svg"), + "hostile", + ), + ( + "cancellation", + _slow_png(), + _plan(artifact_id="qualification-cancellation", slow=True), + "cancel", + ), ) - connection.send_message(_message("collect", collect, principal=principal, job_id=job_id)) - response = _receive_with_timeout(connection, timeout_seconds) - if _response_code(response, "collect") != "recipe.worker.output_chunk.v1": - raise ValueError("output response invalid") - stages.append("collect_output") - return {"status": "passed", "stages": stages} + for selected_name, content, plan, mode in selected_cases: + if case_name is None or case_name == selected_name: + run_case(selected_name, content, plan, mode=mode) + return {"status": "passed", "stages": stages, "cases": cases} except TimeoutError: - return {"status": "blocked", "code": "worker_response_timeout", "stages": stages} + return {"status": "blocked", "code": "worker_response_timeout", "stages": stages, "cases": cases} except Exception: - return {"status": "blocked", "code": "qualification_failed_closed", "stages": stages} + return {"status": "blocked", "code": "qualification_failed_closed", "stages": stages, "cases": cases} finally: - if connection is not None: - _bounded_cleanup(connection.close) if binder is not None: _bounded_cleanup(binder.close_binding) - if worker is not None: - _bounded_cleanup(worker.close) - try: - executable = _process_executable(worker.process_id) - if executable is not None and executable.is_relative_to(workspace.resolve()): - subprocess.run( - ["taskkill", "/PID", str(worker.process_id), "/T", "/F"], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) - except Exception: - pass try: shutil.rmtree(workspace) except OSError: @@ -394,10 +546,19 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--timeout-seconds", type=float, default=DEFAULT_TIMEOUT_SECONDS) parser.add_argument("--json", action="store_true") parser.add_argument("--strict", action="store_true") + parser.add_argument( + "--case", + choices=("transform", "hostile_truncated_png", "hostile_active_svg", "cancellation"), + help="qualify one case instead of the full signed-worker corpus", + ) args = parser.parse_args(argv) if not 1 <= args.timeout_seconds <= 120: parser.error("--timeout-seconds must be between 1 and 120") - result = qualify(args.source_root.resolve(), timeout_seconds=args.timeout_seconds) + result = qualify( + args.source_root.resolve(), + timeout_seconds=args.timeout_seconds, + case_name=args.case, + ) print(json.dumps(result, sort_keys=True, separators=(",", ":") if args.json else None)) return 2 if args.strict and result["status"] != "passed" else 0