diff --git a/proof/region/v1/PROTOCOL.md b/proof/region/v1/PROTOCOL.md index 97c675db..2285d182 100644 --- a/proof/region/v1/PROTOCOL.md +++ b/proof/region/v1/PROTOCOL.md @@ -187,6 +187,13 @@ Git executable/version, repository URL и tag являются диагност ## Diagnostic execution boundary +`proof/region/v1/executor.py` — общий для enclosure engines leaf без импорта +Arb/MPFI, formula или comparator semantics. Он же единолично кодирует +`execution-invocation.v1` и `execution-platform.v1`; engine pipeline не может +вводить параллельную identity того же процесса. Sandbox release +`labcolors.proof-region.executor.linux-x86_64.v1` намеренно не сохраняет +старую Arb-domain identity: это hard cut, а не compatibility alias. + `ControlledExecutorV1` — единственный владелец one-shot capability: новый, неуспешный, перекрывающийся probe или замена backend отзывают ранее выданный объект до RUN. Capability выпускается контроллером для одного probe-поколения @@ -194,6 +201,14 @@ Git executable/version, repository URL и tag являются диагност наблюдённые свойства хоста, получает guard текущего probe и не может продлить жизнь capability повторно используемым report-объектом. +`ExecutionRequestV1`, его limits и `SupportedV1` являются структурно +неизменяемыми значениями. Публичные execution identity functions воспроизводят +admission из точных координат и возвращают +`bytes | ExecutionIdentityRejectedV1`: foreign или даже намеренно forged +malformed value становится versioned typed rejection, а не новой identity и не +exception-channel. `ControlledExecutorV1` отвергает такой request до probe и +backend run как `ObserverFailureV1(REQUEST_NOT_ADMITTED)`. + Linux backend допускается лишь в отдельном helper process. Helper находится в прямом дочернем cgroup объявленного parent, а весь parent subtree имеет `pids.max = 2` и перед probe содержит ровно observer. Эти два task slots имеют diff --git a/proof/region/v1/arb/pipeline.py b/proof/region/v1/arb/pipeline.py index 7358d3eb..bb09eae5 100644 --- a/proof/region/v1/arb/pipeline.py +++ b/proof/region/v1/arb/pipeline.py @@ -132,8 +132,6 @@ _BUILD_INPUT_BUNDLE_ID_LABEL_V1 = ( b"labcolors.proof-region.arb-build-input-bundle.v1\0" ) -_INVOCATION_ID_LABEL_V1 = b"labcolors.proof-region.arb-invocation.v1\0" -_PLATFORM_ID_LABEL_V1 = b"labcolors.proof-region.arb-run-platform.v1\0" _BUILD_SOURCES_TOKEN = object() _COMPARATOR_TOKEN = object() _BUILD_OBSERVATION_TOKEN = object() @@ -2515,44 +2513,6 @@ def input_bundle(self) -> SealedBuildInputBundleV1: ) -def invocation_identity_v1(request: executor.ExecutionRequestV1) -> bytes: - if type(request) is not executor.ExecutionRequestV1: - raise TypeError("request must be ExecutionRequestV1") - chunks: list[bytes] = [hashlib.sha256(request.executable).digest()] - chunks.append(len(request.argv).to_bytes(4, "big")) - chunks.extend(request.argv) - chunks.append(len(request.environment).to_bytes(4, "big")) - for key, value in request.environment: - chunks.extend((key, value)) - chunks.extend( - ( - request.cwd, - hashlib.sha256(request.stdin).digest(), - len(request.stdin).to_bytes(8, "big"), - request.umask.to_bytes(4, "big"), - ) - ) - for item in fields(request.limits): - chunks.append(getattr(request.limits, item.name).to_bytes(8, "big")) - return _identity(_INVOCATION_ID_LABEL_V1, tuple(chunks)) - - -def platform_identity_v1(report: executor.SupportedV1) -> bytes: - if ( - type(report) is not executor.SupportedV1 - or report.platform != executor.EXECUTION_PLATFORM_V1 - or report.sandbox_policy_release != executor.SANDBOX_POLICY_RELEASE_V1 - ): - raise TypeError("report must be the exact V1 supported platform") - return _identity( - _PLATFORM_ID_LABEL_V1, - ( - report.platform.encode("ascii"), - report.sandbox_policy_release.encode("ascii"), - ), - ) - - class ControlledPipelineV1: def __init__( self, diff --git a/proof/region/v1/arb/receipt.py b/proof/region/v1/arb/receipt.py index 07a9586a..d9ac938e 100644 --- a/proof/region/v1/arb/receipt.py +++ b/proof/region/v1/arb/receipt.py @@ -60,6 +60,7 @@ def source_bound_policy_identity_v1() -> bytes: b"source=lock-plus-owned-archive-and-build-input-replay", b"build=one-sealed-bundle-two-fresh-byte-equal-attempts", b"run=retained-executable-object-one-contained-process", + b"identity=immutable-coordinates-total-rejection-v1", b"claim=provenance-only-no-numerical-semantics", b"trust=unsealed-linux-x64-host-and-docker-daemon", ), @@ -288,8 +289,13 @@ def _run_identity_v1( transcript.counters, transcript.witness_store, ) - invocation_identity = pipeline.invocation_identity_v1(invocation) - platform_identity = pipeline.platform_identity_v1(platform_value) + invocation_identity = executor.invocation_identity_v1(invocation) + platform_identity = executor.platform_identity_v1(platform_value) + if ( + type(invocation_identity) is not bytes + or type(platform_identity) is not bytes + ): + raise TypeError("execution identity replay was rejected") expected_claim = protocol.RunClaimV1.for_transcript( request.job, build.comparator.manifest, @@ -526,7 +532,7 @@ def __post_init__(self) -> None: def _limits_copy_v1(value: executor.ExecutionLimitsV1) -> executor.ExecutionLimitsV1: - return executor.ExecutionLimitsV1(*(getattr(value, item.name) for item in fields(value))) + return executor.ExecutionLimitsV1(*value) def _resolve_request_v1( @@ -716,8 +722,26 @@ def execute(self, request: pipeline.PipelineRequestV1) -> SourceBoundResultV1: str(error), ) try: - invocation_identity = pipeline.invocation_identity_v1(invocation) - platform_identity = pipeline.platform_identity_v1(capability) + invocation_identity = executor.invocation_identity_v1(invocation) + if type(invocation_identity) is executor.ExecutionIdentityRejectedV1: + return pipeline.ExecutionRejectedV1( + pipeline.ExecutionFailureReasonV1.BACKEND_CONTRACT, + invocation_identity, + ) + platform_identity = executor.platform_identity_v1(capability) + if type(platform_identity) is executor.ExecutionIdentityRejectedV1: + return pipeline.ExecutionRejectedV1( + pipeline.ExecutionFailureReasonV1.BACKEND_CONTRACT, + platform_identity, + ) + if ( + type(invocation_identity) is not bytes + or type(platform_identity) is not bytes + ): + return pipeline.ExecutionRejectedV1( + pipeline.ExecutionFailureReasonV1.BACKEND_CONTRACT, + (invocation_identity, platform_identity), + ) run_claim = protocol.RunClaimV1.for_transcript( replay_request.job, built.comparator.manifest, diff --git a/proof/region/v1/arb/tests/gate.py b/proof/region/v1/arb/tests/gate.py index b8bed8da..5c8c2fa5 100644 --- a/proof/region/v1/arb/tests/gate.py +++ b/proof/region/v1/arb/tests/gate.py @@ -11,10 +11,11 @@ TEST_DIRECTORY = Path(__file__).resolve().parent +SHARED_TEST_DIRECTORY = TEST_DIRECTORY.parents[1] / "tests" REPO = Path(__file__).resolve().parents[5] sys.path.insert(0, str(REPO)) EXPECTED_TEST_INVENTORY_SHA256 = ( - "6e73ade1e7d5b21d50fe9826a1b39e4043e63bcd090b504dbee5e1c38515e373" + "9df49e5bc78ab7cf2386570f500c80f3759f21385f597f2720aa27b1e9700a76" ) _EVALUATOR_REASON = "set LABCOLORS_ARB_EVALUATOR to the controlled C17 binary" EXPECTED_SKIPS = frozenset( @@ -69,6 +70,23 @@ def test_inventory_sha256_v1(suite: unittest.TestSuite) -> str: return hashlib.sha256(_inventory_preimage_v1(test_ids)).hexdigest() +def full_suite_v1() -> unittest.TestSuite: + """Compose the shared execution contract and Arb-only contract once each.""" + + return unittest.TestSuite( + ( + unittest.defaultTestLoader.discover( + str(SHARED_TEST_DIRECTORY), + pattern="test_executor.py", + ), + unittest.defaultTestLoader.discover( + str(TEST_DIRECTORY), + pattern="test_*.py", + ), + ) + ) + + def run_exact_suite_v1( suite: unittest.TestSuite, *, @@ -121,10 +139,7 @@ def run_exact_suite_v1( def main() -> int: - suite = unittest.defaultTestLoader.discover( - str(TEST_DIRECTORY), - pattern="test_*.py", - ) + suite = full_suite_v1() return run_exact_suite_v1( suite, expected_inventory_sha256=EXPECTED_TEST_INVENTORY_SHA256, diff --git a/proof/region/v1/arb/tests/native_gate.py b/proof/region/v1/arb/tests/native_gate.py index 72363247..90855e82 100644 --- a/proof/region/v1/arb/tests/native_gate.py +++ b/proof/region/v1/arb/tests/native_gate.py @@ -12,7 +12,7 @@ sys.path.insert(0, str(REPO)) from proof.region.v1.arb.tests import gate # noqa: E402 -from proof.region.v1.arb.tests.test_executor import ( # noqa: E402 +from proof.region.v1.tests.test_executor import ( # noqa: E402 NativeLinuxIntegrationTests, ) from proof.region.v1.arb.tests.test_receipt import ( # noqa: E402 diff --git a/proof/region/v1/arb/tests/test_build_recipe.py b/proof/region/v1/arb/tests/test_build_recipe.py index 747e59d7..98ea1e03 100644 --- a/proof/region/v1/arb/tests/test_build_recipe.py +++ b/proof/region/v1/arb/tests/test_build_recipe.py @@ -19,6 +19,26 @@ class ArbBuildRecipeTests(unittest.TestCase): + def test_fast_gate_includes_the_shared_executor_suite_exactly_once(self) -> None: + tests = tuple(arb_gate._iter_tests_v1(arb_gate.full_suite_v1())) + identifiers = tuple(test.id() for test in tests) + executor_identifiers = tuple( + identifier for identifier in identifiers if identifier.startswith("test_executor.") + ) + expected = tuple( + test.id() + for test in arb_gate._iter_tests_v1( + unittest.defaultTestLoader.discover( + str(arb_gate.SHARED_TEST_DIRECTORY), + pattern="test_executor.py", + ) + ) + ) + + self.assertTrue(executor_identifiers) + self.assertEqual(executor_identifiers, expected) + self.assertEqual(len(identifiers), len(set(identifiers))) + def test_pr_gate_requires_a_disposable_exact_workflow_runner(self) -> None: source = WORKFLOW.read_text(encoding="utf-8") runner_contracts = [ diff --git a/proof/region/v1/arb/tests/test_pipeline.py b/proof/region/v1/arb/tests/test_pipeline.py index 0c2b09eb..95934f3c 100644 --- a/proof/region/v1/arb/tests/test_pipeline.py +++ b/proof/region/v1/arb/tests/test_pipeline.py @@ -221,17 +221,19 @@ def _foreign_comparator() -> ContentResolvedComparatorManifestV2: return ContentResolvedComparatorManifestV2.admit(manifest, by_digest.get) -def _limits() -> executor.ExecutionLimitsV1: - return executor.ExecutionLimitsV1( - max_executable_bytes=16 * 1024 * 1024, - max_stdin_bytes=16 * 1024 * 1024, - max_argument_bytes=4096, - max_stdout_bytes=16 * 1024 * 1024, - max_stderr_bytes=64 * 1024, - wall_timeout_ns=60_000_000_000, - memory_max_bytes=1024 * 1024 * 1024, - pids_max=1, - ) +def _limits(**changes: int) -> executor.ExecutionLimitsV1: + values = { + "max_executable_bytes": 16 * 1024 * 1024, + "max_stdin_bytes": 16 * 1024 * 1024, + "max_argument_bytes": 4096, + "max_stdout_bytes": 16 * 1024 * 1024, + "max_stderr_bytes": 64 * 1024, + "wall_timeout_ns": 60_000_000_000, + "memory_max_bytes": 1024 * 1024 * 1024, + "pids_max": 1, + } + values.update(changes) + return executor.ExecutionLimitsV1(**values) def _request(**changes: object) -> pipeline.PipelineRequestV1: @@ -581,6 +583,10 @@ def test_admission_uses_only_explicit_cross_module_verification_api(self) -> Non ): with self.subTest(forbidden=forbidden): self.assertNotIn(forbidden, source) + self.assertTrue(callable(executor.invocation_identity_v1)) + self.assertTrue(callable(executor.platform_identity_v1)) + self.assertFalse(hasattr(pipeline, "invocation_identity_v1")) + self.assertFalse(hasattr(pipeline, "platform_identity_v1")) def test_host_trust_claims_only_backend_observable_facts(self) -> None: trust = pipeline.HostTrustBoundaryV1.UNSEALED_LINUX_X64_DOCKER_HOST @@ -754,10 +760,7 @@ def test_input_transport_or_invalid_binary_is_typed_failure(self) -> None: def test_job_that_exceeds_exact_run_limits_is_rejected_before_build(self) -> None: with self.assertRaises(pipeline.PipelineInputErrorV1) as caught: _request( - execution_limits=replace( - _limits(), - max_stdin_bytes=1, - ) + execution_limits=_limits(max_stdin_bytes=1) ) self.assertEqual( @@ -768,8 +771,7 @@ def test_job_that_exceeds_exact_run_limits_is_rejected_before_build(self) -> Non def test_build_output_limit_is_rejected_at_pipeline_admission(self) -> None: with self.assertRaises(pipeline.PipelineInputErrorV1) as caught: _request( - execution_limits=replace( - _limits(), + execution_limits=_limits( max_executable_bytes=pipeline.BUILD_STDOUT_LIMIT_V1 + 1, ) ) diff --git a/proof/region/v1/arb/tests/test_receipt.py b/proof/region/v1/arb/tests/test_receipt.py index 68ad86a7..80beade1 100644 --- a/proof/region/v1/arb/tests/test_receipt.py +++ b/proof/region/v1/arb/tests/test_receipt.py @@ -102,16 +102,18 @@ def probe(self, guard: object) -> executor.CapabilityReportV1: def run( self, request: executor.ExecutionRequestV1, - _capability: executor.SupportedV1, + capability: executor.SupportedV1, ) -> executor.ExecutionResultV1: self.requests.append(request) if self.result is not None: - return self.result - return executor.CompletedV1( - hashlib.sha256(request.executable).digest(), - _transcript_for_request(request, unresolved=self.unresolved), - b"", - ) + result = self.result + else: + result = executor.CompletedV1( + hashlib.sha256(request.executable).digest(), + _transcript_for_request(request, unresolved=self.unresolved), + b"", + ) + return result def _controller( @@ -160,7 +162,10 @@ def _execute( unresolved: bool = False, process_result: executor.ExecutionResultV1 | None = None, ) -> tuple[receipt.SourceBoundResultV1, _NativeRunBackend]: - backend = _NativeRunBackend(unresolved=unresolved, result=process_result) + backend = _NativeRunBackend( + unresolved=unresolved, + result=process_result, + ) controller, patches = _controller(_static_elf(b"source-bound-receipt"), backend) with patches[0], patches[1], patches[2], patches[3], patches[4]: return controller.execute(_request()), backend @@ -174,7 +179,93 @@ def _tamper(value: object, field: str, replacement: object) -> object: return clone +def _replace_limits( + value: executor.ExecutionLimitsV1, + **changes: int, +) -> executor.ExecutionLimitsV1: + values = { + name: getattr(value, name) + for name in ( + "max_executable_bytes", + "max_stdin_bytes", + "max_argument_bytes", + "max_stdout_bytes", + "max_stderr_bytes", + "wall_timeout_ns", + "memory_max_bytes", + "pids_max", + ) + } + values.update(changes) + return executor.ExecutionLimitsV1(**values) + + +def _replace_invocation( + value: executor.ExecutionRequestV1, + **changes: object, +) -> executor.ExecutionRequestV1: + values: dict[str, object] = { + "executable": value.executable, + "argv": value.argv, + "environment": value.environment, + "cwd": value.cwd, + "stdin": value.stdin, + "umask": value.umask, + "limits": value.limits, + } + values.update(changes) + return executor.ExecutionRequestV1(**values) + + class SourceBoundReceiptTests(unittest.TestCase): + def test_source_bound_policy_identity_binds_immutable_coordinates(self) -> None: + self.assertEqual( + receipt.source_bound_policy_identity_v1().hex(), + "a7cf0c142397a1e8ce3f9bb9dd4168120cdf78814745d1d4596d08a8e88a6b1b", + ) + + def test_identity_rejection_remains_typed_at_the_receipt_boundary(self) -> None: + invocation_rejection = executor.ExecutionIdentityRejectedV1( + executor.ExecutionIdentityReasonV1.REQUEST_NOT_ADMITTED, + ) + admitted_invocation_identity = hashlib.sha256(b"admitted invocation").digest() + with mock.patch.object( + receipt.executor, + "invocation_identity_v1", + side_effect=(admitted_invocation_identity, invocation_rejection), + ): + result, _backend = _execute() + self.assertEqual( + result, + pipeline.ExecutionRejectedV1( + pipeline.ExecutionFailureReasonV1.BACKEND_CONTRACT, + invocation_rejection, + ), + ) + + platform_rejection = executor.ExecutionIdentityRejectedV1( + executor.ExecutionIdentityReasonV1.FOREIGN_PLATFORM, + ) + admitted_platform_identity = executor.platform_identity_v1( + executor.SupportedV1( + executor.EXECUTION_PLATFORM_V1, + executor.SANDBOX_POLICY_RELEASE_V1, + ) + ) + with mock.patch.object( + receipt.executor, + "platform_identity_v1", + side_effect=(admitted_platform_identity, platform_rejection), + ): + result, _backend = _execute() + self.assertEqual( + result, + pipeline.ExecutionRejectedV1( + pipeline.ExecutionFailureReasonV1.BACKEND_CONTRACT, + platform_rejection, + ), + ) + def test_only_controller_execution_can_seal_a_receipt(self) -> None: result, backend = _execute() @@ -217,13 +308,16 @@ def test_no_public_object_or_diagnostic_can_mint(self) -> None: self.assertFalse(hasattr(pipeline, "DiagnosticPipelineObservationV1")) self.assertFalse(hasattr(receipt.SourceBoundEvaluatorReceiptV1, "parse")) - def test_receipt_uses_only_versioned_public_pipeline_verifiers(self) -> None: + def test_receipt_uses_only_versioned_public_cross_module_verifiers(self) -> None: source = (ARB / "receipt.py").read_text(encoding="utf-8") self.assertNotIn("pipeline._sealed_build_input_bundle_is_well_bound_v1", source) self.assertNotIn("pipeline._build_process_bytes_v1", source) + self.assertNotIn("executor._execution_identity_v1", source) self.assertTrue(hasattr(pipeline, "sealed_build_input_bundle_is_well_bound_v1")) self.assertTrue(hasattr(pipeline, "build_process_bytes_v1")) + self.assertTrue(hasattr(executor, "invocation_identity_v1")) + self.assertTrue(hasattr(executor, "platform_identity_v1")) def test_reference_does_not_describe_shipped_arb_receipt_as_future(self) -> None: documentation = (PROOF / "PROTOCOL.md").read_text(encoding="utf-8") @@ -480,15 +574,18 @@ def test_invocation_process_and_same_object_mutations_fail(self) -> None: self.assertEqual(equal_executable_copy, dag.invocation.executable) self.assertIsNot(equal_executable_copy, dag.invocation.executable) mutants = ( - replace(dag.invocation, executable=equal_executable_copy), - replace(dag.invocation, argv=dag.invocation.argv + (b"ambient",)), - replace( + _replace_invocation(dag.invocation, executable=equal_executable_copy), + _replace_invocation( + dag.invocation, + argv=dag.invocation.argv + (b"ambient",), + ), + _replace_invocation( dag.invocation, environment=((b"LC_ALL", b"POSIX"), (b"TZ", b"UTC")), ), - replace(dag.invocation, cwd=b"/tmp"), - replace(dag.invocation, stdin=dag.invocation.stdin + b"x"), - replace(dag.invocation, umask=0o022), + _replace_invocation(dag.invocation, cwd=b"/tmp"), + _replace_invocation(dag.invocation, stdin=dag.invocation.stdin + b"x"), + _replace_invocation(dag.invocation, umask=0o022), ) for invocation in mutants: self.assertFalse( @@ -501,8 +598,8 @@ def test_invocation_process_and_same_object_mutations_fail(self) -> None: dag.build.comparator.manifest, dag.transcript, dag.build.binary_sha256, - pipeline.invocation_identity_v1(invocation), - pipeline.platform_identity_v1(dag.platform), + executor.invocation_identity_v1(invocation), + executor.platform_identity_v1(dag.platform), ) with self.assertRaises(TypeError): receipt.ContentResolvedEvaluatorReplayV1( @@ -515,7 +612,7 @@ def test_invocation_process_and_same_object_mutations_fail(self) -> None: forged_claim, _token=receipt._EVIDENCE_TOKEN, ) - mutated_limits = replace( + mutated_limits = _replace_limits( dag.request.execution_limits, wall_timeout_ns=dag.request.execution_limits.wall_timeout_ns - 1, ) @@ -544,7 +641,13 @@ def test_invocation_process_and_same_object_mutations_fail(self) -> None: _tamper( dag, "platform", - _tamper(dag.platform, "platform", "foreign-linux-x86_64"), + tuple.__new__( + executor.SupportedV1, + ( + "foreign-linux-x86_64", + executor.SANDBOX_POLICY_RELEASE_V1, + ), + ), ) ) ) diff --git a/proof/region/v1/arb/executor.py b/proof/region/v1/executor.py similarity index 88% rename from proof/region/v1/arb/executor.py rename to proof/region/v1/executor.py index 8a649aa2..c063c7bd 100644 --- a/proof/region/v1/arb/executor.py +++ b/proof/region/v1/executor.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Fail-closed Linux process boundary for the Arb evaluator. +"""Fail-closed Linux process boundary for proof evaluators. This module returns process observations only. A caller must bind those observations to source/build evidence elsewhere; no value here can certify that @@ -30,7 +30,10 @@ EXECUTION_PLATFORM_V1 = "linux-x86_64" -SANDBOX_POLICY_RELEASE_V1 = "labcolors.arb.executor.linux-x86_64.v1" +SANDBOX_POLICY_RELEASE_V1 = "labcolors.proof-region.executor.linux-x86_64.v1" + +_INVOCATION_ID_LABEL_V1 = b"labcolors.proof-region.execution-invocation.v1\0" +_PLATFORM_ID_LABEL_V1 = b"labcolors.proof-region.execution-platform.v1\0" # Linux UAPI values are fixed by fcntl.h. Requiring F_SEAL_EXEC makes an older # kernel an explicit Unsupported host instead of silently weakening the object. @@ -81,6 +84,17 @@ _ELF_HEADER = struct.Struct("<16sHHIQQQIHHHHHH") _ELF_PROGRAM_HEADER = struct.Struct(" bytes: + payload = b"".join(len(chunk).to_bytes(8, "big") + chunk for chunk in chunks) + return hashlib.sha256(label + len(payload).to_bytes(8, "big") + payload).digest() + + +def _sequence_count_v1(value: tuple[object, ...]) -> int: + return len(value) class RequestReasonV1(str, Enum): @@ -105,6 +119,24 @@ def __init__(self, reason: RequestReasonV1, field: str) -> None: self.field = field +class ExecutionIdentityReasonV1(str, Enum): + WRONG_REQUEST_TYPE = "wrong_request_type" + REQUEST_NOT_ADMITTED = "request_not_admitted" + FOREIGN_PLATFORM = "foreign_platform" + + +@dataclass(frozen=True) +class ExecutionIdentityRejectedV1: + reason: ExecutionIdentityReasonV1 + + def __post_init__(self) -> None: + if type(self.reason) is not ExecutionIdentityReasonV1: + raise TypeError("reason must be ExecutionIdentityReasonV1") + + +ExecutionIdentityResultV1: TypeAlias = bytes | ExecutionIdentityRejectedV1 + + class CapabilityReasonV1(str, Enum): HOST_NOT_LINUX = "host_not_linux" ARCHITECTURE_NOT_SUPPORTED = "architecture_not_supported" @@ -149,16 +181,32 @@ def __post_init__(self) -> None: raise TypeError("failures must be a nonempty unique tuple") -@dataclass(frozen=True) -class SupportedV1: - platform: str - sandbox_policy_release: str +class SupportedV1(tuple): + """Exact immutable coordinates of one supported execution platform.""" - def __post_init__(self) -> None: - if self.platform != EXECUTION_PLATFORM_V1: + __slots__ = () + + def __new__( + cls, + platform: str, + sandbox_policy_release: str, + ) -> SupportedV1: + if type(platform) is not str or platform != EXECUTION_PLATFORM_V1: raise TypeError("unknown execution platform") - if self.sandbox_policy_release != SANDBOX_POLICY_RELEASE_V1: + if ( + type(sandbox_policy_release) is not str + or sandbox_policy_release != SANDBOX_POLICY_RELEASE_V1 + ): raise TypeError("unknown sandbox policy release") + return tuple.__new__(cls, (platform, sandbox_policy_release)) + + @property + def platform(self) -> str: + return self[0] + + @property + def sandbox_policy_release(self) -> str: + return self[1] CapabilityReportV1: TypeAlias = SupportedV1 | UnsupportedV1 @@ -175,72 +223,125 @@ def _invalidated_capability_report_v1() -> UnsupportedV1: ) -@dataclass(frozen=True) -class ExecutionLimitsV1: - max_executable_bytes: int - max_stdin_bytes: int - max_argument_bytes: int - max_stdout_bytes: int - max_stderr_bytes: int - wall_timeout_ns: int - memory_max_bytes: int - pids_max: int +def _kernel_api_unavailable_report_v1() -> UnsupportedV1: + return UnsupportedV1( + ( + CapabilityFailureV1( + CapabilityReasonV1.KERNEL_API_UNAVAILABLE, + None, + ), + ) + ) - def __post_init__(self) -> None: - positive = ( - "max_executable_bytes", - "max_stdin_bytes", - "max_argument_bytes", - "wall_timeout_ns", - "memory_max_bytes", - "pids_max", + +_EXECUTION_LIMIT_FIELDS_V1 = ( + "max_executable_bytes", + "max_stdin_bytes", + "max_argument_bytes", + "max_stdout_bytes", + "max_stderr_bytes", + "wall_timeout_ns", + "memory_max_bytes", + "pids_max", +) + + +class ExecutionLimitsV1(tuple): + """Immutable resource coordinates admitted by the execution wire.""" + + __slots__ = () + + def __new__( + cls, + max_executable_bytes: int, + max_stdin_bytes: int, + max_argument_bytes: int, + max_stdout_bytes: int, + max_stderr_bytes: int, + wall_timeout_ns: int, + memory_max_bytes: int, + pids_max: int, + ) -> ExecutionLimitsV1: + values = ( + max_executable_bytes, + max_stdin_bytes, + max_argument_bytes, + max_stdout_bytes, + max_stderr_bytes, + wall_timeout_ns, + memory_max_bytes, + pids_max, ) - nonnegative = ("max_stdout_bytes", "max_stderr_bytes") - for field_name in positive: - value = getattr(self, field_name) - if type(value) is not int or value <= 0: - raise ExecutionRequestErrorV1(RequestReasonV1.INVALID_LIMIT, field_name) - for field_name in nonnegative: - value = getattr(self, field_name) - if type(value) is not int or value < 0: + # Every limit is encoded as u64 in the invocation identity. Admission + # owns that representability boundary so identity derivation is total. + positive = frozenset((0, 1, 2, 5, 6, 7)) + for index, (field_name, value) in enumerate( + zip(_EXECUTION_LIMIT_FIELDS_V1, values, strict=True) + ): + minimum = 1 if index in positive else 0 + if type(value) is not int or value < minimum or value >= 1 << 64: raise ExecutionRequestErrorV1(RequestReasonV1.INVALID_LIMIT, field_name) # V1's syscall policy denies clone/fork/vfork; a larger cgroup task # budget would advertise a concurrency capability the executor lacks. - if self.pids_max != 1: + if pids_max != 1: raise ExecutionRequestErrorV1(RequestReasonV1.INVALID_LIMIT, "pids_max") + return tuple.__new__(cls, values) + max_executable_bytes = property(lambda self: self[0]) + max_stdin_bytes = property(lambda self: self[1]) + max_argument_bytes = property(lambda self: self[2]) + max_stdout_bytes = property(lambda self: self[3]) + max_stderr_bytes = property(lambda self: self[4]) + wall_timeout_ns = property(lambda self: self[5]) + memory_max_bytes = property(lambda self: self[6]) + pids_max = property(lambda self: self[7]) -@dataclass(frozen=True) -class ExecutionRequestV1: - executable: bytes - argv: tuple[bytes, ...] - environment: tuple[tuple[bytes, bytes], ...] - cwd: bytes - stdin: bytes - umask: int - limits: ExecutionLimitsV1 - def __post_init__(self) -> None: - if type(self.limits) is not ExecutionLimitsV1: +class ExecutionRequestV1(tuple): + """Deeply immutable invocation coordinates admitted as one value.""" + + __slots__ = () + + def __new__( + cls, + executable: bytes, + argv: tuple[bytes, ...], + environment: tuple[tuple[bytes, bytes], ...], + cwd: bytes, + stdin: bytes, + umask: int, + limits: ExecutionLimitsV1, + ) -> ExecutionRequestV1: + if type(limits) is not ExecutionLimitsV1: + _request_fail(RequestReasonV1.WRONG_TYPE, "limits") + try: + limits = ExecutionLimitsV1(*limits) + except ExecutionRequestErrorV1: + raise + except Exception: _request_fail(RequestReasonV1.WRONG_TYPE, "limits") - if type(self.executable) is not bytes: + if type(executable) is not bytes: _request_fail(RequestReasonV1.WRONG_TYPE, "executable") - if not self.executable or len(self.executable) > self.limits.max_executable_bytes: + if not executable or len(executable) > limits.max_executable_bytes: _request_fail(RequestReasonV1.LIMIT_EXCEEDED, "executable") - require_static_x86_64_elf_v1(self.executable) + require_static_x86_64_elf_v1(executable) - if type(self.argv) is not tuple or not self.argv: + if type(argv) is not tuple or not argv: _request_fail(RequestReasonV1.WRONG_TYPE, "argv") - for index, item in enumerate(self.argv): + if _sequence_count_v1(argv) >= _U32_CARDINALITY_LIMIT_V1: + _request_fail(RequestReasonV1.LIMIT_EXCEEDED, "argv") + for index, item in enumerate(argv): _require_bytes_without_nul(item, f"argv[{index}]") - if not self.argv[0]: + if not argv[0]: _request_fail(RequestReasonV1.EMPTY_ARGV_ZERO, "argv[0]") - if type(self.environment) is not tuple: + if type(environment) is not tuple: _request_fail(RequestReasonV1.WRONG_TYPE, "environment") + if _sequence_count_v1(environment) >= _U32_CARDINALITY_LIMIT_V1: + _request_fail(RequestReasonV1.LIMIT_EXCEEDED, "environment") previous: bytes | None = None - argument_bytes = sum(len(item) + 1 for item in self.argv) - for index, item in enumerate(self.environment): + argument_bytes = sum(len(item) + 1 for item in argv) + for index, item in enumerate(environment): if type(item) is not tuple or len(item) != 2: _request_fail(RequestReasonV1.WRONG_TYPE, f"environment[{index}]") key, value = item @@ -257,25 +358,112 @@ def __post_init__(self) -> None: _request_fail(RequestReasonV1.NONCANONICAL_ENVIRONMENT, "environment") previous = key argument_bytes += len(key) + len(value) + 2 - if argument_bytes > self.limits.max_argument_bytes: + if argument_bytes > limits.max_argument_bytes: _request_fail(RequestReasonV1.LIMIT_EXCEEDED, "argv+environment") - _require_bytes_without_nul(self.cwd, "cwd") - if not self.cwd.startswith(b"/"): + _require_bytes_without_nul(cwd, "cwd") + if not cwd.startswith(b"/"): _request_fail(RequestReasonV1.RELATIVE_CWD, "cwd") if ( - posixpath.normpath(self.cwd) != self.cwd - or self.cwd.startswith(b"//") - or (self.cwd != b"/" and self.cwd.endswith(b"/")) + posixpath.normpath(cwd) != cwd + or cwd.startswith(b"//") + or (cwd != b"/" and cwd.endswith(b"/")) ): _request_fail(RequestReasonV1.NONCANONICAL_CWD, "cwd") - if type(self.stdin) is not bytes: + if type(stdin) is not bytes: _request_fail(RequestReasonV1.WRONG_TYPE, "stdin") - if len(self.stdin) > self.limits.max_stdin_bytes: + if len(stdin) > limits.max_stdin_bytes: _request_fail(RequestReasonV1.LIMIT_EXCEEDED, "stdin") - if type(self.umask) is not int or not 0 <= self.umask <= 0o777: + if type(umask) is not int or not 0 <= umask <= 0o777: _request_fail(RequestReasonV1.INVALID_LIMIT, "umask") + return tuple.__new__( + cls, + (executable, argv, environment, cwd, stdin, umask, limits), + ) + + executable = property(lambda self: self[0]) + argv = property(lambda self: self[1]) + environment = property(lambda self: self[2]) + cwd = property(lambda self: self[3]) + stdin = property(lambda self: self[4]) + umask = property(lambda self: self[5]) + limits = property(lambda self: self[6]) + + +def _invocation_identity_from_fields_v1(request: ExecutionRequestV1) -> bytes: + chunks: list[bytes] = [hashlib.sha256(request.executable).digest()] + chunks.append(len(request.argv).to_bytes(4, "big")) + chunks.extend(request.argv) + chunks.append(len(request.environment).to_bytes(4, "big")) + for key, value in request.environment: + chunks.extend((key, value)) + chunks.extend( + ( + request.cwd, + hashlib.sha256(request.stdin).digest(), + len(request.stdin).to_bytes(8, "big"), + request.umask.to_bytes(4, "big"), + ) + ) + for value in request.limits: + chunks.append(value.to_bytes(8, "big")) + return _execution_identity_v1(_INVOCATION_ID_LABEL_V1, tuple(chunks)) + + +def invocation_identity_v1(request: object) -> ExecutionIdentityResultV1: + """Bind exactly the invocation state that passed request admission.""" + + if type(request) is not ExecutionRequestV1: + return ExecutionIdentityRejectedV1( + ExecutionIdentityReasonV1.WRONG_REQUEST_TYPE + ) + try: + if type(request.limits) is not ExecutionLimitsV1: + raise TypeError("foreign execution limits") + replayed_limits = ExecutionLimitsV1(*request.limits) + replayed = ExecutionRequestV1( + request.executable, + request.argv, + request.environment, + request.cwd, + request.stdin, + request.umask, + replayed_limits, + ) + except Exception: + return ExecutionIdentityRejectedV1( + ExecutionIdentityReasonV1.REQUEST_NOT_ADMITTED + ) + if replayed != request: + return ExecutionIdentityRejectedV1( + ExecutionIdentityReasonV1.REQUEST_NOT_ADMITTED + ) + return _invocation_identity_from_fields_v1(replayed) + + +def platform_identity_v1(report: object) -> ExecutionIdentityResultV1: + """Bind the exact admitted execution platform and sandbox policy.""" + + if type(report) is not SupportedV1: + return ExecutionIdentityRejectedV1( + ExecutionIdentityReasonV1.FOREIGN_PLATFORM + ) + try: + replayed = SupportedV1(report.platform, report.sandbox_policy_release) + if replayed != report: + raise TypeError("platform coordinates did not replay") + return _execution_identity_v1( + _PLATFORM_ID_LABEL_V1, + ( + replayed.platform.encode("ascii"), + replayed.sandbox_policy_release.encode("ascii"), + ), + ) + except Exception: + return ExecutionIdentityRejectedV1( + ExecutionIdentityReasonV1.FOREIGN_PLATFORM + ) def _request_fail(reason: RequestReasonV1, field: str) -> NoReturn: @@ -393,6 +581,7 @@ class SetupStageV1(int, Enum): class ObserverReasonV1(str, Enum): + REQUEST_NOT_ADMITTED = "request_not_admitted" PROBE_FAILED = "probe_failed" BACKEND_EXCEPTION = "backend_exception" BACKEND_CONTRACT = "backend_contract" @@ -561,14 +750,7 @@ def probe(self) -> CapabilityReportV1: try: report = backend.probe(guard) except Exception: - report = UnsupportedV1( - ( - CapabilityFailureV1( - CapabilityReasonV1.KERNEL_API_UNAVAILABLE, - None, - ), - ) - ) + report = _kernel_api_unavailable_report_v1() except BaseException: with self._capability_lock: self._active_capability_probes -= 1 @@ -577,14 +759,24 @@ def probe(self) -> CapabilityReportV1: self._issued_backend = None raise if type(report) not in (SupportedV1, UnsupportedV1): - report = UnsupportedV1( - ( - CapabilityFailureV1( - CapabilityReasonV1.KERNEL_API_UNAVAILABLE, - None, - ), + report = _kernel_api_unavailable_report_v1() + elif ( + type(report) is SupportedV1 + and type(platform_identity_v1(report)) is not bytes + ): + report = _kernel_api_unavailable_report_v1() + elif type(report) is UnsupportedV1: + try: + if not _unsupported_is_well_typed_v1(report): + raise TypeError("unsupported capability report did not replay") + report = UnsupportedV1( + tuple( + CapabilityFailureV1(failure.reason, failure.errno) + for failure in report.failures + ) ) - ) + except Exception: + report = _kernel_api_unavailable_report_v1() with self._capability_lock: self._active_capability_probes -= 1 invalidated = ( @@ -611,13 +803,17 @@ def probe(self) -> CapabilityReportV1: def execute( self, - request: ExecutionRequestV1, + request: object, capability: SupportedV1 | None = None, ) -> ExecutionResultV1: if os.getpid() != self._owner_pid: return ObserverFailureV1(ObserverReasonV1.PROBE_FAILED) - if type(request) is not ExecutionRequestV1: - raise ExecutionRequestErrorV1(RequestReasonV1.WRONG_TYPE, "request") + request_identity = invocation_identity_v1(request) + if ( + type(request) is not ExecutionRequestV1 + or type(request_identity) is not bytes + ): + return ObserverFailureV1(ObserverReasonV1.REQUEST_NOT_ADMITTED) if capability is None: report = self.probe() if type(report) is UnsupportedV1: @@ -903,7 +1099,7 @@ def create_executable_memfd(self) -> int: if not hasattr(os, "memfd_create"): raise OSError(errno_module.ENOSYS, "memfd_create unavailable") return os.memfd_create( - "labcolors-arb-evaluator", + "labcolors-proof-evaluator", _MFD_CLOEXEC | _MFD_ALLOW_SEALING | _MFD_EXEC, ) @@ -1538,14 +1734,7 @@ def probe(self, guard: _ProbeGuardV1) -> CapabilityReportV1: try: return self._probe_capability_v1(guard) except Exception: - return UnsupportedV1( - ( - CapabilityFailureV1( - CapabilityReasonV1.KERNEL_API_UNAVAILABLE, - None, - ), - ) - ) + return _kernel_api_unavailable_report_v1() def _probe_capability_v1(self, guard: _ProbeGuardV1) -> CapabilityReportV1: if self._platform_name != "linux": @@ -1582,14 +1771,7 @@ def _probe_capability_v1(self, guard: _ProbeGuardV1) -> CapabilityReportV1: operations = self._operations if operations is None: if sys.platform != "linux": - return UnsupportedV1( - ( - CapabilityFailureV1( - CapabilityReasonV1.KERNEL_API_UNAVAILABLE, - None, - ), - ) - ) + return _kernel_api_unavailable_report_v1() operations = _NativeLinuxOperationsV1() self._operations = operations diff --git a/proof/region/v1/arb/tests/test_executor.py b/proof/region/v1/tests/test_executor.py similarity index 82% rename from proof/region/v1/arb/tests/test_executor.py rename to proof/region/v1/tests/test_executor.py index 8be7b4a4..66038f71 100644 --- a/proof/region/v1/arb/tests/test_executor.py +++ b/proof/region/v1/tests/test_executor.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Hostile tests for the Linux-only Arb process boundary.""" +"""Hostile tests for the shared Linux-only proof process boundary.""" from __future__ import annotations @@ -19,9 +19,8 @@ from unittest import mock -ROOT = Path(__file__).resolve().parents[2] -ARB_ROOT = ROOT / "arb" -sys.path.insert(0, str(ARB_ROOT)) +PROOF = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROOF)) import executor # noqa: E402 @@ -168,11 +167,32 @@ def _limits(**changes: int) -> executor.ExecutionLimitsV1: return executor.ExecutionLimitsV1(**values) +def _replace_limits( + value: executor.ExecutionLimitsV1, + **changes: int, +) -> executor.ExecutionLimitsV1: + values = { + name: getattr(value, name) + for name in ( + "max_executable_bytes", + "max_stdin_bytes", + "max_argument_bytes", + "max_stdout_bytes", + "max_stderr_bytes", + "wall_timeout_ns", + "memory_max_bytes", + "pids_max", + ) + } + values.update(changes) + return executor.ExecutionLimitsV1(**values) + + def _request(**changes: object) -> executor.ExecutionRequestV1: values: dict[str, object] = { "executable": _static_elf(), "argv": ( - b"arb-evaluator", + b"proof-evaluator", b"--manifest-identity", b"1" * 64, b"--job", @@ -373,6 +393,144 @@ def _read_required(self, name: bytes) -> bytes: return self.values[name] +class SharedExecutorBoundaryTests(unittest.TestCase): + def test_executor_is_one_shared_leaf_outside_engine_packages(self) -> None: + shared = PROOF / "executor.py" + + self.assertTrue(shared.is_file()) + self.assertFalse((PROOF / "arb/executor.py").exists()) + self.assertEqual(Path(executor.__file__).resolve(), shared.resolve()) + source = shared.read_text(encoding="utf-8") + self.assertNotIn("Arb", source) + self.assertNotIn("labcolors-arb", source.lower()) + self.assertNotIn("mpfi", source.lower()) + for engine_import in ( + "import arb", + "from arb", + ".arb", + "import mpfi", + "from mpfi", + ".mpfi", + ): + with self.subTest(engine_import=engine_import): + self.assertNotIn(engine_import, source.lower()) + + def test_execution_identities_match_independent_literal_goldens(self) -> None: + request = _request() + platform_value = executor.SupportedV1( + "linux-x86_64", + executor.SANDBOX_POLICY_RELEASE_V1, + ) + + self.assertEqual( + executor.invocation_identity_v1(request).hex(), + "4c5bf676852b086fe7909a572bdbcc497ea52cec8d5affbe162fcd776605c384", + ) + self.assertEqual( + executor.platform_identity_v1(platform_value).hex(), + "0e37fa87cadce6814466528b2ea419e964554339bcbcb8d27c2da66c95dabc51", + ) + + platform_value = tuple.__new__( + executor.SupportedV1, + (executor.EXECUTION_PLATFORM_V1, "foreign"), + ) + self.assertEqual( + executor.platform_identity_v1(platform_value), + executor.ExecutionIdentityRejectedV1( + executor.ExecutionIdentityReasonV1.FOREIGN_PLATFORM, + ), + ) + + def test_invocation_identity_binds_every_representable_coordinate(self) -> None: + request = _request() + baseline = executor.invocation_identity_v1(request) + limit_mutations = ( + {"max_executable_bytes": request.limits.max_executable_bytes + 1}, + {"max_stdin_bytes": request.limits.max_stdin_bytes + 1}, + {"max_argument_bytes": request.limits.max_argument_bytes + 1}, + {"max_stdout_bytes": request.limits.max_stdout_bytes + 1}, + {"max_stderr_bytes": request.limits.max_stderr_bytes + 1}, + {"wall_timeout_ns": request.limits.wall_timeout_ns + 1}, + {"memory_max_bytes": request.limits.memory_max_bytes + 1}, + ) + mutants = ( + _request(executable=request.executable + b"x"), + _request(argv=request.argv + (b"--strict",)), + _request( + environment=((b"LC_ALL", b"POSIX"), (b"TZ", b"UTC")), + ), + _request(cwd=b"/"), + _request(stdin=request.stdin + b"x"), + _request(umask=0o022), + *( + _request(limits=_replace_limits(request.limits, **changes)) + for changes in limit_mutations + ), + ) + + self.assertEqual(len(mutants), 13) + identities = {executor.invocation_identity_v1(item) for item in mutants} + self.assertEqual(len(identities), 13) + self.assertTrue(all(type(identity) is bytes for identity in identities)) + self.assertTrue( + all(executor.invocation_identity_v1(item) != baseline for item in mutants) + ) + + def test_identity_api_returns_typed_rejections_for_foreign_inputs(self) -> None: + cases = ( + ( + object(), + executor.ExecutionIdentityReasonV1.WRONG_REQUEST_TYPE, + ), + ( + tuple.__new__(executor.ExecutionRequestV1, ()), + executor.ExecutionIdentityReasonV1.REQUEST_NOT_ADMITTED, + ), + ) + for value, reason in cases: + with self.subTest(reason=reason): + self.assertEqual( + executor.invocation_identity_v1(value), + executor.ExecutionIdentityRejectedV1(reason), + ) + self.assertEqual( + executor.platform_identity_v1(object()), + executor.ExecutionIdentityRejectedV1( + executor.ExecutionIdentityReasonV1.FOREIGN_PLATFORM, + ), + ) + + def test_post_admission_request_mutation_cannot_receive_an_identity(self) -> None: + request = _request() + capability = executor.SupportedV1( + executor.EXECUTION_PLATFORM_V1, + executor.SANDBOX_POLICY_RELEASE_V1, + ) + invocation_identity = executor.invocation_identity_v1(request) + platform_identity = executor.platform_identity_v1(capability) + + with self.assertRaises((AttributeError, TypeError)): + object.__setattr__(request, "stdin", request.stdin + b"x") + with self.assertRaises((AttributeError, TypeError)): + object.__setattr__(request.limits, "wall_timeout_ns", 1) + with self.assertRaises((AttributeError, TypeError)): + object.__setattr__(capability, "sandbox_policy_release", "foreign") + self.assertEqual(executor.invocation_identity_v1(request), invocation_identity) + self.assertEqual(executor.platform_identity_v1(capability), platform_identity) + + def test_supported_platform_rejects_hostile_string_subclasses(self) -> None: + class HostileString(str): + def encode(self, *_args: object, **_kwargs: object) -> bytes: + raise RuntimeError("hostile encoding") + + with self.assertRaises(TypeError): + executor.SupportedV1( + HostileString(executor.EXECUTION_PLATFORM_V1), + HostileString(executor.SANDBOX_POLICY_RELEASE_V1), + ) + + class RequestAdmissionTests(unittest.TestCase): def test_combined_dynamic_fixture_points_after_its_full_header_table(self) -> None: elf = _static_elf(interpreter=True, needed=True) @@ -400,7 +558,7 @@ def test_request_preserves_exact_invocation_without_mapping_or_inheritance(self) self.assertEqual( request.argv, ( - b"arb-evaluator", + b"proof-evaluator", b"--manifest-identity", b"1" * 64, b"--job", @@ -410,12 +568,23 @@ def test_request_preserves_exact_invocation_without_mapping_or_inheritance(self) self.assertEqual(request.environment, ((b"LC_ALL", b"C"), (b"TZ", b"UTC"))) self.assertEqual(request.cwd, b"/work") self.assertEqual(request.stdin, b"LCJOB1\0\0") - self.assertNotIn("network_isolated", request.__dataclass_fields__) - self.assertNotIn("cgroup_isolated", request.__dataclass_fields__) + self.assertFalse(hasattr(request, "network_isolated")) + self.assertFalse(hasattr(request, "cgroup_isolated")) + + def test_request_rejects_forged_exact_limit_values(self) -> None: + forged = tuple.__new__( + executor.ExecutionLimitsV1, + (4096, 4096, 4096, 16, 16, 1_000_000_000, 64 * 1024 * 1024, 2), + ) + + with self.assertRaises(executor.ExecutionRequestErrorV1) as caught: + _request(limits=forged) + + self.assertEqual(caught.exception.reason, executor.RequestReasonV1.INVALID_LIMIT) def test_argv_environment_cwd_and_stdin_are_strict_bytes(self) -> None: cases = ( - ({"argv": [b"arb-evaluator"]}, executor.RequestReasonV1.WRONG_TYPE), + ({"argv": [b"proof-evaluator"]}, executor.RequestReasonV1.WRONG_TYPE), ({"argv": (b"",)}, executor.RequestReasonV1.EMPTY_ARGV_ZERO), ({"argv": (b"arb\0evil",)}, executor.RequestReasonV1.NUL_BYTE), ({"environment": {b"LC_ALL": b"C"}}, executor.RequestReasonV1.WRONG_TYPE), @@ -447,11 +616,49 @@ def test_explicit_limits_reject_oversized_inputs_and_bool_numbers(self) -> None: executable=_static_elf() + b"x" * 4096, ) with self.assertRaises(executor.ExecutionRequestErrorV1) as caught: - replace(_limits(), pids_max=True) # type: ignore[arg-type] + _replace_limits(_limits(), pids_max=True) # type: ignore[arg-type] self.assertEqual(caught.exception.reason, executor.RequestReasonV1.INVALID_LIMIT) with self.assertRaises(executor.ExecutionRequestErrorV1) as caught: - replace(_limits(), pids_max=2) + _replace_limits(_limits(), pids_max=2) self.assertEqual(caught.exception.reason, executor.RequestReasonV1.INVALID_LIMIT) + for field_name in ( + "max_executable_bytes", + "max_stdin_bytes", + "max_argument_bytes", + "max_stdout_bytes", + "max_stderr_bytes", + "wall_timeout_ns", + "memory_max_bytes", + "pids_max", + ): + with self.subTest(u64_field=field_name): + with self.assertRaises(executor.ExecutionRequestErrorV1) as caught: + _replace_limits(_limits(), **{field_name: 1 << 64}) + self.assertEqual( + caught.exception.reason, + executor.RequestReasonV1.INVALID_LIMIT, + ) + cardinalities = ( + ("argv", (b"proof-evaluator",)), + ("environment", ((b"LC_ALL", b"C"),)), + ) + for field_name, target in cardinalities: + with self.subTest(u32_cardinality=field_name): + with mock.patch.object( + executor, + "_sequence_count_v1", + side_effect=lambda value, target=target: ( + 1 << 32 if value is target else len(value) + ), + ): + with self.assertRaises( + executor.ExecutionRequestErrorV1 + ) as caught: + _request(**{field_name: target}) + self.assertEqual( + caught.exception.reason, + executor.RequestReasonV1.LIMIT_EXCEEDED, + ) def test_only_static_x86_64_elf_is_admitted(self) -> None: self.assert_rejected(executor.RequestReasonV1.INVALID_ELF, executable=b"#!/bin/sh\n") @@ -699,6 +906,76 @@ def test_one_probe_capability_is_forwarded_to_exactly_one_run(self) -> None: self.assertEqual(received_capability, capability) self.assertIsNot(received_capability, capability) + def test_forged_exact_capability_is_rejected_without_exception(self) -> None: + forged = tuple.__new__(executor.SupportedV1, ()) + backend = _Backend(forged) + + report = executor.ControlledExecutorV1(backend).probe() + + self.assertEqual( + report, + executor.UnsupportedV1( + ( + executor.CapabilityFailureV1( + executor.CapabilityReasonV1.KERNEL_API_UNAVAILABLE, + None, + ), + ) + ), + ) + + def test_forged_exact_unsupported_report_is_rejected_without_exception(self) -> None: + forged = object.__new__(executor.UnsupportedV1) + backend = _Backend(forged) + + report = executor.ControlledExecutorV1(backend).probe() + + self.assertEqual( + report, + executor.UnsupportedV1( + ( + executor.CapabilityFailureV1( + executor.CapabilityReasonV1.KERNEL_API_UNAVAILABLE, + None, + ), + ) + ), + ) + + def test_unadmitted_exact_request_never_reaches_the_backend(self) -> None: + admitted = _request() + forged_limits = tuple.__new__( + executor.ExecutionLimitsV1, + (*admitted.limits[:-1], 2), + ) + forged = tuple.__new__( + executor.ExecutionRequestV1, + (*admitted[:-1], forged_limits), + ) + capability = executor.SupportedV1( + executor.EXECUTION_PLATFORM_V1, + executor.SANDBOX_POLICY_RELEASE_V1, + ) + backend = _Backend( + capability, + executor.CompletedV1( + hashlib.sha256(admitted.executable).digest(), + b"answer", + b"", + ), + ) + + result = executor.ControlledExecutorV1(backend).execute(forged) + + self.assertEqual( + result, + executor.ObserverFailureV1( + executor.ObserverReasonV1.REQUEST_NOT_ADMITTED + ), + ) + self.assertEqual(backend.probe_calls, 0) + self.assertEqual(backend.received, []) + def test_preprobed_capability_is_consumed_without_a_second_probe(self) -> None: capability = executor.SupportedV1( "linux-x86_64", @@ -1105,7 +1382,7 @@ def test_hash_and_exec_use_the_same_sealed_memfd(self) -> None: sealed = executor._seal_executable_v1(executable, operations) sealed.execveat( - (b"arb-evaluator",), + (b"proof-evaluator",), ((b"LC_ALL", b"C"),), operations, )