From 9122156bd959f207e59c06a5593152b7dcbfb708 Mon Sep 17 00:00:00 2001 From: Matthew Wesney Date: Tue, 28 Jul 2026 22:15:09 -0400 Subject: [PATCH] Qualify artifact security boundary --- .github/workflows/quality.yml | 3 + .../execution/artifact_boundary.py | 62 +++- .../cortex_backend/execution/repository.py | 14 +- ...bility-tiered-agentic-execution-harness.md | 11 +- docs/adr/0001-phase2-artifact-boundary.md | 25 +- docs/adr/0001-phase2-evidence.md | 30 +- tests/test_phase2_artifact_boundary.py | 55 +++ tests/test_phase2_artifact_security_review.py | 18 + tools/execution_spikes/README.md | 26 +- .../artifact_security_review.py | 333 ++++++++++++++++++ 10 files changed, 543 insertions(+), 34 deletions(-) create mode 100644 tests/test_phase2_artifact_security_review.py create mode 100644 tools/execution_spikes/artifact_security_review.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7ecc5fa..ed4e4ca 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -35,6 +35,9 @@ jobs: - name: Run headless tests run: python -m pytest + - name: Run artifact security qualification + run: python tools/execution_spikes/artifact_security_review.py --json --strict + - name: Compile application modules run: python -m compileall -q main.py backend diff --git a/backend/cortex_backend/execution/artifact_boundary.py b/backend/cortex_backend/execution/artifact_boundary.py index 26ea091..ac4446f 100644 --- a/backend/cortex_backend/execution/artifact_boundary.py +++ b/backend/cortex_backend/execution/artifact_boundary.py @@ -13,6 +13,7 @@ from dataclasses import dataclass from hashlib import sha256 import json +import math import os from pathlib import Path import re @@ -38,6 +39,17 @@ b"7z\xbc\xaf\x27\x1c", b"\x1f\x8b", ) +_ACTIVE_TEXT_MARKERS = ( + b" Any: raise _NonFiniteJSON("non-finite JSON number") +def _parse_finite_json_float(value: str) -> float: + """Reject exponent overflow even when JSON does not spell ``Infinity``.""" + + parsed = float(value) + if not math.isfinite(parsed): + raise _NonFiniteJSON("non-finite JSON number") + return parsed + + +def _active_text_prefixes(prefix: bytes) -> tuple[bytes, ...]: + """Return normalized UTF-8/UTF-16/UTF-32 prefixes for active-content checks.""" + + candidates = [prefix.lstrip(b"\xef\xbb\xbf \t\r\n").lower()] + for encoding in ("utf-32", "utf-16"): + try: + decoded = prefix.decode(encoding) + except UnicodeError: + continue + candidates.append(decoded.lstrip("\ufeff \t\r\n").lower().encode("utf-8")) + return tuple(candidates) + + +def _contains_active_text(prefixes: tuple[bytes, ...]) -> bool: + return any( + any(prefix.startswith(marker) for marker in _ACTIVE_TEXT_MARKERS) + or b" str: """Return a conservative MIME type or reject active/archive content.""" if not isinstance(content, bytes): raise ArtifactBoundaryError("invalid_artifact") prefix = content[:4096] - lower = prefix.lstrip(b"\xef\xbb\xbf \t\r\n").lower() + prefixes = _active_text_prefixes(prefix) + lower = prefixes[0] if content.startswith(b"\x89PNG\r\n\x1a\n"): return "image/png" if content.startswith(b"\xff\xd8\xff"): @@ -220,19 +263,18 @@ def sniff_artifact_mime(content: bytes) -> str: raise ArtifactBoundaryError("invalid_artifact") if any(content.startswith(magic) for magic in _ARCHIVE_MAGICS) or content[257:262] == b"ustar": raise ArtifactBoundaryError("invalid_artifact") - if ( - lower.startswith((b" ExecutionArtifact: """Copy one explicitly granted source into the owner-scoped artifact store.""" + if not isinstance(grant, ArtifactSourceGrant): + raise ArtifactBoundaryError("artifact_grant_invalid") self._validate_retention(retention_seconds) self._authorize_job(grant.job_id, grant.owner) source = grant.source_path diff --git a/backend/cortex_backend/execution/repository.py b/backend/cortex_backend/execution/repository.py index 8596dd6..342f854 100644 --- a/backend/cortex_backend/execution/repository.py +++ b/backend/cortex_backend/execution/repository.py @@ -895,13 +895,19 @@ def delete_artifact(self, artifact_id: str) -> None: def read_artifact(self, artifact_id: str) -> bytes: with self.connect() as connection: row = connection.execute( - "SELECT path, sha256, expires_at FROM execution_artifacts WHERE artifact_id = ?", + "SELECT path, size, sha256, expires_at FROM execution_artifacts WHERE artifact_id = ?", (artifact_id,), ).fetchone() if row is None: raise ExecutionRepositoryError("Artifact does not exist.") if datetime.fromisoformat(row["expires_at"]) <= datetime.now(timezone.utc): raise ExecutionRepositoryError("Artifact retention has expired.") + try: + expected_size = int(row["size"]) + except (TypeError, ValueError): + raise ExecutionRepositoryError("Artifact integrity check failed.") from None + if not 0 <= expected_size <= self.max_artifact_bytes: + raise ExecutionRepositoryError("Artifact integrity check failed.") original_path = Path(row["path"]) if _is_reparse_point(original_path): raise ExecutionRepositoryError("Artifact path is unavailable.") @@ -915,12 +921,16 @@ def read_artifact(self, artifact_id: str) -> bytes: or _is_reparse_point(path) or not stat.S_ISREG(info.st_mode) or getattr(info, "st_nlink", 1) != 1 + or int(info.st_size) != expected_size ): raise ExecutionRepositoryError("Artifact path is unavailable.") try: - content = path.read_bytes() + with path.open("rb") as stream: + content = stream.read(self.max_artifact_bytes + 1) except OSError: raise ExecutionRepositoryError("Artifact path is unavailable.") from None + if len(content) != expected_size or len(content) > self.max_artifact_bytes: + raise ExecutionRepositoryError("Artifact integrity check failed.") if hashlib.sha256(content).hexdigest() != row["sha256"]: raise ExecutionRepositoryError("Artifact integrity check failed.") return content diff --git a/docs/adr/0001-capability-tiered-agentic-execution-harness.md b/docs/adr/0001-capability-tiered-agentic-execution-harness.md index c1d3a3e..91d52b4 100644 --- a/docs/adr/0001-capability-tiered-agentic-execution-harness.md +++ b/docs/adr/0001-capability-tiered-agentic-execution-harness.md @@ -953,9 +953,11 @@ pass without executing code. complete one-folder dependency closure with inert resource entries and refuses to produce metadata without an external key; signed-worker end-to-end broker 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. + and in-flight cancellation corpus. The deterministic artifact-security corpus now + also qualifies owner/path binding, active-content rejection, exact claims, + quarantine, rollback, and repository integrity without user/model input. 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 @@ -969,7 +971,8 @@ 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. The 2026-07-28 parser qualification also closes the deterministic parser- -fuzzing portion. Artifact security review, resource/watchdog accounting, external +fuzzing portion. The 2026-07-28 artifact-security qualification closes the +deterministic copy-in/publication review. Resource/watchdog accounting, external review, and production lifecycle health remain open. ### Phase 3 — `scratch.auto.v1` arbitrary WebAssembly code diff --git a/docs/adr/0001-phase2-artifact-boundary.md b/docs/adr/0001-phase2-artifact-boundary.md index bcf44f6..d7c9182 100644 --- a/docs/adr/0001-phase2-artifact-boundary.md +++ b/docs/adr/0001-phase2-artifact-boundary.md @@ -47,8 +47,10 @@ renamed, replaced, or deleted. MIME is derived from bytes, never from an extension or model declaration. PNG, JPEG, WebP, finite JSON, and ordinary UTF-8 text are recognized. Portable executables, -ELF/OLE files, shortcuts, shebang scripts, archives, active HTML/SVG/JavaScript, -and common shell/PowerShell launchers are rejected. Unknown non-active bytes may be +ELF/OLE files, shortcuts, shebang scripts, archives, active HTML/SVG/JavaScript +(including UTF-16/UTF-32 markup), and common shell/PowerShell launchers are rejected. +Exponent-overflow JSON and non-finite JSON constants are rejected, while oversized +numeric literals cannot escape as parser exceptions. Unknown non-active bytes may be stored as `application/octet-stream`; this is metadata safety, not permission to decode or execute them. Production image decoding remains behind the separate sandboxed [recipe provider qualification gate](0001-phase2-recipe-provider.md). @@ -82,7 +84,7 @@ of a path outside the configured artifact root. ### 5. Stable failure categories The boundary exposes only categories such as `artifact_owner_mismatch`, -`artifact_path_invalid`, `artifact_reparse_point`, `artifact_hardlink_rejected`, +`artifact_grant_invalid`, `artifact_path_invalid`, `artifact_reparse_point`, `artifact_hardlink_rejected`, `artifact_source_changed`, `artifact_too_large`, `invalid_artifact`, `artifact_unclaimed_output`, `artifact_mime_mismatch`, `artifact_output_limit`, `artifact_publish_failed`, and `artifact_cleanup_pending`. Raw paths and operating @@ -91,15 +93,18 @@ system details do not cross the application boundary. ## Verification and evidence `tests/test_phase2_artifact_boundary.py` covers owner binding, source preservation, -ADS/link/reparse rejection, source mutation, exact claims, quarantine, active/archive -rejection, executable rejection, non-finite JSON rejection, MIME mismatch, aggregate -limits, and all-or-nothing publication rollback. The repository tests continue to -cover hash/size/retention behavior. The complete matrix is recorded in the -[Phase 2 evidence log](0001-phase2-evidence.md). +ADS/link/reparse rejection, source mutation, sparse-file rejection, exact claims, +quarantine, active/archive rejection, executable rejection, finite-number checks, +MIME mismatch, aggregate limits, all-or-nothing publication rollback, and stored-size +integrity. The deterministic `tools/execution_spikes/artifact_security_review.py` +probe repeats the fixed 12-case corpus in a disposable temporary root and reports +`blocked` when a required host link primitive is unavailable. The complete matrix is +recorded in the [Phase 2 evidence log](0001-phase2-evidence.md). ## Explicit non-goals This ADR does not authorize image decoding, thumbnail generation, archive extraction, provider loading, code execution, model tool exposure, network access, or automatic -execution. The next gate is fixed-function provider qualification inside the already -planned OS sandbox, followed by a lifecycle health check and external security review. +execution. The artifact security review is complete; provider enablement remains +blocked on resource/watchdog accounting, external security review, and lifecycle +health gates in the parent ADR. diff --git a/docs/adr/0001-phase2-evidence.md b/docs/adr/0001-phase2-evidence.md index 306e527..5bcbf96 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, deterministic parser-fuzz qualification, 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; resource/watchdog accounting, artifact security review, external review, and lifecycle release gates remain open +- **Status:** Typed contract, deterministic parser-fuzz qualification, signed-manifest verification, native broker transport, authenticated worker loop, signed bundle installation, trusted artifact boundary, artifact security review, signed worker launch/broker qualification, and packaged transform/hostile-decoder/cancellation qualification complete; resource/watchdog accounting, 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,10 +24,11 @@ | Authenticated broker contract | **Complete (transport-neutral)** | Bounded versioned frames, direction-specific HMAC keys, canonical messages, peer ACL/integrity policy, and owner-scoped authorization are covered by adversarial tests. | | 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`. | +| Deterministic artifact security review | **Complete (qualification-only)** | `artifact_security_review.py --json --strict` passed the fixed 12-case disposable corpus (`artifact-boundary-review.v1`, digest `a748cc9f0a514c8d`): owner/path binding, link/hardlink rejection, active/non-finite content, exact claims/quarantine, rollback, and repository size integrity. Missing link primitives remain blocked. | | 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/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`, `collect_output`, hostile decoder rejection, and in-flight cancellation acknowledgement are live-qualified inside the packaged worker. The remaining gate is artifact security review, resource/watchdog accounting, 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, in-flight cancellation acknowledgement, and artifact security review are qualified. The remaining gate is resource/watchdog accounting, external review, and production lifecycle wiring. | ## Security invariants @@ -103,6 +104,10 @@ 28. Typed parser fuzzing uses a fixed seed, bounded iteration count, bounded payload depth, and stable redacted error categories; an unexpected exception or budget violation is a blocked qualification result. +29. Artifact security qualification uses fixed bytes and a disposable temporary root; + it checks owner/path binding, active-content and numeric safety, link rejection, + exact claims, quarantine, rollback, and stored-size integrity. The probe accepts + no user/model input and reports an unavailable required link primitive as blocked. ## Re-run target @@ -122,6 +127,7 @@ python tools/execution_spikes/native_launcher_qualification.py python tools/execution_spikes/recipe_sandbox_qualification.py --json --strict python tools/execution_spikes/recipe_worker_e2e_qualification.py --json --strict python tools/execution_spikes/recipe_parser_fuzz.py --json --strict +python tools/execution_spikes/artifact_security_review.py --json --strict python -m compileall -q backend\cortex_backend\execution tests python -m pytest -q python tools/generate_contracts.py @@ -170,7 +176,8 @@ 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 resource/watchdog accounting, artifact security review, external review, or -production lifecycle health gates. +production lifecycle health gates; those were the remaining gates at the time of +this run. **Parser fuzz qualification result (2026-07-28):** The deterministic `recipe_parser_fuzz.py --iterations 2000 --seed 20260728 --json --strict` run @@ -180,5 +187,18 @@ typed parsers, including unknown fields, malformed operations, non-mapping values, oversized payloads, control/unicode text, and invalid optional values. The corpus exposed and the parser fixed an uncaught `None` tolerance validator failure for `check.v1`/`is_close`; the regression suite now locks that behavior -to `invalid_check`. Resource/watchdog accounting, artifact security review, -external review, and production lifecycle health remain open. +to `invalid_check`. At the time of this run, resource/watchdog accounting, +artifact security review, external review, and production lifecycle health +remained open. + +**Artifact security review result (2026-07-28):** The deterministic +`artifact_security_review.py --json --strict` probe passed all 12 cases in the +fixed `artifact-boundary-review.v1` corpus (`a748cc9f0a514c8d`). The disposable +review covered source preservation and owner/ADS binding, hardlink/symlink +rejection, active UTF-8/UTF-16 and non-finite numeric content, exact output claims +and quarantine, all-or-nothing rollback, and repository stored-size integrity. +The review also fixed two fail-closed gaps: exponent-overflow JSON is rejected as +non-finite, and oversized JSON integers no longer leak a `ValueError`; UTF-16 +active markup is rejected instead of being classified as inert binary. The focused +artifact suite now passes 24 boundary tests plus the reproducibility test. Resource/ +watchdog accounting, external review, and production lifecycle health remain open. diff --git a/tests/test_phase2_artifact_boundary.py b/tests/test_phase2_artifact_boundary.py index 600810f..2f9cf7c 100644 --- a/tests/test_phase2_artifact_boundary.py +++ b/tests/test_phase2_artifact_boundary.py @@ -81,6 +81,27 @@ def test_copy_in_rejects_wrong_owner_and_alternate_data_stream_paths(tmp_path: P assert ads_error.value.code == "artifact_path_invalid" +def test_copy_in_rejects_an_untyped_grant_with_a_stable_category(tmp_path: Path): + repository, job_id = _repository(tmp_path) + boundary = ArtifactBoundary(repository) + + with pytest.raises(ArtifactBoundaryError) as error: + boundary.copy_in(object()) + assert error.value.code == "artifact_grant_invalid" + + +def test_copy_in_rejects_sparse_files_before_reading_them(tmp_path: Path, monkeypatch): + repository, job_id = _repository(tmp_path) + source = tmp_path / "sparse.bin" + source.write_bytes(b"sparse candidate") + boundary = ArtifactBoundary(repository) + monkeypatch.setattr(boundary_module, "_is_sparse", lambda _info: True) + + with pytest.raises(ArtifactBoundaryError) as error: + boundary.copy_in(_grant(source, job_id)) + assert error.value.code == "artifact_sparse_file" + + def test_copy_in_rejects_source_mutation_during_snapshot(tmp_path: Path, monkeypatch): repository, job_id = _repository(tmp_path) source = tmp_path / "input.txt" @@ -173,7 +194,9 @@ def test_output_extra_file_is_quarantined_without_partial_publication(tmp_path: (b"MZ\x90\x00unsafe executable", "invalid_artifact"), (b"PK\x03\x04archive", "invalid_artifact"), (b"0" * 257 + b"ustar" + b"tar", "invalid_artifact"), + (b"[InternetShortcut]\nURL=https://example.invalid", "invalid_artifact"), (b'{"value": NaN}', "invalid_artifact"), + (b'{"value": 1e999999}', "invalid_artifact"), (b"plain text", "artifact_mime_mismatch"), ], ) @@ -235,6 +258,38 @@ def test_json_non_finite_numbers_are_not_misclassified_as_text(): assert error.value.code == "invalid_artifact" +@pytest.mark.parametrize( + "content", + [ + b"\xff\xfe<\x00s\x00v\x00g\x00>\x00", + b"\xff\xfe<\x00s\x00c\x00r\x00i\x00p\x00t\x00>\x00", + ], +) +def test_active_utf16_markup_is_rejected_as_active_content(content: bytes): + with pytest.raises(ArtifactBoundaryError) as error: + sniff_artifact_mime(content) + assert error.value.code == "invalid_artifact" + + +def test_oversized_json_integer_does_not_escape_the_mime_boundary(): + assert sniff_artifact_mime(b'{"value": ' + b"9" * 5_000 + b"}") == "text/plain" + + +def test_repository_read_rejects_database_size_tampering(tmp_path: Path): + repository, job_id = _repository(tmp_path) + source = tmp_path / "input.txt" + source.write_text("safe", encoding="utf-8") + artifact = ArtifactBoundary(repository).copy_in(_grant(source, job_id)) + with repository.connect() as connection: + connection.execute( + "UPDATE execution_artifacts SET size = size + 1 WHERE artifact_id = ?", + (artifact.artifact_id,), + ) + + with pytest.raises(ExecutionRepositoryError, match="Artifact"): + repository.read_artifact(artifact.artifact_id) + + def test_repository_expiry_cleanup_fails_closed_for_external_database_paths(tmp_path: Path): repository, job_id = _repository(tmp_path) source = tmp_path / "input.txt" diff --git a/tests/test_phase2_artifact_security_review.py b/tests/test_phase2_artifact_security_review.py new file mode 100644 index 0000000..2d0dc3e --- /dev/null +++ b/tests/test_phase2_artifact_security_review.py @@ -0,0 +1,18 @@ +"""The artifact security review corpus is deterministic and fail-closed.""" + +from __future__ import annotations + +from tools.execution_spikes.artifact_security_review import run_review + + +def test_artifact_security_review_is_reproducible_and_complete(): + first = run_review() + second = run_review() + + assert first == second + assert first["status"] == "passed" + assert first["cases"] == 12 + assert first["passed"] == 12 + assert first["blocked"] == 0 + assert first["failed"] == 0 + assert all(outcome["status"] == "passed" for outcome in first["outcomes"]) diff --git a/tools/execution_spikes/README.md b/tools/execution_spikes/README.md index 2e5c0c8..4575ba7 100644 --- a/tools/execution_spikes/README.md +++ b/tools/execution_spikes/README.md @@ -89,9 +89,9 @@ 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. Resource/watchdog accounting, artifact security review, external review, -and production-lifecycle release gates remain open; parser-fuzz evidence is -qualified below. +transform. Resource/watchdog accounting, external review, and production-lifecycle +release gates remain open; parser-fuzz and artifact-security evidence are qualified +below. ## Run the typed parser fuzz qualification @@ -110,7 +110,25 @@ typed models or stable redacted `RecipeValidationError` categories; unexpected exceptions, unbounded budgets, or canonicalization failures return exit code 2. The current evidence is 158 accepted payloads, 1,842 rejections, and zero unexpected exceptions. This closes parser-fuzz evidence only; resource/watchdog, -artifact-security, external-review, and production-lifecycle gates remain open. +external-review, and production-lifecycle gates remain open. + +## Run the artifact security review qualification + +The artifact review is deterministic and disposable. It uses fixed bytes and a +temporary root to exercise the trusted copy-in/publication boundary; it never +accepts user/model input, opens a provider, or executes a file: + +```powershell +python tools/execution_spikes/artifact_security_review.py --json +python tools/execution_spikes/artifact_security_review.py --json --strict +``` + +The `artifact-boundary-review.v1` corpus has 12 cases covering owner and ADS/path +binding, active UTF-8/UTF-16 and non-finite content, hardlink/symlink rejection, +exact output claims, quarantine, all-or-nothing rollback, and repository stored-size +integrity. The current evidence is 12/12 passed with corpus digest +`a748cc9f0a514c8d`. `--strict` returns exit code `2` for any failure or for an +unavailable required link primitive; a blocked result is not a release pass. ## What the probes prove diff --git a/tools/execution_spikes/artifact_security_review.py b/tools/execution_spikes/artifact_security_review.py new file mode 100644 index 0000000..a18a257 --- /dev/null +++ b/tools/execution_spikes/artifact_security_review.py @@ -0,0 +1,333 @@ +"""Deterministic, disposable artifact-boundary security qualification. + +The review corpus exercises the trusted copy-in and output-publication boundary +with fixed bytes and temporary files only. It accepts no user/model input, never +executes a file, and reports stable case names/categories rather than paths or +operating-system details. A missing Windows link primitive is a blocked release +gate, not a silent pass. +""" + +from __future__ import annotations + +import argparse +from hashlib import sha256 +import json +from pathlib import Path +import sys +from tempfile import TemporaryDirectory +from typing import Callable + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +import cortex_backend.execution.artifact_boundary as boundary_module # noqa: E402 +from cortex_backend.execution import ( # noqa: E402 + ArtifactBoundary, + ArtifactBoundaryError, + ArtifactSourceGrant, + ExecutionRepository, + ExecutionRepositoryError, + OutputClaim, +) + + +_OWNER = "review-owner" +_TURN = "review-turn" +_PNG = b"\x89PNG\r\n\x1a\nreview-bytes" +_CASE_NAMES = ( + "copy_in_preserves_source", + "copy_in_owner_binding", + "copy_in_ads_path", + "copy_in_untyped_grant", + "copy_in_hardlink", + "copy_in_symlink", + "mime_active_and_nonfinite_corpus", + "output_exact_claims_and_quarantine", + "output_symlink_rejection", + "output_hardlink_rejection", + "output_publication_rollback", + "repository_size_integrity", +) + + +class _ReviewFailure(Exception): + """Internal bounded result marker; its text never leaves the probe.""" + + def __init__(self, code: str) -> None: + self.code = code + + +class _BlockedCapability(Exception): + """A required host primitive was unavailable for this release probe.""" + + def __init__(self, code: str) -> None: + self.code = code + + +def _fixture(root: Path, case_name: str) -> tuple[Path, ExecutionRepository, ArtifactBoundary, str]: + case_root = root / case_name + case_root.mkdir() + repository = ExecutionRepository( + case_root / "execution.sqlite", + case_root / "artifacts", + max_artifact_bytes=4_096, + ) + job_id = f"job-{case_name}" + repository.create_job( + job_id=job_id, + owner=_OWNER, + request_id=f"request-{case_name}", + profile="artifact.transform.v1", + payload={}, + ) + return case_root, repository, ArtifactBoundary(repository), job_id + + +def _grant(path: Path, job_id: str, *, owner: str = _OWNER) -> ArtifactSourceGrant: + return ArtifactSourceGrant( + owner=owner, + job_id=job_id, + source_turn_id=_TURN, + source_path=path, + ) + + +def _expect_boundary_error(expected: str, action: Callable[[], object]) -> None: + try: + action() + except ArtifactBoundaryError as error: + if error.code != expected: + raise _ReviewFailure(f"wrong_category:{expected}:{error.code}") from None + else: + raise _ReviewFailure(f"accepted:{expected}") + + +def _case_copy_in_preserves_source(root: Path, repository: ExecutionRepository, boundary: ArtifactBoundary, job_id: str) -> None: + source = root / "source.bin" + source.write_bytes(_PNG) + artifact = boundary.copy_in(_grant(source, job_id)) + if artifact.mime_type != "image/png" or source.read_bytes() != _PNG: + raise _ReviewFailure("source_or_mime_changed") + if repository.read_artifact(artifact.artifact_id) != _PNG: + raise _ReviewFailure("published_bytes_changed") + + +def _case_copy_in_owner_binding(root: Path, _repository: ExecutionRepository, boundary: ArtifactBoundary, job_id: str) -> None: + source = root / "source.txt" + source.write_text("owner-bound", encoding="utf-8") + _expect_boundary_error( + "artifact_owner_mismatch", + lambda: boundary.copy_in(_grant(source, job_id, owner="other-owner")), + ) + + +def _case_copy_in_ads_path(root: Path, _repository: ExecutionRepository, boundary: ArtifactBoundary, job_id: str) -> None: + source = root / "source.txt" + source.write_text("ads", encoding="utf-8") + _expect_boundary_error( + "artifact_path_invalid", + lambda: boundary.copy_in(_grant(Path(f"{source}:secret"), job_id)), + ) + + +def _case_copy_in_untyped_grant(_root: Path, _repository: ExecutionRepository, boundary: ArtifactBoundary, _job_id: str) -> None: + _expect_boundary_error("artifact_grant_invalid", lambda: boundary.copy_in(object())) + + +def _case_copy_in_hardlink(root: Path, _repository: ExecutionRepository, boundary: ArtifactBoundary, job_id: str) -> None: + source = root / "external.bin" + source.write_bytes(b"hardlink") + link = root / "hardlink.bin" + try: + link.hardlink_to(source) + except (OSError, NotImplementedError): + raise _BlockedCapability("hardlink_unavailable") from None + _expect_boundary_error("artifact_hardlink_rejected", lambda: boundary.copy_in(_grant(link, job_id))) + + +def _case_copy_in_symlink(root: Path, _repository: ExecutionRepository, boundary: ArtifactBoundary, job_id: str) -> None: + source = root / "external.bin" + source.write_bytes(b"symlink") + link = root / "symlink.bin" + try: + link.symlink_to(source) + except (OSError, NotImplementedError): + raise _BlockedCapability("symlink_unavailable") from None + _expect_boundary_error("artifact_reparse_point", lambda: boundary.copy_in(_grant(link, job_id))) + + +def _case_mime_active_and_nonfinite_corpus(_root: Path, _repository: ExecutionRepository, _boundary: ArtifactBoundary, _job_id: str) -> None: + rejected = ( + b"MZ\x90\x00executable", + b"PK\x03\x04archive", + b"", + b"\xff\xfe<\x00s\x00v\x00g\x00>\x00", + b"[InternetShortcut]\nURL=https://example.invalid", + b'{"value": NaN}', + b'{"value": 1e999999}', + ) + for content in rejected: + _expect_boundary_error("invalid_artifact", lambda content=content: boundary_module.sniff_artifact_mime(content)) + if boundary_module.sniff_artifact_mime(b'{"value": ' + b"9" * 5_000 + b"}") != "text/plain": + raise _ReviewFailure("oversized_json_not_bounded") + + +def _case_output_exact_claims_and_quarantine(root: Path, repository: ExecutionRepository, boundary: ArtifactBoundary, job_id: str) -> None: + output = root / "output" + output.mkdir() + (output / "declared.txt").write_text("declared", encoding="utf-8") + (output / "unclaimed.txt").write_text("unclaimed", encoding="utf-8") + _expect_boundary_error( + "artifact_unclaimed_output", + lambda: boundary.collect_outputs(job_id, _OWNER, output, [OutputClaim("declared.txt", "text/plain")]), + ) + with repository.connect() as connection: + count = connection.execute("SELECT COUNT(*) FROM execution_artifacts").fetchone()[0] + if count != 0 or not list((output / ".quarantine").iterdir()): + raise _ReviewFailure("quarantine_or_publication_invariant_failed") + + +def _case_output_symlink_rejection(root: Path, _repository: ExecutionRepository, boundary: ArtifactBoundary, job_id: str) -> None: + output = root / "output" + output.mkdir() + external = root / "external.txt" + external.write_text("outside", encoding="utf-8") + link = output / "link.txt" + try: + link.symlink_to(external) + except (OSError, NotImplementedError): + raise _BlockedCapability("symlink_unavailable") from None + _expect_boundary_error( + "artifact_reparse_point", + lambda: boundary.collect_outputs(job_id, _OWNER, output, [OutputClaim("link.txt")]), + ) + + +def _case_output_hardlink_rejection(root: Path, _repository: ExecutionRepository, boundary: ArtifactBoundary, job_id: str) -> None: + output = root / "output" + output.mkdir() + external = root / "external.txt" + external.write_text("outside", encoding="utf-8") + link = output / "link.txt" + try: + link.hardlink_to(external) + except (OSError, NotImplementedError): + raise _BlockedCapability("hardlink_unavailable") from None + _expect_boundary_error( + "artifact_hardlink_rejected", + lambda: boundary.collect_outputs(job_id, _OWNER, output, [OutputClaim("link.txt")]), + ) + + +def _case_output_publication_rollback(root: Path, repository: ExecutionRepository, boundary: ArtifactBoundary, job_id: str) -> None: + output = root / "output" + output.mkdir() + (output / "one.txt").write_text("one", encoding="utf-8") + (output / "two.txt").write_text("two", encoding="utf-8") + original = repository.publish_artifact + calls = 0 + + def fail_second(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 2: + raise ExecutionRepositoryError("injected publication failure") + return original(*args, **kwargs) + + repository.publish_artifact = fail_second # type: ignore[method-assign] + try: + _expect_boundary_error( + "artifact_publish_failed", + lambda: boundary.collect_outputs( + job_id, + _OWNER, + output, + [OutputClaim("one.txt", "text/plain"), OutputClaim("two.txt", "text/plain")], + ), + ) + finally: + repository.publish_artifact = original # type: ignore[method-assign] + with repository.connect() as connection: + count = connection.execute("SELECT COUNT(*) FROM execution_artifacts").fetchone()[0] + if count != 0 or not list((output / ".quarantine").iterdir()): + raise _ReviewFailure("rollback_or_quarantine_invariant_failed") + + +def _case_repository_size_integrity(root: Path, repository: ExecutionRepository, boundary: ArtifactBoundary, job_id: str) -> None: + source = root / "source.txt" + source.write_text("integrity", encoding="utf-8") + artifact = boundary.copy_in(_grant(source, job_id)) + with repository.connect() as connection: + connection.execute( + "UPDATE execution_artifacts SET size = size + 1 WHERE artifact_id = ?", + (artifact.artifact_id,), + ) + try: + repository.read_artifact(artifact.artifact_id) + except ExecutionRepositoryError: + return + raise _ReviewFailure("database_size_tamper_accepted") + + +_CASES: tuple[tuple[str, Callable[[Path, ExecutionRepository, ArtifactBoundary, str], None]], ...] = ( + ("copy_in_preserves_source", _case_copy_in_preserves_source), + ("copy_in_owner_binding", _case_copy_in_owner_binding), + ("copy_in_ads_path", _case_copy_in_ads_path), + ("copy_in_untyped_grant", _case_copy_in_untyped_grant), + ("copy_in_hardlink", _case_copy_in_hardlink), + ("copy_in_symlink", _case_copy_in_symlink), + ("mime_active_and_nonfinite_corpus", _case_mime_active_and_nonfinite_corpus), + ("output_exact_claims_and_quarantine", _case_output_exact_claims_and_quarantine), + ("output_symlink_rejection", _case_output_symlink_rejection), + ("output_hardlink_rejection", _case_output_hardlink_rejection), + ("output_publication_rollback", _case_output_publication_rollback), + ("repository_size_integrity", _case_repository_size_integrity), +) + + +def run_review() -> dict[str, object]: + """Run the fixed corpus in a disposable directory and return safe evidence.""" + + outcomes: list[dict[str, str]] = [] + with TemporaryDirectory(prefix="cortex-artifact-review-") as temporary: + root = Path(temporary) + for name, case in _CASES: + try: + case_root, repository, boundary, job_id = _fixture(root, name) + case(case_root, repository, boundary, job_id) + except _BlockedCapability as error: + outcomes.append({"case": name, "status": "blocked", "reason": error.code}) + except _ReviewFailure as error: + outcomes.append({"case": name, "status": "failed", "reason": error.code}) + except Exception as error: # pragma: no cover - qualification failure path. + outcomes.append({"case": name, "status": "failed", "reason": f"unexpected_exception:{type(error).__name__}"}) + else: + outcomes.append({"case": name, "status": "passed"}) + passed = sum(outcome["status"] == "passed" for outcome in outcomes) + blocked = sum(outcome["status"] == "blocked" for outcome in outcomes) + failed = sum(outcome["status"] == "failed" for outcome in outcomes) + corpus_digest = sha256(json.dumps(_CASE_NAMES, separators=(",", ":")).encode("ascii")).hexdigest()[:16] + return { + "status": "passed" if failed == 0 and blocked == 0 else "blocked", + "corpus": "artifact-boundary-review.v1", + "corpus_digest": corpus_digest, + "cases": len(outcomes), + "passed": passed, + "blocked": blocked, + "failed": failed, + "outcomes": outcomes, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true") + parser.add_argument("--strict", action="store_true") + args = parser.parse_args(argv) + result = run_review() + print(json.dumps(result, sort_keys=True, separators=(",", ":") if args.json else None)) + return 2 if args.strict and result["status"] != "passed" else 0 + + +if __name__ == "__main__": + raise SystemExit(main())