From aa00b77540b31eca7f7774308688fbd3e1fdaec6 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:04:43 +0000 Subject: [PATCH 01/17] Prepare the installed PHP command-key acceptance adapter --- tests/ci/test_command_key_client.py | 71 ++++++++++++ tests/smoke/command-key-client.php | 160 ++++++++++++++++++++++++++++ tests/smoke/command-key-supply.json | 9 ++ tests/smoke/command-keys.md | 59 ++++++++++ 4 files changed, 299 insertions(+) create mode 100644 tests/ci/test_command_key_client.py create mode 100644 tests/smoke/command-key-client.php create mode 100644 tests/smoke/command-key-supply.json create mode 100644 tests/smoke/command-keys.md diff --git a/tests/ci/test_command_key_client.py b/tests/ci/test_command_key_client.py new file mode 100644 index 00000000..57828b36 --- /dev/null +++ b/tests/ci/test_command_key_client.py @@ -0,0 +1,71 @@ +"""Offline input controls for the installed PHP command-key adapter.""" + +import json +import os +from pathlib import Path +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +IMAGE = os.environ.get("SDK_PHP_COMMAND_KEY_IMAGE", "") + + +@unittest.skipUnless(IMAGE, "Select the already-built immutable PHP image for the offline controls.") +class CommandKeyClientInputTest(unittest.TestCase): + def run_packet(self, packet, *, url="http://127.0.0.1:1", syntax=False): + self.assertRegex(IMAGE, r"\Asha256:[0-9a-f]{64}\Z") + self.addCleanup(self.assert_container_absent) + command = [ + "docker", "run", "--rm", "--init", "--interactive", "--name", self.container_name, + "--cpus", "1", "--memory", "128m", "--pids-limit", "64", + "--network", "none", "--read-only", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--user", f"{os.getuid()}:{os.getgid()}", + "--env", f"COMMAND_KEY_RUNTIME_URL={url}", + "--mount", f"type=bind,source={ROOT / 'tests/smoke/command-key-client.php'},target=/fixture/client.php,readonly", + "--mount", f"type=bind,source={ROOT / 'tests/smoke/command-key-supply.json'},target=/fixture/command-key-supply.json,readonly", + IMAGE, "php", + ] + command += ["-l", "/fixture/client.php"] if syntax else ["/fixture/client.php", "/autoload-must-not-be-read.php"] + return subprocess.run(command, input=packet, capture_output=True, text=True, timeout=20) + + def setUp(self): + self.container_name = f"sdk-php-command-key-input-{os.getpid()}" + + def assert_container_absent(self): + result = subprocess.run(["docker", "inspect", self.container_name], capture_output=True, text=True, timeout=10) + self.assertNotEqual(0, result.returncode) + self.assertEqual(f"error: no such object: {self.container_name}", result.stderr.strip().lower()) + + def refused(self, packet, message, **options): + result = self.run_packet(packet, **options) + self.assertNotEqual(0, result.returncode) + self.assertIn(message, result.stderr) + self.assertNotIn("autoload-must-not-be-read", result.stderr) + + def test_unknown_operation_stops_before_autoload(self): + self.refused('{"operation":"unsupported"}', "Unknown command-key fixture operation") + + def test_foreign_workflow_stops_before_autoload(self): + self.refused(json.dumps({"operation": "signal", "workflow_id": "customer-order"}), + "workflow identity is outside its owned prefix") + + def test_foreign_namespace_stops_before_autoload(self): + self.refused(json.dumps({"operation": "signal", "workflow_id": "sdk-php-216-inert", "namespace": "customer"}), + "namespace is outside its owned prefix") + + def test_external_origin_stops_before_autoload(self): + self.refused(json.dumps({"operation": "signal", "workflow_id": "sdk-php-216-inert"}), + "requires a numeric loopback runtime", url="https://example.invalid") + + def test_oversized_packet_stops_before_autoload(self): + self.refused('x' * 65537, "fixture packet exceeds its limit") + + def test_adapter_syntax(self): + result = self.run_packet('', syntax=True) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("No syntax errors", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/smoke/command-key-client.php b/tests/smoke/command-key-client.php new file mode 100644 index 00000000..ec6ed0c1 --- /dev/null +++ b/tests/smoke/command-key-client.php @@ -0,0 +1,160 @@ + 65536) { + throw new RuntimeException('The command-key fixture packet exceeds its limit.'); +} +$packet = json_decode($input, true, flags: JSON_THROW_ON_ERROR); +if (!is_array($packet) || array_is_list($packet)) { + throw new RuntimeException('The command-key fixture needs an object packet.'); +} +$operation = $packet['operation'] ?? ''; +if (!in_array($operation, ['bootstrap', 'drive', 'signal', 'update', 'read', 'value', 'history', 'describe'], true)) { + throw new RuntimeException('Unknown command-key fixture operation.'); +} +$workflow = $packet['workflow_id'] ?? ''; +if (!is_string($workflow) || preg_match('/\Asdk-php-216-[a-zA-Z0-9_.-]{1,100}\z/', $workflow) !== 1) { + throw new RuntimeException('The fixture workflow identity is outside its owned prefix.'); +} +$namespace = $packet['namespace'] ?? 'default'; +if (!is_string($namespace) || ($namespace !== 'default' + && preg_match('/\Asdk-php-216-[a-zA-Z0-9_.-]{1,100}\z/', $namespace) !== 1)) { + throw new RuntimeException('The fixture namespace is outside its owned prefix.'); +} +$supply = json_decode((string) file_get_contents(__DIR__.'/command-key-supply.json'), true, flags: JSON_THROW_ON_ERROR); +$url = getenv('COMMAND_KEY_RUNTIME_URL'); +if (!is_string($url) || preg_match('~\Ahttp://127\.0\.0\.1:([1-9][0-9]{0,4})\z~', $url, $port) !== 1 + || (int) $port[1] > 65535) { + throw new RuntimeException('The fixture requires a numeric loopback runtime and explicit port.'); +} + +require $argv[1]; +$root = realpath((string) InstalledVersions::getInstallPath('durable-workflow/sdk')); +if ($root === false || InstalledVersions::getPrettyVersion('durable-workflow/sdk') !== $supply['sdk_version'] + || InstalledVersions::getReference('durable-workflow/sdk') !== $supply['sdk_reference']) { + throw new RuntimeException('The installed SDK differs from the selected published supply.'); +} +foreach ([Client::class, Worker::class, SignalFailed::class, UpdateFailed::class] as $class) { + $file = realpath((string) (new ReflectionClass($class))->getFileName()); + if ($file === false || !str_starts_with($file, $root.'/')) { + throw new RuntimeException('Source fallback is forbidden.'); + } +} + +$client = new Client($url, namespace: $namespace, + token: getenv('COMMAND_KEY_TOKEN'), requestTimeoutSeconds: 8, workerRequestTimeoutSeconds: 8); +$info = $client->clusterInfo(); +if ($info->version !== $supply['server_version'] || ($info->raw['implementation'] ?? null) !== 'rust' + || !$info->supportsCommandRequestKeys()) { + throw new RuntimeException('The runtime lacks the selected native command-key contract.'); +} +$identity = ['sdk_version' => $supply['sdk_version'], 'sdk_reference' => $supply['sdk_reference'], + 'server_version' => $info->version, 'php_version' => PHP_VERSION, 'source_fallback' => false]; +$run = $packet['run_id'] ?? null; +$arguments = $packet['arguments'] ?? []; +if (isset($packet['arguments_envelope'])) { + if (array_key_exists('arguments', $packet)) { + throw new RuntimeException('Use one argument representation.'); + } + $arguments = $client->payloadCodec()->decodeEnvelope($packet['arguments_envelope']); +} +if (!is_array($arguments) || !array_is_list($arguments)) { + throw new RuntimeException('Command arguments must be a list.'); +} + +$receiptFields = ['workflow_id', 'run_id', 'command_id', 'update_id', 'request_id', 'command_status', + 'update_status', 'outcome', 'retained', 'intent_verified', 'accepted_at']; +try { + if ($operation === 'bootstrap' || $operation === 'drive') { + $queue = $packet['task_queue'] ?? ''; + if (!is_string($queue) || preg_match('/\Asdk-php-216-[a-zA-Z0-9_.-]{1,100}\z/', $queue) !== 1) { + throw new RuntimeException('The fixture queue is outside its owned prefix.'); + } + $worker = new Worker($client, $queue); + $worker->registerWorkflow('command.keys', static function (WorkflowContext $context, string $mode) use ($worker): mixed { + $worker->requestShutdown(); + $context->waitCondition(static fn (): bool => $context->signals('release') !== []); + if ($mode === 'continue') { + $context->continueAsNew(['open']); + } + return null; + }); + foreach (['signal', 'other'] as $name) { + $worker->declareSignal('command.keys', $name, static fn (mixed $value): mixed => null); + $worker->registerUpdate('command.keys', $name, static function (QueryContext $context, mixed $value) use ($worker): mixed { + $worker->requestShutdown(); + return null; + }); + } + $worker->declareSignal('command.keys', 'release'); + if ($operation === 'bootstrap') { + $mode = $packet['mode'] ?? 'open'; + if (!in_array($mode, ['open', 'close', 'continue'], true)) { + throw new RuntimeException('Unknown workflow fixture mode.'); + } + $client->startWorkflow('command.keys', $workflow, $queue, [$mode], + executionTimeoutSeconds: 300, runTimeoutSeconds: 120); + } + // The runner supplies a finite process timeout even if no task exists. + $worker->run(0); + $result = ['worker_stopped' => true]; + } elseif ($operation === 'signal') { + $receipt = $client->signalWorkflow($workflow, $packet['name'], $arguments, $run, $packet['request_id'] ?? null); + $result = array_intersect_key($receipt, array_flip($receiptFields)); + } elseif ($operation === 'update') { + $receipt = $client->updateWorkflowReceipt($workflow, $packet['name'], $arguments, + $packet['wait_for'] ?? 'accepted', 1, $packet['request_id'] ?? null, $run); + $result = array_intersect_key($receipt, array_flip($receiptFields)); + } elseif ($operation === 'read') { + $receipt = $client->updateOutcomeReceipt($workflow, $packet['update_id'], + waitFor: $packet['wait_for'] ?? 'accepted', waitTimeoutSeconds: 1); + $result = array_intersect_key($receipt, array_flip($receiptFields)); + if (($receipt['update_status'] ?? null) === 'completed') { + $result['completed_null'] = $client->updateOutcome($workflow, $packet['update_id'], waitTimeoutSeconds: 1) === null; + } + } elseif ($operation === 'value') { + $result = ['completed_null' => $client->updateOutcome($workflow, $packet['update_id'], waitTimeoutSeconds: 1) === null]; + } elseif ($operation === 'describe') { + $description = $client->describeWorkflow($workflow, $run); + $result = array_intersect_key($description, array_flip(['workflow_id', 'run_id', 'status', 'current_run_id'])); + } elseif ($operation === 'history') { + $counts = []; + foreach ($client->iterateWorkflowHistory($workflow, $run) as $event) { + $type = $event['event_type']; + $counts[$type] = ($counts[$type] ?? 0) + 1; + } + $result = ['event_counts' => $counts]; + } else { + throw new RuntimeException('Unknown command-key fixture operation.'); + } + echo json_encode($identity + ['outcome' => 'answered', 'result' => $result], JSON_THROW_ON_ERROR).PHP_EOL; +} catch (SignalFailed|UpdateFailed $error) { + // A lost reply may follow a committed admission; do not label it refused. + echo json_encode($identity + ['outcome' => 'error', 'exception' => $error::class, 'status' => $error->status, + 'reason' => $error->reason(), 'request_id' => $error->requestId(), + 'is_request_id_conflict' => $error->isRequestIdConflict(), + 'conflict' => $error->conflict(), 'retained_command_id' => $error->retainedCommandId(), + 'retained' => $error->details()['retained'] ?? null, + 'intent_verified' => $error->details()['intent_verified'] ?? null, + 'transient_connection_failure' => $error->isTransientConnectionFailure(), + 'transient_upstream_failure' => $error->isTransientUpstreamFailure()], JSON_THROW_ON_ERROR).PHP_EOL; +} catch (UpdateStillPending $error) { + echo json_encode($identity + ['outcome' => 'pending', 'update_id' => $error->updateId, + 'run_id' => $error->runId, 'request_id' => $error->requestId], JSON_THROW_ON_ERROR).PHP_EOL; +} catch (InvalidArgumentException $error) { + echo json_encode($identity + ['outcome' => 'local_invalid_argument', 'exception' => $error::class], + JSON_THROW_ON_ERROR).PHP_EOL; +} diff --git a/tests/smoke/command-key-supply.json b/tests/smoke/command-key-supply.json new file mode 100644 index 00000000..999e1524 --- /dev/null +++ b/tests/smoke/command-key-supply.json @@ -0,0 +1,9 @@ +{ + "sdk_version": "3.18.2", + "sdk_reference": "f9483bb2b5b45f591a82d9e5419c5b60a6e967c6", + "sdk_archive_sha256": "32dc5d1a05a96e9e772e964bea95ae8142882b901d612c97a4aadee699f4ec6b", + "server_version": "3.9.2", + "server_source": "5614274d2629d03f92932a07dfee0fb10349ddd9", + "server_index": "sha256:8ffcdb2a36af3129a1987849aafe8715fe0d3bf9a9648467f0b5cb8eeef43ba9", + "server_amd64": "sha256:813c9337c44be7d497efd29e1b463546519e488aa796b5ecf9dfc6f4f10d2128" +} diff --git a/tests/smoke/command-keys.md b/tests/smoke/command-keys.md new file mode 100644 index 00000000..bbb3e6ea --- /dev/null +++ b/tests/smoke/command-keys.md @@ -0,0 +1,59 @@ +# Installed PHP command-key adapter + +`command-key-client.php` reads one JSON packet from stdin and performs one +public PHP SDK operation. The selected immutable SDK and Server tuple lives +in `command-key-supply.json`. Every result identifies the installed package +reference, runtime version and PHP version. The adapter bounds input to 64 KiB, +accepts only a numeric loopback origin, and restricts fixture workflow and +queue names to the `sdk-php-216-` prefix. + +Before invocation, the owning acceptance runner verifies the registry archive +checksum, Composer lock/source/dist identities and installed SDK file bytes +against that selected supply. It owns the isolated runtime and endpoint, +synthetic credential, namespace provisioning, finite process timeout and exact +cleanup. Mount or capture the reviewed adapter, supply and autoloader inputs +read-only. The adapter's reflection checks reject a repository source fallback; +the runner supplies the independent archive/provenance proof. + +Set `COMMAND_KEY_RUNTIME_URL` to the owned numeric loopback URL and +`COMMAND_KEY_TOKEN` to its synthetic credential. Pass the registry consumer's +`vendor/autoload.php` as the first argument. Packet fields are: + +- `operation`: `bootstrap`, `drive`, `signal`, `update`, `read`, `value`, + `history` or `describe`. +- `workflow_id`, optional `run_id`, and optional `namespace` (`default` or an + owned fixture prefix). +- `task_queue` for `bootstrap`/`drive`, and `mode` (`open`, `close`, `continue`) + for bootstrap. The real Worker waits for `release`, completes with null, or + continues once into an open run. Each processed workflow/update asks that + Worker to shut down. The outer process timeout also bounds an empty queue. +- `name`, optional `request_id`, and either a list of `arguments` or an + official Avro `arguments_envelope` for signal/update calls. `signal` and + `other` are declared; `release` takes no arguments. Both update handlers + return null. Omit a key to test the SDK's generated key and typed error. +- `update_id` and optional `wait_for` for retained reads. `read` returns the + receipt; `value` preserves typed pending versus completed null. + +Results select admission/read identities and flags rather than arbitrary +response fields, headers or payloads. A transport failure is an error with an +unknown admission outcome; it is never described as a refused admission. +History output contains event counts only. + +Use the accepted shared committed-response-loss fixture unchanged to prove +response loss. Use the accepted portable SQLite helper and its adjacent exact +Server manifest for synthetic legacy copies. The producer-owned physical seed +and upgrade profile stay separate and must never pass through the synthetic +transform. This PHP adapter currently supplies modern direct/loss API calls; +installed execution, legacy/physical adapters and complete shared216 acceptance +require their own reviewed runner and sanitized receipts. + +Run the offline input controls against an existing immutable PHP image: + +```sh +SDK_PHP_COMMAND_KEY_IMAGE=sha256: \ + python3 -I -S -B tests/ci/test_command_key_client.py +``` + +These controls use no network or SDK autoload. They verify malformed operations, +foreign fixture identities, non-loopback origins and oversized packets stop +before package loading. They establish no installed command admission result. From c378e23b26476b23e1ae20a15b47528db91df270 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:26:30 +0000 Subject: [PATCH 02/17] Verify command-key SDK supply before package loading --- tests/ci/test_command_key_supply.py | 122 +++++++++++++++++++ tests/smoke/command-key-client.php | 2 +- tests/smoke/command-key-supply.json | 2 + tests/smoke/command-keys.md | 11 +- tests/smoke/verify_command_key_supply.py | 144 +++++++++++++++++++++++ 5 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 tests/ci/test_command_key_supply.py create mode 100644 tests/smoke/verify_command_key_supply.py diff --git a/tests/ci/test_command_key_supply.py b/tests/ci/test_command_key_supply.py new file mode 100644 index 00000000..dba56b06 --- /dev/null +++ b/tests/ci/test_command_key_supply.py @@ -0,0 +1,122 @@ +"""Inert archive/installation provenance controls; no PHP or network.""" + +from copy import deepcopy +import hashlib +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +import zipfile + + +SOURCE = Path(__file__).resolve().parents[1] / "smoke/verify_command_key_supply.py" +SPEC = importlib.util.spec_from_file_location("command_key_supply", SOURCE) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class CommandKeySupplyTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="sdk-php-supply-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.consumer = self.root / "consumer" + self.package = self.consumer / "vendor/durable-workflow/sdk" + (self.package / "src").mkdir(parents=True) + (self.consumer / "vendor/composer").mkdir(parents=True) + self.data = b" 65536) { diff --git a/tests/smoke/command-key-supply.json b/tests/smoke/command-key-supply.json index 999e1524..8cabaa47 100644 --- a/tests/smoke/command-key-supply.json +++ b/tests/smoke/command-key-supply.json @@ -1,6 +1,8 @@ { "sdk_version": "3.18.2", "sdk_reference": "f9483bb2b5b45f591a82d9e5419c5b60a6e967c6", + "sdk_source_url": "https://github.com/durable-workflow/sdk-php.git", + "sdk_dist_url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/f9483bb2b5b45f591a82d9e5419c5b60a6e967c6", "sdk_archive_sha256": "32dc5d1a05a96e9e772e964bea95ae8142882b901d612c97a4aadee699f4ec6b", "server_version": "3.9.2", "server_source": "5614274d2629d03f92932a07dfee0fb10349ddd9", diff --git a/tests/smoke/command-keys.md b/tests/smoke/command-keys.md index bbb3e6ea..d8ed0e19 100644 --- a/tests/smoke/command-keys.md +++ b/tests/smoke/command-keys.md @@ -1,7 +1,7 @@ # Installed PHP command-key adapter `command-key-client.php` reads one JSON packet from stdin and performs one -public PHP SDK operation. The selected immutable SDK and Server tuple lives +fixture operation through the public PHP SDK API. The selected immutable SDK and Server tuple lives in `command-key-supply.json`. Every result identifies the installed package reference, runtime version and PHP version. The adapter bounds input to 64 KiB, accepts only a numeric loopback origin, and restricts fixture workflow and @@ -15,6 +15,15 @@ cleanup. Mount or capture the reviewed adapter, supply and autoloader inputs read-only. The adapter's reflection checks reject a repository source fallback; the runner supplies the independent archive/provenance proof. +`verify_command_key_supply.py ` +performs the package inspection before PHP loading. It checks the exact root +requirement, absence of repository overrides, lock/source/dist URLs and refs, +dist installation, approved archive digest and every installed SDK file. It +accepts private umasks, normalizes GitHub's DOS nonexecutable entries and +rejects added permissions, missing/extra/changed files or aliases. Its output +is explicitly inspection-only. Run its inert controls with +`python3 -I -S -B tests/ci/test_command_key_supply.py`. + Set `COMMAND_KEY_RUNTIME_URL` to the owned numeric loopback URL and `COMMAND_KEY_TOKEN` to its synthetic credential. Pass the registry consumer's `vendor/autoload.php` as the first argument. Packet fields are: diff --git a/tests/smoke/verify_command_key_supply.py b/tests/smoke/verify_command_key_supply.py new file mode 100644 index 00000000..7e19163f --- /dev/null +++ b/tests/smoke/verify_command_key_supply.py @@ -0,0 +1,144 @@ +"""Verify the selected registry SDK bytes before PHP package loading.""" + +import hashlib +import json +from pathlib import Path, PurePosixPath +import stat +import sys +import zipfile + + +MAX_ARCHIVE_BYTES = 40 * 1024 * 1024 +MAX_PACKAGE_BYTES = 100 * 1024 * 1024 +MAX_FILES = 5000 +PACKAGE = "durable-workflow/sdk" + + +def regular(path): + metadata = path.lstat() + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ValueError("SDK evidence must be single-link regular files") + return metadata + + +def package_metadata(consumer, supply): + for path in (consumer / "composer.json", consumer / "composer.lock", consumer / "vendor/composer/installed.json"): + regular(path) + if path.resolve() != path: + raise ValueError("Composer metadata must not be an alias") + manifest = json.loads((consumer / "composer.json").read_text()) + if (manifest.get("repositories") or manifest.get("require-dev") + or manifest.get("require") != {PACKAGE: supply["sdk_version"]}): + raise ValueError("The consumer must select only the exact registry SDK") + lock = json.loads((consumer / "composer.lock").read_text()) + installed = json.loads((consumer / "vendor/composer/installed.json").read_text()) + installed = installed["packages"] if isinstance(installed, dict) else installed + selected = [] + for packages in (lock["packages"], installed): + matches = [entry for entry in packages if entry["name"] == PACKAGE] + if len(matches) != 1: + raise ValueError("The selected SDK package must occur exactly once") + entry = matches[0] + if (entry["version"].removeprefix("v") != supply["sdk_version"] + or entry.get("source", {}).get("url") != supply["sdk_source_url"] + or entry.get("source", {}).get("reference") != supply["sdk_reference"] + or entry.get("dist", {}).get("url") != supply["sdk_dist_url"] + or entry.get("dist", {}).get("reference") != supply["sdk_reference"]): + raise ValueError("The SDK lock/source/dist identities differ") + selected.append(entry) + if selected[1].get("installation-source") != "dist": + raise ValueError("The SDK must be a registry dist installation") + + package = consumer / "vendor/durable-workflow/sdk" + if package.resolve() != package or not package.is_dir(): + raise ValueError("The installed SDK root must not be an alias") + if (consumer / "vendor/composer" / selected[1]["install-path"]).resolve() != package: + raise ValueError("Composer's installed SDK path differs") + return package + + +def archive_member(entry): + path = PurePosixPath(entry.filename) + if path.is_absolute() or ".." in path.parts or not path.parts: + raise ValueError("The package member is outside its root") + if entry.is_dir(): + return None + name = PurePosixPath(*path.parts[1:]).as_posix() + mode = entry.external_attr >> 16 + # GitHub uses DOS entries without Unix modes for ordinary files, and Unix + # entries for executables. Composer extracts the former as nonexecutables. + if entry.create_system == 0 and mode == 0: + mode = stat.S_IFREG | 0o644 + if not name or name == "." or stat.S_IFMT(mode) not in (0, stat.S_IFREG): + raise ValueError("The package inventory contains an unsupported member") + return name, mode & 0o777 + + +def archive_files(archive, supply): + size = regular(archive).st_size + if not 0 < size <= MAX_ARCHIVE_BYTES: + raise ValueError("The registry archive exceeds its byte bound") + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + if digest != supply["sdk_archive_sha256"]: + raise ValueError("The registry archive differs from the approved SDK supply") + + expected = {} + total = 0 + with zipfile.ZipFile(archive) as source: + if len(source.infolist()) > MAX_FILES: + raise ValueError("The package inventory exceeds its bound") + prefixes = {PurePosixPath(entry.filename).parts[0] for entry in source.infolist()} + if len(prefixes) != 1: + raise ValueError("The registry dist must have one package root") + for entry in source.infolist(): + member = archive_member(entry) + if member is None: + continue + name, mode = member + if name in expected: + raise ValueError("The package inventory contains a duplicate member") + total += entry.file_size + if total > MAX_PACKAGE_BYTES: + raise ValueError("The package expands beyond its byte bound") + expected[name] = (source.read(entry), mode) + return expected, digest, total + + +def installed_files(package, expected): + actual = set() + for path in package.rglob("*"): + if path.is_symlink(): + raise ValueError("The installed SDK contains an alias") + if path.is_dir(): + continue + name = path.relative_to(package).as_posix() + if name not in expected: + raise ValueError("The installed SDK contains an unapproved file") + metadata = regular(path) + data, mode = expected[name] + # Private umasks may remove archive permissions, but never add them. + if metadata.st_mode & 0o777 & ~mode or metadata.st_mode & 0o022: + raise ValueError("The installed SDK file mode exceeds its archive mode") + if metadata.st_size != len(data) or path.read_bytes() != data: + raise ValueError("The installed SDK bytes differ from the registry archive") + actual.add(name) + if actual != set(expected) or not any(name.startswith("src/") for name in actual): + raise ValueError("The installed SDK inventory is incomplete") + return len(actual) + + +def verify(consumer, archive, supply): + consumer = Path(consumer) + if consumer.resolve() != consumer or not consumer.is_dir(): + raise ValueError("The consumer must be a canonical directory") + package = package_metadata(consumer, supply) + expected, digest, total = archive_files(Path(archive), supply) + count = installed_files(package, expected) + return {"sdk_version": supply["sdk_version"], "sdk_reference": supply["sdk_reference"], + "archive_sha256": digest, "installed_files": count, + "installed_bytes": total, "source_fallback": False, "inspection_only": True} + + +if __name__ == "__main__": + supply = json.loads(Path(__file__).with_name("command-key-supply.json").read_text()) + print(json.dumps(verify(Path(sys.argv[1]), Path(sys.argv[2]), supply))) From fa63aa3bffd9f101551108a008ef4f96371c46a8 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:59:15 +0000 Subject: [PATCH 03/17] Bind the modern installed command-key cohort to exact supply and runtime IDs --- .github/workflows/ci.yml | 4 + .github/workflows/published-command-keys.yml | 105 +++++ tests/ci/test_command_key_cohort.py | 127 ++++++ tests/smoke/command-key-client.php | 15 +- tests/smoke/command-key-supply.json | 5 +- tests/smoke/command-keys.md | 34 +- tests/smoke/command_key_cohort.py | 419 +++++++++++++++++++ 7 files changed, 701 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/published-command-keys.yml create mode 100644 tests/ci/test_command_key_cohort.py create mode 100644 tests/smoke/command_key_cohort.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f02f532..386239f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,10 @@ jobs: run: python -I scripts/ci/test-apt-no-retries.py - name: Verify PHP container launcher boundaries run: python -I -S -B tests/ci/test_php_container_launcher.py + - name: Verify command-key package and runtime provenance controls + run: | + python3 -I -S -B tests/ci/test_command_key_supply.py + python3 -I -S -B tests/ci/test_command_key_cohort.py test: name: PHP ${{ matrix.php }} diff --git a/.github/workflows/published-command-keys.yml b/.github/workflows/published-command-keys.yml new file mode 100644 index 00000000..192a96b6 --- /dev/null +++ b/.github/workflows/published-command-keys.yml @@ -0,0 +1,105 @@ +name: Published PHP command keys + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + modern-command-keys: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + defaults: + run: + shell: bash --noprofile --norc -e -o pipefail {0} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Check the isolated system Python and inert runner controls + run: | + /usr/bin/python3 -I -S -B -c 'import sys; assert sys.version_info[:2] == (3,12)' + echo /usr/bin >> "$GITHUB_PATH" + /usr/bin/python3 -I -S -B tests/ci/test_command_key_supply.py + /usr/bin/python3 -I -S -B tests/ci/test_command_key_cohort.py + - name: Create private registry consumer and runtime state + run: | + umask 077 + state="$(mktemp -d "$RUNNER_TEMP/php-command-keys.XXXXXX")" + mkdir -m 700 "$state/home" "$state/cache" "$state/tmp" "$state/consumer" "$state/runtime" + { + echo "COMMAND_KEY_STATE=$state" + echo "CONSUMER=$state/consumer" + echo "COMPOSER_HOME=$state/home" + echo "COMPOSER_CACHE_DIR=$state/cache" + echo "TMPDIR=$state/tmp" + } >> "$GITHUB_ENV" + python3 -I -S -B - "$state/consumer/composer.json" <<'PY' + import json,pathlib,sys + supply=json.loads(pathlib.Path('tests/smoke/command-key-supply.json').read_text()) + pathlib.Path(sys.argv[1]).write_text(json.dumps({'name':'durable-workflow/command-key-consumer','require':{'durable-workflow/sdk':supply['sdk_version']},'config':{'allow-plugins':{}}})+'\n') + PY + - name: Select authenticated dependency trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + with: + clients: python,composer,curl,git + - name: Set up the qualified PHP profile + uses: durable-workflow/.github/.github/actions/setup-php-linux@411a14ac07d32073eed6ab4977f2825eba4739a7 # bound-linux-php + with: + php-version: '8.3' + ini-file: production + tools: composer:2.10.3,phpunit:11.5.57 + coverage: none + - name: Verify Composer trust before registry requests + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + with: + working-directory: ${{ env.CONSUMER }} + - name: Install the selected registry dist without loading package code + run: composer install --working-dir="$CONSUMER" --no-autoloader --no-scripts --no-plugins --prefer-dist --no-interaction --no-progress + - name: Download the exact public archive and shared response-loss fixture + run: | + python3 -I -S -B - <<'PY' > "$COMMAND_KEY_STATE/public-inputs" + import json,pathlib + supply=json.loads(pathlib.Path('tests/smoke/command-key-supply.json').read_text()) + print(supply['sdk_dist_url']) + print('https://raw.githubusercontent.com/durable-workflow/.github/'+supply['loss_proxy_source']+'/conformance/command-keys/committed_response_loss.py') + PY + mapfile -t urls < "$COMMAND_KEY_STATE/public-inputs" + curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-time 90 --max-filesize 41943040 "${urls[0]}" --output "$COMMAND_KEY_STATE/sdk.zip" + curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-time 60 --max-filesize 65536 "${urls[1]}" --output "$COMMAND_KEY_STATE/committed_response_loss.py" + python3 -I -S -B tests/smoke/verify_command_key_supply.py "$CONSUMER" "$COMMAND_KEY_STATE/sdk.zip" > command-key-package.json + - name: Verify Composer trust before autoloader generation + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + with: + working-directory: ${{ env.CONSUMER }} + - name: Generate only the verified consumer autoloader + run: composer dump-autoload --working-dir="$CONSUMER" --no-scripts --no-plugins --no-interaction + - name: Qualify modern retained commands and postcommit response loss + run: | + php -l tests/smoke/command-key-client.php + timeout --signal=INT --kill-after=30s 12m python3 -I -S -B tests/smoke/command_key_cohort.py \ + --consumer "$CONSUMER" --archive "$COMMAND_KEY_STATE/sdk.zip" \ + --proxy-source "$COMMAND_KEY_STATE/committed_response_loss.py" \ + --runtime-state "$COMMAND_KEY_STATE/runtime" \ + --runtime-owner "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --runner-source "$GITHUB_SHA" \ + | tee command-key-modern.json + - name: Independently repeat captured-ID runtime cleanup + if: always() && env.COMMAND_KEY_STATE != '' + run: | + python3 -I -S -B tests/smoke/command_key_cohort.py \ + --runtime-state "$COMMAND_KEY_STATE/runtime" \ + --runtime-owner "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --runner-source "$GITHUB_SHA" \ + --cleanup-only | tee command-key-cleanup.json + - name: Retain sanitized observations and installed dependency graph + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: published-php-command-keys + path: | + command-key-package.json + command-key-modern.json + command-key-cleanup.json + ${{ env.CONSUMER }}/composer.lock + retention-days: 7 diff --git a/tests/ci/test_command_key_cohort.py b/tests/ci/test_command_key_cohort.py new file mode 100644 index 00000000..b6f3f186 --- /dev/null +++ b/tests/ci/test_command_key_cohort.py @@ -0,0 +1,127 @@ +"""Inert provenance/receipt controls. No Docker, PHP, runtime or network calls.""" + +from copy import deepcopy +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + + +SOURCE = Path(__file__).resolve().parents[1] / "smoke/command_key_cohort.py" +SPEC = importlib.util.spec_from_file_location("command_key_cohort", SOURCE) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +SUPPLY = json.loads(SOURCE.with_name("command-key-supply.json").read_text()) +IDENTITY = "a" * 64 + + +def runtime(): + return {"Id": IDENTITY, "Image": SUPPLY["server_config"], "State": {"Running": True}, + "Config": {"Labels": {"durable-workflow.php-command-keys": "123-1", + "org.opencontainers.image.revision": SUPPLY["server_source"]}}, + "HostConfig": {"ReadonlyRootfs": True, "Privileged": False, "Binds": None, + "Init": True, "CapDrop": ["ALL"], "CapAdd": None, + "SecurityOpt": ["no-new-privileges"], "Devices": [], "NetworkMode": "bridge", + "Memory": 1024**3, "NanoCpus": 10**9, "PidsLimit": 256, + "Tmpfs": {"/tmp": "size=64m", "/app/database": "size=32m", "/app/storage": "size=64m"}}, + "Mounts": [], "NetworkSettings": {"Ports": {"8080/tcp": [{"HostIp": "127.0.0.1", "HostPort": "43123"}]}}} + + +class ProvenanceControls(unittest.TestCase): + def origin(self, record): + with patch.object(MODULE, "inspect_runtime", return_value=record): + return MODULE.runtime_origin(IDENTITY, "123-1", SUPPLY) + + def test_complete_owned_runtime_selects_only_numeric_loopback(self): + self.assertEqual(self.origin(runtime()), "http://127.0.0.1:43123") + + def test_foreign_id_owner_source_and_config_refuse(self): + for mutation in (lambda r: r.update(Id="b" * 64), + lambda r: r.update(Image="sha256:" + "0" * 64), + lambda r: r["Config"]["Labels"].update({"durable-workflow.php-command-keys": "other"}), + lambda r: r["Config"]["Labels"].update({"org.opencontainers.image.revision": "0" * 40}), + lambda r: r["State"].update(Running=False)): + record = runtime() + mutation(record) + with self.subTest(record=record), self.assertRaises(RuntimeError): + self.origin(record) + + def test_broader_caps_filesystems_and_ports_refuse(self): + mutations = [lambda r: r["Mounts"].append({"Type": "bind"}), + lambda r: r["HostConfig"]["Tmpfs"].update({"/other": "size=1m"}), + lambda r: r["NetworkSettings"]["Ports"]["8080/tcp"][0].update(HostIp="0.0.0.0"), + lambda r: r["NetworkSettings"]["Ports"].update({"9000/tcp": None})] + mutations += [lambda r, key=key, value=value: r["HostConfig"].update({key: value}) + for key, value in [("Memory", 0), ("Memory", 2 * 1024**3), ("NanoCpus", 0), + ("PidsLimit", 257), ("ReadonlyRootfs", False), ("Privileged", True), + ("Init", False), ("CapDrop", []), ("CapAdd", ["SYS_ADMIN"]), + ("SecurityOpt", []), ("NetworkMode", "host"), ("Devices", [{}])]] + for index, mutation in enumerate(mutations): + record = runtime() + mutation(record) + with self.subTest(index=index), self.assertRaises(RuntimeError): + self.origin(record) + + def test_cleanup_preserves_foreign_id_replacements(self): + record = runtime() + record["Config"]["Labels"]["durable-workflow.php-command-keys"] = "foreign" + with patch.object(MODULE, "inspect_runtime", return_value=record), \ + patch.object(MODULE.subprocess, "run") as execute: + with self.assertRaisesRegex(RuntimeError, "preserve the replacement"): + MODULE.remove_runtime(IDENTITY, "123-1") + execute.assert_not_called() + + def test_cleanup_uses_only_captured_id_and_requires_missing_object(self): + calls = [] + + def execute(arguments, **_): + calls.append(arguments) + return type("Result", (), {"returncode": 1, "stderr": "Error: No such object: " + IDENTITY})() + + with patch.object(MODULE, "inspect_runtime", return_value=runtime()), \ + patch.object(MODULE.subprocess, "run", side_effect=execute): + MODULE.remove_runtime(IDENTITY, "123-1") + self.assertEqual(calls, [["docker", "rm", "--force", "--volumes", IDENTITY], ["docker", "inspect", IDENTITY]]) + + def test_inspection_error_category_is_not_an_absence_receipt(self): + result = type("Result", (), {"returncode": 1, "stdout": "", "stderr": "engine unavailable"})() + with patch.object(MODULE.subprocess, "run", return_value=result), self.assertRaises(RuntimeError): + MODULE.inspect_runtime(IDENTITY, allow_missing=True) + + def test_only_owned_private_runtime_state_and_regular_cid_are_accepted(self): + with tempfile.TemporaryDirectory() as temporary: + state = Path(temporary) + self.assertEqual(MODULE.private_state(state), state / "runtime.cid") + (state / "runtime.cid").write_text(IDENTITY) + with patch.object(MODULE, "remove_runtime") as remove: + MODULE.cleanup_state(state, "123-1") + remove.assert_called_once_with(IDENTITY, "123-1") + os.chmod(state, 0o755) + with self.assertRaises(RuntimeError): + MODULE.private_state(state) + os.chmod(state, 0o700) + (state / "runtime.cid").write_text("name-instead-of-id") + with self.assertRaises(RuntimeError): + MODULE.cleanup_state(state, "123-1") + + def test_receipt_comparison_preserves_every_original_identity(self): + for kind in ("signal", "update"): + original = {"command_id": "command", "run_id": "run", "request_id": "key", "signal_id": "signal", + "update_id": "update", "accepted_at": "date", "command_sequence": 3} + retained = original | {"retained": True, "intent_verified": True} + MODULE.retained(retained, original, kind) + for field in MODULE.identity(original, kind): + changed = deepcopy(retained) + changed[field] = 4 if field == "command_sequence" else "changed" + with self.subTest(kind=kind, field=field), self.assertRaises(RuntimeError): + MODULE.retained(changed, original, kind) + for field in ("retained", "intent_verified"): + with self.assertRaises(RuntimeError): + MODULE.retained(retained | {field: False}, original, kind) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/smoke/command-key-client.php b/tests/smoke/command-key-client.php index d9e373a1..d4ac2671 100644 --- a/tests/smoke/command-key-client.php +++ b/tests/smoke/command-key-client.php @@ -75,7 +75,7 @@ throw new RuntimeException('Command arguments must be a list.'); } -$receiptFields = ['workflow_id', 'run_id', 'command_id', 'update_id', 'request_id', 'command_status', +$receiptFields = ['workflow_id', 'run_id', 'command_id', 'signal_id', 'update_id', 'request_id', 'command_sequence', 'command_status', 'update_status', 'outcome', 'retained', 'intent_verified', 'accepted_at']; try { if ($operation === 'bootstrap' || $operation === 'drive') { @@ -129,14 +129,23 @@ $result = ['completed_null' => $client->updateOutcome($workflow, $packet['update_id'], waitTimeoutSeconds: 1) === null]; } elseif ($operation === 'describe') { $description = $client->describeWorkflow($workflow, $run); - $result = array_intersect_key($description, array_flip(['workflow_id', 'run_id', 'status', 'current_run_id'])); + $result = ['workflow_id' => $description->workflowId, 'run_id' => $description->runId, + 'status' => $description->status]; } elseif ($operation === 'history') { $counts = []; + $admissions = []; foreach ($client->iterateWorkflowHistory($workflow, $run) as $event) { $type = $event['event_type']; $counts[$type] = ($counts[$type] ?? 0) + 1; + if (in_array($type, ['SignalReceived', 'UpdateAccepted'], true)) { + $payload = is_array($event['payload'] ?? null) ? $event['payload'] : []; + $admissions[] = ['event_type' => $type, + 'command_id' => $payload['workflow_command_id'] ?? $event['workflow_command_id'] ?? null, + 'signal_id' => $payload['signal_id'] ?? null, 'update_id' => $payload['update_id'] ?? null, + 'request_id' => $payload['request_id'] ?? null]; + } } - $result = ['event_counts' => $counts]; + $result = ['event_counts' => $counts, 'admissions' => $admissions]; } else { throw new RuntimeException('Unknown command-key fixture operation.'); } diff --git a/tests/smoke/command-key-supply.json b/tests/smoke/command-key-supply.json index 8cabaa47..5d73b146 100644 --- a/tests/smoke/command-key-supply.json +++ b/tests/smoke/command-key-supply.json @@ -7,5 +7,8 @@ "server_version": "3.9.2", "server_source": "5614274d2629d03f92932a07dfee0fb10349ddd9", "server_index": "sha256:8ffcdb2a36af3129a1987849aafe8715fe0d3bf9a9648467f0b5cb8eeef43ba9", - "server_amd64": "sha256:813c9337c44be7d497efd29e1b463546519e488aa796b5ecf9dfc6f4f10d2128" + "server_amd64": "sha256:813c9337c44be7d497efd29e1b463546519e488aa796b5ecf9dfc6f4f10d2128", + "server_config": "sha256:71b39c210e16adae7643f1209386c968e757eb304cfc6d6878b4b8400c3e3da9", + "loss_proxy_source": "117118c83093822469b3824ba9587ff4068ed29f", + "loss_proxy_sha256": "bd1e8ccbbf76f63fc2eafa278a023eb181d405b68d9c30016392e395a1eff725" } diff --git a/tests/smoke/command-keys.md b/tests/smoke/command-keys.md index d8ed0e19..4efa8ad6 100644 --- a/tests/smoke/command-keys.md +++ b/tests/smoke/command-keys.md @@ -46,15 +46,41 @@ Set `COMMAND_KEY_RUNTIME_URL` to the owned numeric loopback URL and Results select admission/read identities and flags rather than arbitrary response fields, headers or payloads. A transport failure is an error with an unknown admission outcome; it is never described as a refused admission. -History output contains event counts only. +History output contains event counts and selected signal/update admission +identities. It excludes argument values and arbitrary event payloads. Use the accepted shared committed-response-loss fixture unchanged to prove response loss. Use the accepted portable SQLite helper and its adjacent exact Server manifest for synthetic legacy copies. The producer-owned physical seed and upgrade profile stay separate and must never pass through the synthetic -transform. This PHP adapter currently supplies modern direct/loss API calls; -installed execution, legacy/physical adapters and complete shared216 acceptance -require their own reviewed runner and sanitized receipts. +transform. The modern controller supplies explicit four-way retries, changed +intent/name/run conflicts, pending/null reads, closed/continued retained reads, +Unicode character limits and generated-key recovery after a fully consumed +upstream response is deliberately lost. It compares history admissions for +these cases. Synthetic legacy, physical upgrade, namespace isolation and full +database snapshot proofs remain separate work. No complete shared216 result +follows from the modern controller alone. + +`published-command-keys.yml` is a manual, main-only ordinary GitHub Actions +cell. It uses the qualified stock PHP helper, private Composer state and the +real Composer trust guard. Installation disables scripts, plugins and autoload +generation until archive/installed-file inspection passes. The runner captures +its exact committed source and the unchanged accepted shared loss fixture +before loading them. Source checks and independent review precede execution. + +The controller pulls the selected Server index for linux/amd64 and verifies +its image config/source. It creates one read-only, nonprivileged 1-CPU/1-GiB/ +256-PID runtime with private bounded tmpfs and one numeric loopback binding. +Every operation checks the captured container ID/owner/endpoint and installed +SDK bytes again. A private CID file supports cleanup on exceptions and the +workflow's separate always-run cleanup. Removal uses that ID after checking +its label; foreign replacements are preserved. A missing-object response, +rather than a general engine error, establishes absence. Sanitized evidence +and the dependency lock are retained for seven days. + +Run inert runner controls with +`python3 -I -S -B tests/ci/test_command_key_cohort.py`. They exercise synthetic +inspection metadata and receipts without creating a runtime or loading PHP. Run the offline input controls against an existing immutable PHP image: diff --git a/tests/smoke/command_key_cohort.py b/tests/smoke/command_key_cohort.py new file mode 100644 index 00000000..9fa275b7 --- /dev/null +++ b/tests/smoke/command_key_cohort.py @@ -0,0 +1,419 @@ +"""Modern installed PHP command-key cases on one already-owned runtime.""" + +import argparse +import concurrent.futures +from contextlib import contextmanager +import hashlib +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import stat +import subprocess +import threading +import time +import types +import urllib.request + + +HERE = Path(__file__).resolve().parent +INPUTS = ("command-key-client.php", "command-key-supply.json", "verify_command_key_supply.py", "command_key_cohort.py") + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def capture_inputs(commit): + require(re.fullmatch("[0-9a-f]{40}", commit or "") is not None, "An exact reviewed runner commit is required") + repository = HERE.parents[1] + result = {} + for name in INPUTS: + path = HERE / name + relative = path.relative_to(repository).as_posix() + original = subprocess.check_output(["git", "--no-replace-objects", "-C", str(repository), + "show", commit + ":" + relative], timeout=10) + require(path.read_bytes() == original, "Runner source differs from the selected commit") + result[name] = original + return result + + +def module_from_bytes(name, raw): + module = types.ModuleType(name) + module.__file__ = str(HERE / (name + ".py")) + exec(compile(raw, module.__file__, "exec"), module.__dict__) + return module + + +def inspect_runtime(identity, allow_missing=False): + process = subprocess.run(["docker", "inspect", identity], capture_output=True, text=True, timeout=15) + if (allow_missing and process.returncode != 0 + and "no such object: " + identity in process.stderr.lower()): + return None + require(process.returncode == 0, "The captured runtime cannot be inspected") + records = json.loads(process.stdout) + require(isinstance(records, list) and len(records) == 1, "The captured runtime is ambiguous") + return records[0] + + +def runtime_origin(identity, owner, supply): + require(re.fullmatch("[0-9a-f]{64}", identity) is not None, "A captured full runtime ID is required") + require(re.fullmatch("[0-9]+-[0-9]+", owner) is not None, "The workflow run owner is required") + record = inspect_runtime(identity) + require(record["Id"] == identity and record["State"]["Running"], "The captured runtime is not running") + require(record["Config"]["Labels"].get("durable-workflow.php-command-keys") == owner + and record["Config"]["Labels"].get("org.opencontainers.image.revision") == supply["server_source"], + "The runtime belongs to another run") + require(record["Image"] == supply["server_config"], "The runtime image config differs") + host = record["HostConfig"] + require(host["ReadonlyRootfs"] and not host["Privileged"] and not host.get("Binds") + and host["Init"] and host["CapDrop"] == ["ALL"] and not host.get("CapAdd") + and "no-new-privileges" in host["SecurityOpt"] and not host.get("Devices") + and host["NetworkMode"] == "bridge", + "The runtime filesystem is outside the owned fixture profile") + require(0 < host["Memory"] <= 1024**3 and 0 < host["NanoCpus"] <= 10**9 + and 0 < host["PidsLimit"] <= 256, "The runtime resource caps differ") + require(all(mount["Type"] == "tmpfs" for mount in record["Mounts"]), "Only private tmpfs mounts are allowed") + require(set(host["Tmpfs"]) == {"/tmp", "/app/database", "/app/storage"}, "Unexpected runtime writable paths") + require(set(record["NetworkSettings"]["Ports"]) == {"8080/tcp"}, "Unexpected published runtime ports") + ports = record["NetworkSettings"]["Ports"].get("8080/tcp") + require(isinstance(ports, list) and len(ports) == 1 and ports[0]["HostIp"] == "127.0.0.1", + "The runtime must have one numeric loopback binding") + port = ports[0]["HostPort"] + require(re.fullmatch("[1-9][0-9]{0,4}", port) is not None and int(port) <= 65535, + "The runtime port is invalid") + return "http://127.0.0.1:" + port + + +def remove_runtime(identity, owner): + record = inspect_runtime(identity, allow_missing=True) + if record is None: + return + require(record["Id"] == identity + and record["Config"]["Labels"].get("durable-workflow.php-command-keys") == owner, + "Runtime ownership changed; preserve the replacement") + subprocess.run(["docker", "rm", "--force", "--volumes", identity], check=True, + stdout=subprocess.DEVNULL, timeout=30) + missing = subprocess.run(["docker", "inspect", identity], capture_output=True, text=True, timeout=15) + require(missing.returncode != 0 and "no such object: " + identity in missing.stderr.lower(), + "Exact runtime absence was not established") + + +def private_state(directory): + metadata = directory.lstat() + require(directory.resolve() == directory and stat.S_ISDIR(metadata.st_mode) + and metadata.st_uid == os.getuid() and stat.S_IMODE(metadata.st_mode) == 0o700, + "Runtime state must be a canonical private owned directory") + return directory / "runtime.cid" + + +def cleanup_state(directory, owner): + cidfile = private_state(directory) + if not cidfile.exists() and not cidfile.is_symlink(): + return + metadata = cidfile.lstat() + require(stat.S_ISREG(metadata.st_mode) and metadata.st_uid == os.getuid() + and metadata.st_nlink == 1 and metadata.st_size == 64, + "Runtime cleanup needs the captured single-link CID file") + identity = cidfile.read_text() + require(re.fullmatch("[0-9a-f]{64}", identity) is not None, "Invalid cleanup runtime ID") + remove_runtime(identity, owner) + + +@contextmanager +def owned_runtime(owner, supply, token, directory): + require(re.fullmatch("[0-9]+-[0-9]+", owner) is not None, "The workflow run owner is required") + image = "durableworkflow/server@" + supply["server_index"] + subprocess.run(["docker", "pull", "--platform", "linux/amd64", image], check=True, + stdout=subprocess.DEVNULL, timeout=180) + raw = subprocess.check_output(["docker", "image", "inspect", image], timeout=15) + selected = json.loads(raw)[0] + require(selected["Id"] == supply["server_config"] and selected["Os"] == "linux" + and selected["Architecture"] == "amd64" + and selected["Config"]["Labels"].get("org.opencontainers.image.revision") == supply["server_source"], + "The pulled immutable runtime identity differs") + cidfile = private_state(directory) + require(not list(directory.iterdir()), "Runtime state must start empty") + try: + subprocess.run(["docker", "create", "--cidfile", str(cidfile), "--init", "--read-only", + "--name", "sdk-php-command-keys-" + owner + "-" + secrets.token_hex(4), + "--label", "durable-workflow.php-command-keys=" + owner, + "--cpus", "1", "--memory", "1g", "--pids-limit", "256", "--network", "bridge", + "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--tmpfs", "/tmp:mode=1777,size=64m", + "--tmpfs", "/app/storage:uid=1000,gid=1000,mode=0700,size=64m", + "--tmpfs", "/app/database:uid=1000,gid=1000,mode=0700,size=32m", + "--publish", "127.0.0.1::8080", "--env", "DB_CONNECTION=sqlite", + "--env", "DW_AUTH_DRIVER=token", "--env", "DW_AUTH_TOKEN=" + token, image], + check=True, stdout=subprocess.DEVNULL, timeout=30) + identity = cidfile.read_text() + require(re.fullmatch("[0-9a-f]{64}", identity) is not None, "Docker did not capture a full runtime ID") + created = inspect_runtime(identity) + require(created["Id"] == identity + and created["Config"]["Labels"].get("durable-workflow.php-command-keys") == owner, + "The created runtime identity differs") + subprocess.run(["docker", "start", identity], check=True, stdout=subprocess.DEVNULL, timeout=30) + origin = runtime_origin(identity, owner, supply) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + for _ in range(30): + try: + with opener.open(origin + "/api/ready", timeout=2) as response: + if response.status == 200: + break + except OSError: + pass + time.sleep(1) + else: + raise RuntimeError("The selected runtime did not become ready") + yield identity, origin + finally: + # The workflow repeats this cleanup if its outer timeout interrupts us. + cleanup_state(directory, owner) + + +class Consumer: + def __init__(self, consumer, archive, supply, sources, verifier, origin, token, runtime_id, owner): + self.consumer, self.archive, self.supply = consumer, archive, supply + self.sources, self.verifier, self.origin, self.token = sources, verifier, origin, token + self.runtime_id, self.owner = runtime_id, owner + self.php = shutil.which("php") + require(self.php is not None, "The qualified PHP executable is required") + require(isinstance(token, str) and token.startswith("sdk-php-216-"), "A synthetic fixture token is required") + self.package = verifier.verify(consumer, archive, supply) + + def call(self, packet, origin=None): + require(runtime_origin(self.runtime_id, self.owner, self.supply) == self.origin, + "The captured runtime endpoint changed") + for name, raw in self.sources.items(): + require((HERE / name).read_bytes() == raw, "The captured runner source changed") + require(self.verifier.verify(self.consumer, self.archive, self.supply) == self.package, + "The installed package changed before invocation") + environment = {"PATH": "/usr/bin:/bin", "COMMAND_KEY_RUNTIME_URL": origin or self.origin, + "COMMAND_KEY_TOKEN": self.token} + process = subprocess.run([self.php, str(HERE / "command-key-client.php"), + str(self.consumer / "vendor/autoload.php")], input=json.dumps(packet), + text=True, capture_output=True, env=environment, timeout=20) + require(process.returncode == 0, "The PHP fixture operation failed") + require(0 < len(process.stdout) <= 1024**2, "The PHP fixture evidence is empty or oversized") + result = json.loads(process.stdout) + require(result.get("sdk_version") == self.supply["sdk_version"] + and result.get("sdk_reference") == self.supply["sdk_reference"] + and result.get("server_version") == self.supply["server_version"] + and result.get("source_fallback") is False, "The API operation used another supply") + return result + + def answered(self, packet, origin=None): + response = self.call(packet, origin) + require(response.get("outcome") == "answered", "The API operation was not answered") + return response["result"] + + +def identity(receipt, kind): + fields = ["command_id", "run_id", "request_id"] + if kind == "update": + fields += ["update_id", "accepted_at"] + require(all(isinstance(receipt.get(field), str) and receipt[field] for field in fields), + "The admission identity is incomplete") + require(type(receipt.get("command_sequence")) is int and receipt["command_sequence"] > 0, + "The command sequence is missing") + if kind == "signal": + require(isinstance(receipt.get("signal_id"), str) and receipt["signal_id"], "The signal identity is missing") + fields += ["signal_id"] + return {field: receipt[field] for field in fields + ["command_sequence"]} + + +def retained(receipt, original, kind): + require(identity(receipt, kind) == identity(original, kind), "A retry changed the original admission identity") + require(receipt.get("retained") is True and receipt.get("intent_verified") is True, + "The retry did not retain verified intent") + + +def four_retries(client, packet, original, kind, origin=None): + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + replies = list(pool.map(lambda _: client.answered(packet, origin), range(4))) + for reply in replies: + retained(reply, original, kind) + + +def conflict(client, packet, original): + response = client.call(packet) + require(response.get("outcome") == "error" and response.get("status") == 409 + and response.get("is_request_id_conflict") is True + and response.get("retained_command_id") == original["command_id"] + and response.get("transient_connection_failure") is False + and response.get("transient_upstream_failure") is False, "The changed intent was not a typed conflict") + + +def bootstrap(client, mode, label): + workflow = "sdk-php-216-" + label + "-" + secrets.token_hex(5) + queue = workflow + "-queue" + client.answered({"operation": "bootstrap", "workflow_id": workflow, "task_queue": queue, "mode": mode}) + description = client.answered({"operation": "describe", "workflow_id": workflow}) + require(isinstance(description.get("run_id"), str) and description["run_id"], "The fixture has no original run") + return workflow, queue, description["run_id"] + + +def history(client, workflow, run): + return client.answered({"operation": "history", "workflow_id": workflow, "run_id": run}) + + +def settle(client, workflow, queue, original_run, mode): + client.answered({"operation": "signal", "workflow_id": workflow, "name": "release", + "request_id": "release", "arguments": []}) + for _ in range(6): + client.answered({"operation": "drive", "workflow_id": workflow, "task_queue": queue}) + description = client.answered({"operation": "describe", "workflow_id": workflow}) + if mode == "close" and description.get("status") == "completed": + return description + if mode == "continue" and description.get("run_id") != original_run: + return description + raise RuntimeError("The fixture did not complete its declared transition") + + +def direct_cell(client, mode): + workflow, queue, run = bootstrap(client, mode, mode) + admissions = {} + for kind in ("signal", "update"): + packet = {"operation": kind, "workflow_id": workflow, "name": "signal", "arguments": [1], + "request_id": "explicit-" + kind} + original = client.answered(packet) + identity(original, kind) + require(original["run_id"] == run and original["request_id"] == packet["request_id"] + and original.get("retained") is False and original.get("intent_verified") is True, + "The fresh admission identity or intent marker differs") + four_retries(client, packet, original, kind) + before = history(client, workflow, run) + conflict(client, packet | {"arguments": [True]}, original) + conflict(client, packet | {"name": "other"}, original) + conflict(client, packet | {"run_id": "00000000000000000000000000"}, original) + require(history(client, workflow, run) == before, "A conflict changed durable history") + admissions[kind] = (packet, original) + pending = client.call({"operation": "value", "workflow_id": workflow, + "update_id": admissions["update"][1]["update_id"]}) + require(pending.get("outcome") == "pending", "The queued update did not preserve typed pending") + if mode != "open": + settled = settle(client, workflow, queue, run, mode) + for kind, (packet, original) in admissions.items(): + four_retries(client, packet, original, kind) + value = client.answered({"operation": "value", "workflow_id": workflow, + "update_id": admissions["update"][1]["update_id"]}) + require(value.get("completed_null") is True, "The completed update did not preserve null") + if mode == "continue": + require(settled["run_id"] != run, "The successor reused the old run") + for packet, original in admissions.values(): + conflict(client, packet | {"run_id": settled["run_id"]}, original) + counts = history(client, workflow, run)["event_counts"] + require(counts.get("UpdateAccepted") == 1 and counts.get("SignalReceived") == (1 if mode == "open" else 2), + "Retries admitted an extra durable command") + return {"mode": mode, "four_way_retries": True, "typed_conflicts": True, + "retained_after_transition": mode != "open", "typed_pending": True, + "completed_null": mode != "open", "event_counts": counts, + "workflow_id": workflow, "original_run_id": run, + "admissions": {kind: identity(receipt, kind) for kind, (_, receipt) in admissions.items()}} + + +def unicode_cell(client): + workflow, _, run = bootstrap(client, "open", "unicode") + for kind in ("signal", "update"): + packet = {"operation": kind, "workflow_id": workflow, "name": "signal", "arguments": [1], "request_id": "é" * 255} + original = client.answered(packet) + require(original.get("request_id") == packet["request_id"], "The Unicode key changed") + four_retries(client, packet, original, kind) + before = history(client, workflow, run) + refused = client.call(packet | {"request_id": "é" * 256}) + require(refused.get("outcome") == "local_invalid_argument", "The oversized character key was submitted") + require(history(client, workflow, run) == before, "The local refusal changed history") + return {"unicode_255_characters": True, "unicode_256_local_refusal": True} + + +def loss_cell(client, module, kind): + workflow, _, run = bootstrap(client, "open", "loss-" + kind) + proxy = module.CommittedResponseLoss(client.origin) + thread = threading.Thread(target=proxy.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True) + thread.start() + path = "/api/workflows/" + workflow + "/" + kind + "/signal" + packet = {"operation": kind, "workflow_id": workflow, "name": "signal", "arguments": [1]} + try: + proxy.arm("POST", path) + response = client.call(packet, proxy.url) + if response.get("outcome") == "error": + require(response.get("transient_connection_failure") is True, "The lost reply was not a typed transport error") + key = response.get("request_id") + else: + require(response.get("outcome") == "answered", "The lost reply has no recoverable identity") + key = response["result"].get("request_id") + require(isinstance(key, str) and re.fullmatch("sdk-php-" + kind + "-[0-9a-f]{32}", key), + "The generated key was not recoverable") + first = history(client, workflow, run) + event_type = "SignalReceived" if kind == "signal" else "UpdateAccepted" + original = [event for event in first["admissions"] if event["event_type"] == event_type] + require(len(original) == 1 and isinstance(original[0].get("command_id"), str), + "The committed original admission identity is unavailable") + packet["request_id"] = key + recovered = client.answered(packet, proxy.url) + require(recovered["command_id"] == original[0]["command_id"] and recovered["run_id"] == run, + "Recovery did not return the committed original command") + require(recovered.get("retained") is True and recovered.get("intent_verified") is True + and recovered.get("request_id") == key, "Recovery did not retain the generated key and intent") + four_retries(client, packet, recovered, kind, proxy.url) + require(history(client, workflow, run) == first, "Response-loss retries changed durable history") + records = [record for record in proxy.records if record["method"] == "POST" and record["path"] == path] + require(6 <= len(records) <= 9 and sum(record["dropped"] for record in records) == 1, + "The response-loss observation is incomplete") + require(all(record["upstream_complete"] and 200 <= record["upstream_status"] < 300 for record in records), + "An upstream response was not completely consumed") + require(len({record["request_body_sha256"] for record in records}) == 1, "A retry changed the generated key or command body") + return {"command_type": kind, "committed_before_loss": True, "original_identity_retained": True, + "four_way_retries": True, "no_second_admission": True, "requests": records, + "workflow_id": workflow, "original_run_id": run, "recovered": identity(recovered, kind)} + finally: + proxy.shutdown() + proxy.server_close() + thread.join(timeout=2) + require(not thread.is_alive(), "The response-loss fixture did not stop") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--consumer", type=Path) + parser.add_argument("--archive", type=Path) + parser.add_argument("--proxy-source", type=Path) + parser.add_argument("--runtime-state", type=Path, required=True) + parser.add_argument("--runtime-owner", required=True) + parser.add_argument("--runner-source", required=True) + parser.add_argument("--cleanup-only", action="store_true") + options = parser.parse_args() + sources = capture_inputs(options.runner_source) + require(re.fullmatch("[0-9]+-[0-9]+", options.runtime_owner) is not None, "Invalid runtime owner") + if options.cleanup_only: + cleanup_state(options.runtime_state, options.runtime_owner) + print(json.dumps({"runtime_removed": True})) + return + require(options.consumer is not None and options.archive is not None and options.proxy_source is not None, + "The registry consumer, archive and proxy source are required") + supply = json.loads(sources["command-key-supply.json"]) + proxy_raw = options.proxy_source.read_bytes() + require(hashlib.sha256(proxy_raw).hexdigest() == supply["loss_proxy_sha256"], "The shared loss fixture source differs") + verifier = module_from_bytes("verify_command_key_supply", sources["verify_command_key_supply.py"]) + verifier.verify(options.consumer, options.archive, supply) + proxy = module_from_bytes("committed_response_loss", proxy_raw) + token = "sdk-php-216-" + secrets.token_hex(24) + with owned_runtime(options.runtime_owner, supply, token, options.runtime_state) as (runtime_id, origin): + client = Consumer(options.consumer, options.archive, supply, sources, verifier, origin, + token, runtime_id, options.runtime_owner) + direct = [direct_cell(client, mode) for mode in ("open", "close", "continue")] + unicode = unicode_cell(client) + loss = [loss_cell(client, proxy, kind) for kind in ("signal", "update")] + print(json.dumps({"outcome": "pass", "scope": "modern-direct-and-postcommit-loss-only", + "runner_source": options.runner_source, + "runner_inputs": {name: hashlib.sha256(raw).hexdigest() for name, raw in sources.items()}, + "package": client.package, "server_index": supply["server_index"], + "server_platform": "linux/amd64", "server_config": supply["server_config"], + "direct": direct, "unicode": unicode, "loss": loss, "runtime_removed": True})) + + +if __name__ == "__main__": + main() From 242f155e8a24974f885b8ec74f63a760332e7fae Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:10:07 +0000 Subject: [PATCH 04/17] Require exact Docker absence receipts and an empty private configuration --- .github/workflows/published-command-keys.yml | 4 +- tests/ci/test_command_key_cohort.py | 32 +++++++++++++++- tests/smoke/command_key_cohort.py | 40 +++++++++++++++----- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/.github/workflows/published-command-keys.yml b/.github/workflows/published-command-keys.yml index 192a96b6..a13b7fd4 100644 --- a/.github/workflows/published-command-keys.yml +++ b/.github/workflows/published-command-keys.yml @@ -28,7 +28,7 @@ jobs: run: | umask 077 state="$(mktemp -d "$RUNNER_TEMP/php-command-keys.XXXXXX")" - mkdir -m 700 "$state/home" "$state/cache" "$state/tmp" "$state/consumer" "$state/runtime" + mkdir -m 700 "$state/home" "$state/cache" "$state/tmp" "$state/consumer" "$state/runtime" "$state/docker" { echo "COMMAND_KEY_STATE=$state" echo "CONSUMER=$state/consumer" @@ -83,6 +83,7 @@ jobs: --consumer "$CONSUMER" --archive "$COMMAND_KEY_STATE/sdk.zip" \ --proxy-source "$COMMAND_KEY_STATE/committed_response_loss.py" \ --runtime-state "$COMMAND_KEY_STATE/runtime" \ + --docker-config "$COMMAND_KEY_STATE/docker" \ --runtime-owner "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --runner-source "$GITHUB_SHA" \ | tee command-key-modern.json - name: Independently repeat captured-ID runtime cleanup @@ -90,6 +91,7 @@ jobs: run: | python3 -I -S -B tests/smoke/command_key_cohort.py \ --runtime-state "$COMMAND_KEY_STATE/runtime" \ + --docker-config "$COMMAND_KEY_STATE/docker" \ --runtime-owner "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --runner-source "$GITHUB_SHA" \ --cleanup-only | tee command-key-cleanup.json - name: Retain sanitized observations and installed dependency graph diff --git a/tests/ci/test_command_key_cohort.py b/tests/ci/test_command_key_cohort.py index b6f3f186..d47e1baf 100644 --- a/tests/ci/test_command_key_cohort.py +++ b/tests/ci/test_command_key_cohort.py @@ -31,6 +31,13 @@ def runtime(): class ProvenanceControls(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + configuration = patch.object(MODULE, "DOCKER_CONFIG", Path(directory.name)) + configuration.start() + self.addCleanup(configuration.stop) + def origin(self, record): with patch.object(MODULE, "inspect_runtime", return_value=record): return MODULE.runtime_origin(IDENTITY, "123-1", SUPPLY) @@ -79,7 +86,8 @@ def test_cleanup_uses_only_captured_id_and_requires_missing_object(self): def execute(arguments, **_): calls.append(arguments) - return type("Result", (), {"returncode": 1, "stderr": "Error: No such object: " + IDENTITY})() + return type("Result", (), {"returncode": 1, "stdout": "[]\n", + "stderr": "Error: No such object: " + IDENTITY + "\n"})() with patch.object(MODULE, "inspect_runtime", return_value=runtime()), \ patch.object(MODULE.subprocess, "run", side_effect=execute): @@ -91,6 +99,28 @@ def test_inspection_error_category_is_not_an_absence_receipt(self): with patch.object(MODULE.subprocess, "run", return_value=result), self.assertRaises(RuntimeError): MODULE.inspect_runtime(IDENTITY, allow_missing=True) + def test_missing_object_requires_exact_exit_stdout_and_stderr(self): + expected = {"returncode": 1, "stdout": "[]\n", "stderr": "Error: No such object: " + IDENTITY + "\n"} + self.assertTrue(MODULE.exact_missing(type("Result", (), expected)(), IDENTITY)) + for changed in ({"returncode": 125}, {"stdout": ""}, {"stdout": "[{}]\n"}, + {"stderr": expected["stderr"] + "permission denied\n"}, + {"stderr": "daemon error\n" + expected["stderr"]}): + with self.subTest(changed=changed): + process = type("Result", (), expected | changed)() + self.assertFalse(MODULE.exact_missing(process, IDENTITY)) + with patch.object(MODULE.subprocess, "run", return_value=process), self.assertRaises(RuntimeError): + MODULE.inspect_runtime(IDENTITY, allow_missing=True) + + def test_docker_environment_excludes_ambient_credentials_and_remote_context(self): + with patch.dict(os.environ, {"DOCKER_AUTH_CONFIG": "untrusted", "DOCKER_CONTEXT": "foreign", + "DOCKER_HOST": "tcp://remote:2375"}): + environment = MODULE.docker_environment() + self.assertEqual(environment, {"PATH": "/usr/bin:/bin", "DOCKER_CONFIG": str(MODULE.DOCKER_CONFIG), + "DOCKER_HOST": "unix:///var/run/docker.sock"}) + (MODULE.DOCKER_CONFIG / "config.json").write_text('{}') + with self.assertRaisesRegex(RuntimeError, "remain empty"): + MODULE.docker_environment() + def test_only_owned_private_runtime_state_and_regular_cid_are_accepted(self): with tempfile.TemporaryDirectory() as temporary: state = Path(temporary) diff --git a/tests/smoke/command_key_cohort.py b/tests/smoke/command_key_cohort.py index 9fa275b7..3265d3f3 100644 --- a/tests/smoke/command_key_cohort.py +++ b/tests/smoke/command_key_cohort.py @@ -20,6 +20,7 @@ HERE = Path(__file__).resolve().parent INPUTS = ("command-key-client.php", "command-key-supply.json", "verify_command_key_supply.py", "command_key_cohort.py") +DOCKER_CONFIG = None def require(condition, message): @@ -48,10 +49,23 @@ def module_from_bytes(name, raw): return module +def docker_environment(): + require(DOCKER_CONFIG is not None, "An explicit empty Docker configuration is required") + private_state(DOCKER_CONFIG) + require(not list(DOCKER_CONFIG.iterdir()), "Docker configuration must remain empty") + return {"PATH": "/usr/bin:/bin", "DOCKER_CONFIG": str(DOCKER_CONFIG), + "DOCKER_HOST": "unix:///var/run/docker.sock"} + + +def exact_missing(process, identity): + return (process.returncode == 1 and process.stdout == "[]\n" + and process.stderr == "Error: No such object: " + identity + "\n") + + def inspect_runtime(identity, allow_missing=False): - process = subprocess.run(["docker", "inspect", identity], capture_output=True, text=True, timeout=15) - if (allow_missing and process.returncode != 0 - and "no such object: " + identity in process.stderr.lower()): + process = subprocess.run(["docker", "inspect", identity], capture_output=True, text=True, timeout=15, + env=docker_environment()) + if allow_missing and exact_missing(process, identity): return None require(process.returncode == 0, "The captured runtime cannot be inspected") records = json.loads(process.stdout) @@ -96,9 +110,10 @@ def remove_runtime(identity, owner): and record["Config"]["Labels"].get("durable-workflow.php-command-keys") == owner, "Runtime ownership changed; preserve the replacement") subprocess.run(["docker", "rm", "--force", "--volumes", identity], check=True, - stdout=subprocess.DEVNULL, timeout=30) - missing = subprocess.run(["docker", "inspect", identity], capture_output=True, text=True, timeout=15) - require(missing.returncode != 0 and "no such object: " + identity in missing.stderr.lower(), + stdout=subprocess.DEVNULL, timeout=30, env=docker_environment()) + missing = subprocess.run(["docker", "inspect", identity], capture_output=True, text=True, timeout=15, + env=docker_environment()) + require(exact_missing(missing, identity), "Exact runtime absence was not established") @@ -128,8 +143,8 @@ def owned_runtime(owner, supply, token, directory): require(re.fullmatch("[0-9]+-[0-9]+", owner) is not None, "The workflow run owner is required") image = "durableworkflow/server@" + supply["server_index"] subprocess.run(["docker", "pull", "--platform", "linux/amd64", image], check=True, - stdout=subprocess.DEVNULL, timeout=180) - raw = subprocess.check_output(["docker", "image", "inspect", image], timeout=15) + stdout=subprocess.DEVNULL, timeout=180, env=docker_environment()) + raw = subprocess.check_output(["docker", "image", "inspect", image], timeout=15, env=docker_environment()) selected = json.loads(raw)[0] require(selected["Id"] == supply["server_config"] and selected["Os"] == "linux" and selected["Architecture"] == "amd64" @@ -148,14 +163,15 @@ def owned_runtime(owner, supply, token, directory): "--tmpfs", "/app/database:uid=1000,gid=1000,mode=0700,size=32m", "--publish", "127.0.0.1::8080", "--env", "DB_CONNECTION=sqlite", "--env", "DW_AUTH_DRIVER=token", "--env", "DW_AUTH_TOKEN=" + token, image], - check=True, stdout=subprocess.DEVNULL, timeout=30) + check=True, stdout=subprocess.DEVNULL, timeout=30, env=docker_environment()) identity = cidfile.read_text() require(re.fullmatch("[0-9a-f]{64}", identity) is not None, "Docker did not capture a full runtime ID") created = inspect_runtime(identity) require(created["Id"] == identity and created["Config"]["Labels"].get("durable-workflow.php-command-keys") == owner, "The created runtime identity differs") - subprocess.run(["docker", "start", identity], check=True, stdout=subprocess.DEVNULL, timeout=30) + subprocess.run(["docker", "start", identity], check=True, stdout=subprocess.DEVNULL, timeout=30, + env=docker_environment()) origin = runtime_origin(identity, owner, supply) opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) for _ in range(30): @@ -377,15 +393,19 @@ def loss_cell(client, module, kind): def main(): + global DOCKER_CONFIG parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--consumer", type=Path) parser.add_argument("--archive", type=Path) parser.add_argument("--proxy-source", type=Path) parser.add_argument("--runtime-state", type=Path, required=True) + parser.add_argument("--docker-config", type=Path, required=True) parser.add_argument("--runtime-owner", required=True) parser.add_argument("--runner-source", required=True) parser.add_argument("--cleanup-only", action="store_true") options = parser.parse_args() + DOCKER_CONFIG = options.docker_config + docker_environment() sources = capture_inputs(options.runner_source) require(re.fullmatch("[0-9]+-[0-9]+", options.runtime_owner) is not None, "Invalid runtime owner") if options.cleanup_only: From 4bcb415932b2e29d7a45a5f3cd4b207298cc0ae8 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:12:50 +0000 Subject: [PATCH 05/17] Launch captured controller bytes through a digest-bound Git bootstrap --- .github/workflows/published-command-keys.yml | 13 +++++++++- tests/ci/test_command_key_cohort.py | 26 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/published-command-keys.yml b/.github/workflows/published-command-keys.yml index a13b7fd4..0b1be7f5 100644 --- a/.github/workflows/published-command-keys.yml +++ b/.github/workflows/published-command-keys.yml @@ -79,7 +79,18 @@ jobs: - name: Qualify modern retained commands and postcommit response loss run: | php -l tests/smoke/command-key-client.php - timeout --signal=INT --kill-after=30s 12m python3 -I -S -B tests/smoke/command_key_cohort.py \ + timeout --signal=INT --kill-after=30s 12m python3 -I -S -B -c ' + import hashlib,os,re,subprocess + from pathlib import Path + source=os.environ["GITHUB_SHA"] + if re.fullmatch("[0-9a-f]{40}",source) is None: + raise RuntimeError("An exact trusted runner source is required") + path="tests/smoke/command_key_cohort.py" + raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) + if hashlib.sha256(raw).hexdigest() != "7e2fa936fb45238411ac0d7c986cfc983898ceeeb2065d796cdf0a0ffa8059a0": + raise RuntimeError("The captured controller differs from its independently fixed digest") + exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) + ' \ --consumer "$CONSUMER" --archive "$COMMAND_KEY_STATE/sdk.zip" \ --proxy-source "$COMMAND_KEY_STATE/committed_response_loss.py" \ --runtime-state "$COMMAND_KEY_STATE/runtime" \ diff --git a/tests/ci/test_command_key_cohort.py b/tests/ci/test_command_key_cohort.py index d47e1baf..429eaf73 100644 --- a/tests/ci/test_command_key_cohort.py +++ b/tests/ci/test_command_key_cohort.py @@ -6,6 +6,7 @@ import os from pathlib import Path import tempfile +import textwrap import unittest from unittest.mock import patch @@ -121,6 +122,31 @@ def test_docker_environment_excludes_ambient_credentials_and_remote_context(self with self.assertRaisesRegex(RuntimeError, "remain empty"): MODULE.docker_environment() + def controller_bootstrap(self): + workflow = (SOURCE.parents[2] / ".github/workflows/published-command-keys.yml").read_text() + literal = workflow.split("python3 -I -S -B -c '\n", 1)[1].split("\n ' \\", 1)[0] + return compile(textwrap.dedent(literal), "literal-controller-bootstrap", "exec") + + def test_literal_bootstrap_loads_only_the_digest_bound_git_blob(self): + bootstrap = self.controller_bootstrap() + with patch.dict(os.environ, {"GITHUB_SHA": "a" * 40}), \ + patch.object(MODULE.subprocess, "check_output", return_value=SOURCE.read_bytes()) as read, \ + patch("sys.argv", ["controller", "--help"]), patch("sys.stdout"), \ + self.assertRaises(SystemExit) as result: + exec(bootstrap, {}) + self.assertEqual(result.exception.code, 0) + self.assertEqual(read.call_args.args[0], ["/usr/bin/git", "--no-replace-objects", "show", + "a" * 40 + ":tests/smoke/command_key_cohort.py"]) + + def test_literal_bootstrap_rejects_other_bytes_before_compile(self): + bootstrap = self.controller_bootstrap() + with patch.dict(os.environ, {"GITHUB_SHA": "a" * 40}), \ + patch.object(MODULE.subprocess, "check_output", return_value=b"print('inert marker')"), \ + patch("builtins.compile") as compile_source, \ + self.assertRaisesRegex(RuntimeError, "independently fixed digest"): + exec(bootstrap, {}) + compile_source.assert_not_called() + def test_only_owned_private_runtime_state_and_regular_cid_are_accepted(self): with tempfile.TemporaryDirectory() as temporary: state = Path(temporary) From a38a0fed5d8b7d1200a787d7d0d9a2072a8821ed Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:56:38 +0000 Subject: [PATCH 06/17] Prepare the complete data-only PHP include closure inventory --- .github/workflows/ci.yml | 1 + tests/ci/test_command_key_include_closure.py | 99 +++++++++++++++ tests/smoke/command-key-supply.json | 4 + tests/smoke/command-keys.md | 12 ++ tests/smoke/command_key_include_closure.py | 120 +++++++++++++++++++ 5 files changed, 236 insertions(+) create mode 100644 tests/ci/test_command_key_include_closure.py create mode 100644 tests/smoke/command_key_include_closure.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 386239f4..8e7cbc35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,7 @@ jobs: run: | python3 -I -S -B tests/ci/test_command_key_supply.py python3 -I -S -B tests/ci/test_command_key_cohort.py + python3 -I -S -B tests/ci/test_command_key_include_closure.py test: name: PHP ${{ matrix.php }} diff --git a/tests/ci/test_command_key_include_closure.py b/tests/ci/test_command_key_include_closure.py new file mode 100644 index 00000000..b41ec0ec --- /dev/null +++ b/tests/ci/test_command_key_include_closure.py @@ -0,0 +1,99 @@ +"""Inert include-tree inventory controls; no package code or runtime loading.""" + +import importlib.util +import os +from pathlib import Path +import tempfile +import unittest + + +SOURCE = Path(__file__).resolve().parents[1] / "smoke/command_key_include_closure.py" +SPEC = importlib.util.spec_from_file_location("command_key_include_closure", SOURCE) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class IncludeClosureControls(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.consumer, self.fixture, self.output = [self.root / name for name in ("consumer", "fixture", "output")] + for path in (self.consumer, self.fixture, self.output): + path.mkdir(mode=0o700) + (self.consumer / "composer.json").write_text('{}') + (self.consumer / "composer.lock").write_text('{}') + (self.consumer / "vendor/composer").mkdir(parents=True) + (self.consumer / "vendor/autoload.php").write_text('inert autoload bytes') + (self.consumer / "vendor/composer/installed.json").write_text('{}') + (self.consumer / "vendor/composer/nested.php").write_text('inert nested include bytes') + (self.fixture / "command-key-client.php").write_text('inert fixture bytes') + (self.fixture / "command-key-supply.json").write_text('{}') + self.addCleanup(self.unseal) + + def unseal(self): + for path in [self.root, *self.root.rglob("*")]: + if path.is_dir() and not path.is_symlink(): + path.chmod(0o700) + + def stage(self): + return MODULE.stage(self.consumer, self.fixture, self.output) + + def test_complete_nested_include_inventory_and_readonly_copy(self): + result = self.stage() + self.assertEqual(MODULE.inventory(self.output, os.getuid(), sealed=True), result) + member = result["entries"]["consumer/vendor/composer/nested.php"] + self.assertEqual(member["bytes"], len(b'inert nested include bytes')) + self.assertFalse(member["mode"] & 0o222) + self.assertIn("fixture/command-key-client.php", result["entries"]) + + def test_alias_and_hardlink_leaves_refuse(self): + target = self.consumer / "vendor/composer/alias.php" + original = self.consumer / "vendor/autoload.php" + target.symlink_to(original) + with self.assertRaises(ValueError): + self.stage() + target.unlink() + os.link(original, target) + with self.assertRaises(ValueError): + self.stage() + + def test_extra_root_member_refuses(self): + (self.consumer / "unreviewed.php").write_text('inert') + with self.assertRaisesRegex(ValueError, "Unexpected consumer"): + self.stage() + + def test_extra_fixture_and_missing_autoloader_refuse(self): + extra = self.fixture / "unreviewed.php" + extra.write_text('inert') + with self.assertRaisesRegex(ValueError, "Unexpected captured fixture"): + self.stage() + extra.unlink() + (self.consumer / "vendor/autoload.php").unlink() + with self.assertRaisesRegex(ValueError, "complete installed autoloader"): + self.stage() + + def test_repository_metadata_inside_a_dependency_refuses(self): + (self.consumer / "vendor/composer/.git").write_text('inert metadata') + with self.assertRaisesRegex(ValueError, "Repository metadata"): + self.stage() + + def test_nested_byte_change_changes_complete_inventory(self): + before = MODULE.inventory(self.consumer, os.getuid(), sealed=False) + (self.consumer / "vendor/composer/nested.php").write_text('changed inert bytes') + after = MODULE.inventory(self.consumer, os.getuid(), sealed=False) + self.assertNotEqual(before["sha256"], after["sha256"]) + + def test_writable_sealed_member_and_wrong_owner_refuse(self): + self.stage() + member = self.output / "consumer/vendor/autoload.php" + member.chmod(0o600) + with self.assertRaisesRegex(ValueError, "write permissions"): + MODULE.inventory(self.output, os.getuid(), sealed=True) + member.chmod(0o400) + with self.assertRaisesRegex(ValueError, "ownership"): + MODULE.inventory(self.output, os.getuid() + 1, sealed=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/smoke/command-key-supply.json b/tests/smoke/command-key-supply.json index 5d73b146..11c78627 100644 --- a/tests/smoke/command-key-supply.json +++ b/tests/smoke/command-key-supply.json @@ -9,6 +9,10 @@ "server_index": "sha256:8ffcdb2a36af3129a1987849aafe8715fe0d3bf9a9648467f0b5cb8eeef43ba9", "server_amd64": "sha256:813c9337c44be7d497efd29e1b463546519e488aa796b5ecf9dfc6f4f10d2128", "server_config": "sha256:71b39c210e16adae7643f1209386c968e757eb304cfc6d6878b4b8400c3e3da9", + "php_version": "8.3.36", + "php_index": "sha256:232331112d3a7c5b98edeeddc3b85f610172f99352c0635fe6b3e166e47062cb", + "php_amd64": "sha256:b2d2be78de4de803e20d1ae3df91c196e332a40cc851754472be4fab0fac1f03", + "php_config": "sha256:6d664d15c9c1ad38a202a9b91736126c7c804d7669c3b7bfddd7ff6f7fdadba5", "loss_proxy_source": "117118c83093822469b3824ba9587ff4068ed29f", "loss_proxy_sha256": "bd1e8ccbbf76f63fc2eafa278a023eb181d405b68d9c30016392e395a1eff725" } diff --git a/tests/smoke/command-keys.md b/tests/smoke/command-keys.md index 4efa8ad6..fd00df32 100644 --- a/tests/smoke/command-keys.md +++ b/tests/smoke/command-keys.md @@ -82,6 +82,18 @@ Run inert runner controls with `python3 -I -S -B tests/ci/test_command_key_cohort.py`. They exercise synthetic inspection metadata and receipts without creating a runtime or loading PHP. +The proposed include-tree capture is data-only preparation for the required +read-only consumer boundary. `command_key_include_closure.py` inventories every +consumer/vendor/fixture leaf, rejects aliases, hardlinks, repository metadata +and unexpected root members, and copies verified bytes with read-only modes. +Its output includes a complete member/hash inventory. Before any installed +execution, that complete tree still needs a root-controlled staging location, +a kernel-enforced read-only consumer mount, before/after source and mount +identity checks, independent container-ID cleanup and actual boundary controls. +File modes and the inert inventory tests alone provide no isolation verdict. +The proposed official PHP image identities in the supply are raw-registry +metadata bindings; this prototype has not pulled or qualified that consumer. + Run the offline input controls against an existing immutable PHP image: ```sh diff --git a/tests/smoke/command_key_include_closure.py b/tests/smoke/command_key_include_closure.py new file mode 100644 index 00000000..2d2dd720 --- /dev/null +++ b/tests/smoke/command_key_include_closure.py @@ -0,0 +1,120 @@ +"""Capture and inventory the complete PHP include tree without loading PHP.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import stat + + +MAX_FILES = 10000 +MAX_BYTES = 128 * 1024 * 1024 + + +def inventory(root, owner, *, sealed): + if root.resolve() != root: + raise ValueError("The include root must be canonical") + entries = {} + total = 0 + paths = [root] + for directory, folders, files in os.walk(root, followlinks=False): + paths.extend(Path(directory) / name for name in [*folders, *files]) + if len(paths) > MAX_FILES: + raise ValueError("The include tree exceeds its member bound") + for path in sorted(paths): + if any(part in {".git", ".hg", ".svn"} for part in path.relative_to(root).parts): + raise ValueError("Repository metadata is outside the installed include closure") + metadata = path.lstat() + mode = stat.S_IMODE(metadata.st_mode) + if metadata.st_uid != owner or mode & 0o022 or (sealed and mode & 0o222): + raise ValueError("The include tree ownership or write permissions differ") + if path.is_symlink() or path.resolve() != path: + raise ValueError("The include tree contains an alias") + name = path.relative_to(root).as_posix() + if stat.S_ISDIR(metadata.st_mode): + entries[name] = {"kind": "directory", "mode": mode} + elif stat.S_ISREG(metadata.st_mode) and metadata.st_nlink == 1: + total += metadata.st_size + if total > MAX_BYTES: + raise ValueError("The include tree exceeds its byte bound") + raw = path.read_bytes() + if len(raw) != metadata.st_size: + raise ValueError("The include file changed while capturing it") + entries[name] = {"kind": "file", "mode": mode, "bytes": len(raw), + "sha256": hashlib.sha256(raw).hexdigest()} + else: + raise ValueError("The include tree needs single-link regular leaves") + if len(entries) > MAX_FILES: + raise ValueError("The include tree exceeds its member bound") + digest = hashlib.sha256(json.dumps(entries, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + return {"entries": entries, "sha256": digest, "bytes": total} + + +def copy_tree(source, destination, expected): + destination.mkdir(mode=0o700) + for name, member in expected["entries"].items(): + if name == ".": + continue + original, target = source / name, destination / name + if member["kind"] == "directory": + target.mkdir(mode=0o700) + continue + descriptor = os.open(original, os.O_RDONLY | os.O_NOFOLLOW) + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ValueError("The include source changed its leaf type") + with os.fdopen(descriptor, "rb", closefd=False) as stream: + raw = stream.read(MAX_BYTES + 1) + if len(raw) != member["bytes"] or hashlib.sha256(raw).hexdigest() != member["sha256"]: + raise ValueError("The include source changed before copying") + finally: + os.close(descriptor) + output = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(output, "wb") as stream: + stream.write(raw) + target.chmod(0o555 if member["mode"] & 0o111 else 0o444) + for path in sorted(destination.rglob("*"), reverse=True): + if path.is_dir(): + path.chmod(0o555) + destination.chmod(0o555) + + +def stage(consumer, fixture, destination): + if set(path.name for path in consumer.iterdir()) != {"composer.json", "composer.lock", "vendor"}: + raise ValueError("Unexpected consumer root members") + if set(path.name for path in fixture.iterdir()) != {"command-key-client.php", "command-key-supply.json"}: + raise ValueError("Unexpected captured fixture members") + owner = os.getuid() + before = inventory(consumer, owner, sealed=False) + fixture_before = inventory(fixture, owner, sealed=False) + for required in ("vendor/autoload.php", "vendor/composer/installed.json"): + if before["entries"].get(required, {}).get("kind") != "file": + raise ValueError("The complete installed autoloader metadata is required") + metadata = destination.lstat() + if (destination.resolve() != destination or not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != owner or stat.S_IMODE(metadata.st_mode) != 0o700 + or list(destination.iterdir())): + raise ValueError("The include destination must be a fresh private directory") + copy_tree(consumer, destination / "consumer", before) + copy_tree(fixture, destination / "fixture", fixture_before) + if inventory(consumer, owner, sealed=False) != before or inventory(fixture, owner, sealed=False) != fixture_before: + raise ValueError("An include source changed during staging") + destination.chmod(0o555) + return inventory(destination, owner, sealed=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--consumer", type=Path, required=True) + parser.add_argument("--fixture", type=Path, required=True) + parser.add_argument("--destination", type=Path, required=True) + options = parser.parse_args() + result = stage(options.consumer, options.fixture, options.destination) + print(json.dumps({"inspection_only": True, "include_sha256": result["sha256"], + "members": len(result["entries"]), "bytes": result["bytes"]})) + + +if __name__ == "__main__": + main() From e4f65ea5e65dec1acf72a3151102e034337e249c Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:07:59 +0000 Subject: [PATCH 07/17] Recognize both exact Docker CLI missing-object forms --- .github/workflows/published-command-keys.yml | 2 +- tests/ci/test_command_key_cohort.py | 2 ++ tests/smoke/command_key_cohort.py | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/published-command-keys.yml b/.github/workflows/published-command-keys.yml index 0b1be7f5..1a32e018 100644 --- a/.github/workflows/published-command-keys.yml +++ b/.github/workflows/published-command-keys.yml @@ -87,7 +87,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "7e2fa936fb45238411ac0d7c986cfc983898ceeeb2065d796cdf0a0ffa8059a0": + if hashlib.sha256(raw).hexdigest() != "621dca01d9d34edbb612db2ba2ba13c72c476667f7b5c1a75cc72079530e13df": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ diff --git a/tests/ci/test_command_key_cohort.py b/tests/ci/test_command_key_cohort.py index 429eaf73..37c2d389 100644 --- a/tests/ci/test_command_key_cohort.py +++ b/tests/ci/test_command_key_cohort.py @@ -103,6 +103,8 @@ def test_inspection_error_category_is_not_an_absence_receipt(self): def test_missing_object_requires_exact_exit_stdout_and_stderr(self): expected = {"returncode": 1, "stdout": "[]\n", "stderr": "Error: No such object: " + IDENTITY + "\n"} self.assertTrue(MODULE.exact_missing(type("Result", (), expected)(), IDENTITY)) + current_cli = expected | {"stderr": "error: no such object: " + IDENTITY + "\n"} + self.assertTrue(MODULE.exact_missing(type("Result", (), current_cli)(), IDENTITY)) for changed in ({"returncode": 125}, {"stdout": ""}, {"stdout": "[{}]\n"}, {"stderr": expected["stderr"] + "permission denied\n"}, {"stderr": "daemon error\n" + expected["stderr"]}): diff --git a/tests/smoke/command_key_cohort.py b/tests/smoke/command_key_cohort.py index 3265d3f3..a29724cb 100644 --- a/tests/smoke/command_key_cohort.py +++ b/tests/smoke/command_key_cohort.py @@ -59,7 +59,8 @@ def docker_environment(): def exact_missing(process, identity): return (process.returncode == 1 and process.stdout == "[]\n" - and process.stderr == "Error: No such object: " + identity + "\n") + and process.stderr in {"Error: No such object: " + identity + "\n", + "error: no such object: " + identity + "\n"}) def inspect_runtime(identity, allow_missing=False): From 55273aafbe9576ff767c18d171647f4516999e9b Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:54:18 +0000 Subject: [PATCH 08/17] Confine command-key includes and isolate captured-ID cleanup --- .github/workflows/ci.yml | 1 + .github/workflows/published-command-keys.yml | 147 ++++++- tests/ci/test_command_key_cohort.py | 287 ++++++++------ tests/ci/test_command_key_containers.py | 189 +++++++++ tests/ci/test_command_key_supply.py | 30 +- tests/smoke/command-key-supply.json | 4 +- tests/smoke/command-keys.md | 70 ++-- tests/smoke/command_key_cohort.py | 389 ++++++++++--------- tests/smoke/command_key_containers.py | 172 ++++++++ tests/smoke/verify_command_key_supply.py | 12 +- 10 files changed, 951 insertions(+), 350 deletions(-) create mode 100644 tests/ci/test_command_key_containers.py create mode 100644 tests/smoke/command_key_containers.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e7cbc35..d36926d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,7 @@ jobs: run: | python3 -I -S -B tests/ci/test_command_key_supply.py python3 -I -S -B tests/ci/test_command_key_cohort.py + python3 -I -S -B tests/ci/test_command_key_containers.py python3 -I -S -B tests/ci/test_command_key_include_closure.py test: diff --git a/.github/workflows/published-command-keys.yml b/.github/workflows/published-command-keys.yml index 1a32e018..2c9249f1 100644 --- a/.github/workflows/published-command-keys.yml +++ b/.github/workflows/published-command-keys.yml @@ -24,6 +24,8 @@ jobs: echo /usr/bin >> "$GITHUB_PATH" /usr/bin/python3 -I -S -B tests/ci/test_command_key_supply.py /usr/bin/python3 -I -S -B tests/ci/test_command_key_cohort.py + /usr/bin/python3 -I -S -B tests/ci/test_command_key_containers.py + /usr/bin/python3 -I -S -B tests/ci/test_command_key_include_closure.py - name: Create private registry consumer and runtime state run: | umask 077 @@ -76,9 +78,75 @@ jobs: working-directory: ${{ env.CONSUMER }} - name: Generate only the verified consumer autoloader run: composer dump-autoload --working-dir="$CONSUMER" --no-scripts --no-plugins --no-interaction + - name: Capture the complete include closure without package execution + id: stage + run: | + mkdir -m 700 "$COMMAND_KEY_STATE/stage" + python3 -I -S -B -c ' + import hashlib,os,re,subprocess + from pathlib import Path + source=os.environ["GITHUB_SHA"] + if re.fullmatch("[0-9a-f]{40}",source) is None: + raise RuntimeError("An exact trusted runner source is required") + path="tests/smoke/command_key_cohort.py" + raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) + if hashlib.sha256(raw).hexdigest() != "53feca91964f24382d2a4f90967efa51970ba05405b9b2c7a16b0a521b125f84": + raise RuntimeError("The captured controller differs from its independently fixed digest") + exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) + ' \ + --phase stage --consumer "$CONSUMER" --archive "$COMMAND_KEY_STATE/sdk.zip" \ + --stage-root "$COMMAND_KEY_STATE/stage" --runtime-state "$COMMAND_KEY_STATE/runtime" \ + --docker-config "$COMMAND_KEY_STATE/docker" \ + --runtime-owner "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --runner-source "$GITHUB_SHA" \ + > command-key-staging.json + root="$(sudo mktemp -d /run/sdk-php-command-keys.XXXXXX)" + python3 -I -S -B - "$root" <<'PYROOT' + import json,os,pathlib,stat,sys + root=pathlib.Path(sys.argv[1]); metadata=root.lstat() + if root.parent!=pathlib.Path('/run') or metadata.st_uid!=0 or not stat.S_ISDIR(metadata.st_mode): + raise RuntimeError('Unexpected root-controlled stage') + captured=json.loads(pathlib.Path('command-key-staging.json').read_text()) + with open(os.environ['GITHUB_OUTPUT'],'a') as output: + output.write('root='+str(root)+'\n') + output.write('root_identity='+str(metadata.st_dev)+':'+str(metadata.st_ino)+'\n') + output.write('include_sha256='+captured['include_sha256']+'\n') + PYROOT + sudo cp -a --no-preserve=ownership -- "$COMMAND_KEY_STATE/stage/." "$root/" + sudo chmod 0555 -- "$root" + - name: Prepare the captured native and read-only PHP containers + id: prepare + env: + INCLUDE_ROOT: ${{ steps.stage.outputs.root }} + INCLUDE_IDENTITY: ${{ steps.stage.outputs.root_identity }} + INCLUDE_SHA256: ${{ steps.stage.outputs.include_sha256 }} + run: | + python3 -I -S -B -c ' + import hashlib,os,re,subprocess + from pathlib import Path + source=os.environ["GITHUB_SHA"] + if re.fullmatch("[0-9a-f]{40}",source) is None: + raise RuntimeError("An exact trusted runner source is required") + path="tests/smoke/command_key_cohort.py" + raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) + if hashlib.sha256(raw).hexdigest() != "53feca91964f24382d2a4f90967efa51970ba05405b9b2c7a16b0a521b125f84": + raise RuntimeError("The captured controller differs from its independently fixed digest") + exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) + ' \ + --phase prepare --stage-root "$INCLUDE_ROOT" --root-identity "$INCLUDE_IDENTITY" \ + --include-sha256 "$INCLUDE_SHA256" --runtime-state "$COMMAND_KEY_STATE/runtime" \ + --docker-config "$COMMAND_KEY_STATE/docker" \ + --runtime-owner "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --runner-source "$GITHUB_SHA" \ + | tee command-key-prepared.json - name: Qualify modern retained commands and postcommit response loss + env: + INCLUDE_ROOT: ${{ steps.stage.outputs.root }} + INCLUDE_IDENTITY: ${{ steps.stage.outputs.root_identity }} + INCLUDE_SHA256: ${{ steps.stage.outputs.include_sha256 }} + SERVER_ID: ${{ steps.prepare.outputs.server_id }} + CONSUMER_ID: ${{ steps.prepare.outputs.consumer_id }} + FIXTURE_NONCE: ${{ steps.prepare.outputs.nonce }} + COMMAND_KEY_TOKEN: ${{ steps.prepare.outputs.token }} run: | - php -l tests/smoke/command-key-client.php timeout --signal=INT --kill-after=30s 12m python3 -I -S -B -c ' import hashlib,os,re,subprocess from pathlib import Path @@ -87,24 +155,77 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "621dca01d9d34edbb612db2ba2ba13c72c476667f7b5c1a75cc72079530e13df": + if hashlib.sha256(raw).hexdigest() != "53feca91964f24382d2a4f90967efa51970ba05405b9b2c7a16b0a521b125f84": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ - --consumer "$CONSUMER" --archive "$COMMAND_KEY_STATE/sdk.zip" \ - --proxy-source "$COMMAND_KEY_STATE/committed_response_loss.py" \ - --runtime-state "$COMMAND_KEY_STATE/runtime" \ + --phase cohort --stage-root "$INCLUDE_ROOT" --root-identity "$INCLUDE_IDENTITY" \ + --include-sha256 "$INCLUDE_SHA256" --runtime-state "$COMMAND_KEY_STATE/runtime" \ --docker-config "$COMMAND_KEY_STATE/docker" \ + --server-id "$SERVER_ID" --consumer-id "$CONSUMER_ID" --nonce "$FIXTURE_NONCE" \ + --proxy-source "$COMMAND_KEY_STATE/committed_response_loss.py" \ --runtime-owner "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --runner-source "$GITHUB_SHA" \ | tee command-key-modern.json - - name: Independently repeat captured-ID runtime cleanup - if: always() && env.COMMAND_KEY_STATE != '' + - name: Remove captured fixtures independently of checkout and state + if: always() + env: + SERVER_ID: ${{ steps.prepare.outputs.server_id }} + CONSUMER_ID: ${{ steps.prepare.outputs.consumer_id }} + FIXTURE_NONCE: ${{ steps.prepare.outputs.nonce }} + INCLUDE_ROOT: ${{ steps.stage.outputs.root }} + INCLUDE_IDENTITY: ${{ steps.stage.outputs.root_identity }} run: | - python3 -I -S -B tests/smoke/command_key_cohort.py \ - --runtime-state "$COMMAND_KEY_STATE/runtime" \ - --docker-config "$COMMAND_KEY_STATE/docker" \ - --runtime-owner "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --runner-source "$GITHUB_SHA" \ - --cleanup-only | tee command-key-cleanup.json + /usr/bin/python3 -I -S -B - <<'PYCLEAN' | tee command-key-cleanup.json + import json,os,pathlib,re,stat,subprocess,tempfile + owner=os.environ['GITHUB_RUN_ID']+'-'+os.environ['GITHUB_RUN_ATTEMPT'] + nonce=os.environ['FIXTURE_NONCE']; errors=[] + with tempfile.TemporaryDirectory(prefix='sdk-php-command-key-cleanup-',dir='/tmp') as configuration: + environment={'PATH':'/usr/bin:/bin','DOCKER_CONFIG':configuration,'DOCKER_HOST':'unix:///var/run/docker.sock'} + def invoke(arguments): + return subprocess.run(['/usr/bin/docker',*arguments],env=environment,capture_output=True,text=True,timeout=30) + def absent(result,identity): + return result.returncode==1 and result.stdout=='[]\n' and result.stderr in { + 'Error: No such object: '+identity+'\n','error: no such object: '+identity+'\n'} + for role,key in [('consumer','CONSUMER_ID'),('server','SERVER_ID')]: + identity=os.environ[key] + if not identity: + continue + try: + if re.fullmatch('[0-9a-f]{64}',identity) is None or re.fullmatch('[0-9a-f]{32}',nonce) is None: + raise RuntimeError('Invalid immutable fixture outputs') + result=invoke(['inspect',identity]) + if absent(result,identity): + continue + if result.returncode!=0: + raise RuntimeError('The recorded fixture cannot be inspected') + rows=json.loads(result.stdout) + if len(rows)!=1 or rows[0]['Id']!=identity: + raise RuntimeError('Fixture identity differs') + labels=rows[0]['Config'].get('Labels') or {} + if (labels.get('durable-workflow.php-command-keys'),labels.get('durable-workflow.fixture-role'),labels.get('durable-workflow.fixture-nonce'))!=(owner,role,nonce): + raise RuntimeError('Foreign replacement preserved') + removed=invoke(['rm','--force','--volumes',identity]) + if removed.returncode!=0 or not absent(invoke(['inspect',identity]),identity): + raise RuntimeError('Exact fixture absence was not established') + except Exception as error: + errors.append(role+':'+type(error).__name__) + root=os.environ['INCLUDE_ROOT'] + if root and not errors: + try: + path=pathlib.Path(root); metadata=path.lstat() + if (path.parent!=pathlib.Path('/run') or re.fullmatch(r'sdk-php-command-keys\.[A-Za-z0-9]{6}',path.name) is None + or path.resolve()!=path or not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid!=0 + or str(metadata.st_dev)+':'+str(metadata.st_ino)!=os.environ['INCLUDE_IDENTITY']): + raise RuntimeError('Foreign include root preserved') + subprocess.run(['/usr/bin/sudo','/usr/bin/rm','-rf','--',root],check=True,timeout=30) + if path.exists() or path.is_symlink(): + raise RuntimeError('Include root absence was not established') + except Exception as error: + errors.append('include:'+type(error).__name__) + print(json.dumps({'runtime_removed':not errors,'errors':errors})) + if errors: + raise SystemExit(1) + PYCLEAN - name: Retain sanitized observations and installed dependency graph if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -112,6 +233,8 @@ jobs: name: published-php-command-keys path: | command-key-package.json + command-key-staging.json + command-key-prepared.json command-key-modern.json command-key-cleanup.json ${{ env.CONSUMER }}/composer.lock diff --git a/tests/ci/test_command_key_cohort.py b/tests/ci/test_command_key_cohort.py index 37c2d389..04b8d091 100644 --- a/tests/ci/test_command_key_cohort.py +++ b/tests/ci/test_command_key_cohort.py @@ -1,14 +1,15 @@ -"""Inert provenance/receipt controls. No Docker, PHP, runtime or network calls.""" +"""Inert closure, workflow-bootstrap and receipt controls; no runtime calls.""" from copy import deepcopy import importlib.util import json import os from pathlib import Path -import tempfile +import stat import textwrap +from types import SimpleNamespace import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch SOURCE = Path(__file__).resolve().parents[1] / "smoke/command_key_cohort.py" @@ -16,156 +17,188 @@ MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MODULE) SUPPLY = json.loads(SOURCE.with_name("command-key-supply.json").read_text()) +WORKFLOW = SOURCE.parents[2] / ".github/workflows/published-command-keys.yml" IDENTITY = "a" * 64 +ROOT = Path("/run/sdk-php-command-keys.abcdef") def runtime(): return {"Id": IDENTITY, "Image": SUPPLY["server_config"], "State": {"Running": True}, - "Config": {"Labels": {"durable-workflow.php-command-keys": "123-1", - "org.opencontainers.image.revision": SUPPLY["server_source"]}}, + "Config": {"Labels": {"org.opencontainers.image.revision": SUPPLY["server_source"]}}, "HostConfig": {"ReadonlyRootfs": True, "Privileged": False, "Binds": None, "Init": True, "CapDrop": ["ALL"], "CapAdd": None, "SecurityOpt": ["no-new-privileges"], "Devices": [], "NetworkMode": "bridge", "Memory": 1024**3, "NanoCpus": 10**9, "PidsLimit": 256, "Tmpfs": {"/tmp": "size=64m", "/app/database": "size=32m", "/app/storage": "size=64m"}}, - "Mounts": [], "NetworkSettings": {"Ports": {"8080/tcp": [{"HostIp": "127.0.0.1", "HostPort": "43123"}]}}} + "Mounts": [{"Type": "tmpfs", "Destination": path} for path in ("/tmp", "/app/database", "/app/storage")], + "NetworkSettings": {"Ports": {"8080/tcp": [{"HostIp": "127.0.0.1", "HostPort": "43123"}]}}} -class ProvenanceControls(unittest.TestCase): - def setUp(self): - directory = tempfile.TemporaryDirectory() - self.addCleanup(directory.cleanup) - configuration = patch.object(MODULE, "DOCKER_CONFIG", Path(directory.name)) - configuration.start() - self.addCleanup(configuration.stop) +def consumer(): + record = runtime() + record["Image"] = SUPPLY["php_config"] + record["Config"]["User"] = "1001:1001" + record["HostConfig"].update(NetworkMode="host", Tmpfs={"/tmp": "size=128m"}) + record["Mounts"] = [{"Type": "bind", "Source": str(ROOT), "Destination": "/closure", "RW": False}, + {"Type": "tmpfs", "Destination": "/tmp"}] + record["NetworkSettings"]["Ports"] = None + return record - def origin(self, record): - with patch.object(MODULE, "inspect_runtime", return_value=record): - return MODULE.runtime_origin(IDENTITY, "123-1", SUPPLY) - def test_complete_owned_runtime_selects_only_numeric_loopback(self): +class ProvenanceControls(unittest.TestCase): + def origin(self, record): + resources = Mock() + resources.owned.return_value = record + result = MODULE.runtime_origin(resources, IDENTITY, SUPPLY) + resources.owned.assert_called_once_with(IDENTITY, "server") + return result + + def php(self, record): + resources = Mock() + resources.owned.return_value = record + with patch.object(MODULE.os, "getuid", return_value=1001), patch.object(MODULE.os, "getgid", return_value=1001): + result = MODULE.consumer_record(resources, IDENTITY, SUPPLY, ROOT) + resources.owned.assert_called_once_with(IDENTITY, "consumer") + return result + + def test_complete_native_profile_selects_numeric_loopback(self): self.assertEqual(self.origin(runtime()), "http://127.0.0.1:43123") - def test_foreign_id_owner_source_and_config_refuse(self): - for mutation in (lambda r: r.update(Id="b" * 64), - lambda r: r.update(Image="sha256:" + "0" * 64), - lambda r: r["Config"]["Labels"].update({"durable-workflow.php-command-keys": "other"}), - lambda r: r["Config"]["Labels"].update({"org.opencontainers.image.revision": "0" * 40}), - lambda r: r["State"].update(Running=False)): - record = runtime() - mutation(record) - with self.subTest(record=record), self.assertRaises(RuntimeError): - self.origin(record) - - def test_broader_caps_filesystems_and_ports_refuse(self): - mutations = [lambda r: r["Mounts"].append({"Type": "bind"}), - lambda r: r["HostConfig"]["Tmpfs"].update({"/other": "size=1m"}), + def test_native_source_configuration_network_and_ports_are_closed(self): + mutations = [lambda r: r.update(Image="sha256:" + "0" * 64), + lambda r: r["Config"]["Labels"].update({"org.opencontainers.image.revision": "0" * 40}), + lambda r: r["State"].update(Running=False), + lambda r: r["HostConfig"].update(NetworkMode="host"), lambda r: r["NetworkSettings"]["Ports"]["8080/tcp"][0].update(HostIp="0.0.0.0"), + lambda r: r["NetworkSettings"]["Ports"]["8080/tcp"][0].update(HostPort="65536"), lambda r: r["NetworkSettings"]["Ports"].update({"9000/tcp": None})] - mutations += [lambda r, key=key, value=value: r["HostConfig"].update({key: value}) - for key, value in [("Memory", 0), ("Memory", 2 * 1024**3), ("NanoCpus", 0), - ("PidsLimit", 257), ("ReadonlyRootfs", False), ("Privileged", True), - ("Init", False), ("CapDrop", []), ("CapAdd", ["SYS_ADMIN"]), - ("SecurityOpt", []), ("NetworkMode", "host"), ("Devices", [{}])]] for index, mutation in enumerate(mutations): record = runtime() mutation(record) with self.subTest(index=index), self.assertRaises(RuntimeError): self.origin(record) - def test_cleanup_preserves_foreign_id_replacements(self): - record = runtime() - record["Config"]["Labels"]["durable-workflow.php-command-keys"] = "foreign" - with patch.object(MODULE, "inspect_runtime", return_value=record), \ - patch.object(MODULE.subprocess, "run") as execute: - with self.assertRaisesRegex(RuntimeError, "preserve the replacement"): - MODULE.remove_runtime(IDENTITY, "123-1") - execute.assert_not_called() - - def test_cleanup_uses_only_captured_id_and_requires_missing_object(self): - calls = [] - - def execute(arguments, **_): - calls.append(arguments) - return type("Result", (), {"returncode": 1, "stdout": "[]\n", - "stderr": "Error: No such object: " + IDENTITY + "\n"})() - - with patch.object(MODULE, "inspect_runtime", return_value=runtime()), \ - patch.object(MODULE.subprocess, "run", side_effect=execute): - MODULE.remove_runtime(IDENTITY, "123-1") - self.assertEqual(calls, [["docker", "rm", "--force", "--volumes", IDENTITY], ["docker", "inspect", IDENTITY]]) - - def test_inspection_error_category_is_not_an_absence_receipt(self): - result = type("Result", (), {"returncode": 1, "stdout": "", "stderr": "engine unavailable"})() - with patch.object(MODULE.subprocess, "run", return_value=result), self.assertRaises(RuntimeError): - MODULE.inspect_runtime(IDENTITY, allow_missing=True) - - def test_missing_object_requires_exact_exit_stdout_and_stderr(self): - expected = {"returncode": 1, "stdout": "[]\n", "stderr": "Error: No such object: " + IDENTITY + "\n"} - self.assertTrue(MODULE.exact_missing(type("Result", (), expected)(), IDENTITY)) - current_cli = expected | {"stderr": "error: no such object: " + IDENTITY + "\n"} - self.assertTrue(MODULE.exact_missing(type("Result", (), current_cli)(), IDENTITY)) - for changed in ({"returncode": 125}, {"stdout": ""}, {"stdout": "[{}]\n"}, - {"stderr": expected["stderr"] + "permission denied\n"}, - {"stderr": "daemon error\n" + expected["stderr"]}): - with self.subTest(changed=changed): - process = type("Result", (), expected | changed)() - self.assertFalse(MODULE.exact_missing(process, IDENTITY)) - with patch.object(MODULE.subprocess, "run", return_value=process), self.assertRaises(RuntimeError): - MODULE.inspect_runtime(IDENTITY, allow_missing=True) - - def test_docker_environment_excludes_ambient_credentials_and_remote_context(self): - with patch.dict(os.environ, {"DOCKER_AUTH_CONFIG": "untrusted", "DOCKER_CONTEXT": "foreign", - "DOCKER_HOST": "tcp://remote:2375"}): - environment = MODULE.docker_environment() - self.assertEqual(environment, {"PATH": "/usr/bin:/bin", "DOCKER_CONFIG": str(MODULE.DOCKER_CONFIG), - "DOCKER_HOST": "unix:///var/run/docker.sock"}) - (MODULE.DOCKER_CONFIG / "config.json").write_text('{}') - with self.assertRaisesRegex(RuntimeError, "remain empty"): - MODULE.docker_environment() - - def controller_bootstrap(self): - workflow = (SOURCE.parents[2] / ".github/workflows/published-command-keys.yml").read_text() - literal = workflow.split("python3 -I -S -B -c '\n", 1)[1].split("\n ' \\", 1)[0] - return compile(textwrap.dedent(literal), "literal-controller-bootstrap", "exec") - - def test_literal_bootstrap_loads_only_the_digest_bound_git_blob(self): - bootstrap = self.controller_bootstrap() - with patch.dict(os.environ, {"GITHUB_SHA": "a" * 40}), \ - patch.object(MODULE.subprocess, "check_output", return_value=SOURCE.read_bytes()) as read, \ - patch("sys.argv", ["controller", "--help"]), patch("sys.stdout"), \ - self.assertRaises(SystemExit) as result: - exec(bootstrap, {}) - self.assertEqual(result.exception.code, 0) - self.assertEqual(read.call_args.args[0], ["/usr/bin/git", "--no-replace-objects", "show", - "a" * 40 + ":tests/smoke/command_key_cohort.py"]) - - def test_literal_bootstrap_rejects_other_bytes_before_compile(self): - bootstrap = self.controller_bootstrap() - with patch.dict(os.environ, {"GITHUB_SHA": "a" * 40}), \ - patch.object(MODULE.subprocess, "check_output", return_value=b"print('inert marker')"), \ - patch("builtins.compile") as compile_source, \ - self.assertRaisesRegex(RuntimeError, "independently fixed digest"): - exec(bootstrap, {}) - compile_source.assert_not_called() - - def test_only_owned_private_runtime_state_and_regular_cid_are_accepted(self): - with tempfile.TemporaryDirectory() as temporary: - state = Path(temporary) - self.assertEqual(MODULE.private_state(state), state / "runtime.cid") - (state / "runtime.cid").write_text(IDENTITY) - with patch.object(MODULE, "remove_runtime") as remove: - MODULE.cleanup_state(state, "123-1") - remove.assert_called_once_with(IDENTITY, "123-1") - os.chmod(state, 0o755) - with self.assertRaises(RuntimeError): - MODULE.private_state(state) - os.chmod(state, 0o700) - (state / "runtime.cid").write_text("name-instead-of-id") + def test_broader_caps_and_privileges_refuse_for_both_roles(self): + changes = [("Memory", 0), ("Memory", 2 * 1024**3), ("NanoCpus", 0), ("NanoCpus", 2 * 10**9), + ("PidsLimit", 0), ("PidsLimit", 257), ("ReadonlyRootfs", False), ("Privileged", True), + ("Init", False), ("CapDrop", []), ("CapAdd", ["SYS_ADMIN"]), + ("SecurityOpt", []), ("Devices", [{}]), ("Binds", ["unexpected"])] + for factory, check in ((runtime, self.origin), (consumer, self.php)): + for key, value in changes: + record = factory() + record["HostConfig"][key] = value + with self.subTest(role=factory.__name__, key=key, value=value), self.assertRaises(RuntimeError): + check(record) + + def test_native_writable_paths_and_mounts_cannot_expand(self): + for mutation in (lambda r: r["Mounts"].append({"Type": "bind", "Destination": "/closure"}), + lambda r: r["Mounts"].append({"Type": "tmpfs", "Destination": "/other"}), + lambda r: r["HostConfig"]["Tmpfs"].update({"/other": "size=1m"})): + record = runtime() + mutation(record) with self.assertRaises(RuntimeError): - MODULE.cleanup_state(state, "123-1") + self.origin(record) - def test_receipt_comparison_preserves_every_original_identity(self): + def test_complete_php_include_closure_is_readonly_and_unprivileged(self): + self.assertEqual(self.php(consumer())["Mounts"][0]["RW"], False) + + def test_writable_partial_replaced_or_shadowed_include_mount_refuses(self): + mutations = [lambda r: r["Mounts"][0].update(RW=True), + lambda r: r["Mounts"][0].update(Source="/other"), + lambda r: r["Mounts"][0].update(Destination="/closure/consumer/vendor"), + lambda r: r["Mounts"].append({"Type": "tmpfs", "Destination": "/closure/fixture"}), + lambda r: r["Mounts"].append({"Type": "volume", "Destination": "/cache"}), + lambda r: r["HostConfig"]["Tmpfs"].update({"/closure": "size=1m"}), + lambda r: r["Config"].update(User="0:0")] + for index, mutation in enumerate(mutations): + record = consumer() + mutation(record) + with self.subTest(index=index), self.assertRaises(RuntimeError): + self.php(record) + + def test_stage_requires_root_owner_inode_sealed_inventory_and_digest(self): + metadata = SimpleNamespace(st_mode=stat.S_IFDIR | 0o555, st_uid=0, st_dev=3, st_ino=4) + closure = Mock() + closure.inventory.return_value = {"sha256": "d" * 64} + with patch.object(Path, "lstat", return_value=metadata), patch.object(Path, "resolve", lambda path: path): + self.assertEqual(MODULE.stage_inventory(ROOT, "d" * 64, "3:4", closure), closure.inventory.return_value) + closure.inventory.assert_called_with(ROOT, 0, sealed=True) + for root, digest, identity in ((Path("/tmp") / ROOT.name, "d" * 64, "3:4"), + (ROOT, "e" * 64, "3:4"), (ROOT, "d" * 64, "3:5")): + with self.assertRaises(RuntimeError): + MODULE.stage_inventory(root, digest, identity, closure) + metadata.st_uid = 1001 + with self.assertRaises(RuntimeError): + MODULE.stage_inventory(ROOT, "d" * 64, "3:4", closure) + + def bootstraps(self): + bodies = WORKFLOW.read_text().split("python3 -I -S -B -c '\n")[1:] + return [compile(textwrap.dedent(body.split("\n ' \\", 1)[0]), "literal-controller-bootstrap", "exec") + for body in bodies] + + def test_all_three_phase_bootstraps_bind_git_bytes_before_execution(self): + bootstraps = self.bootstraps() + self.assertEqual(len(bootstraps), 3) + for bootstrap in bootstraps: + with patch.dict(os.environ, {"GITHUB_SHA": "a" * 40}), \ + patch.object(MODULE.subprocess, "check_output", return_value=SOURCE.read_bytes()) as read, \ + patch("sys.argv", ["controller", "--help"]), patch("sys.stdout"), \ + self.assertRaises(SystemExit) as result: + exec(bootstrap, {}) + self.assertEqual(result.exception.code, 0) + self.assertEqual(read.call_args.args[0], ["/usr/bin/git", "--no-replace-objects", "show", + "a" * 40 + ":tests/smoke/command_key_cohort.py"]) + + def test_preopen_controller_substitution_refuses_before_compile(self): + for bootstrap in self.bootstraps(): + with patch.dict(os.environ, {"GITHUB_SHA": "a" * 40}), \ + patch.object(MODULE.subprocess, "check_output", return_value=b"inert substituted bytes"), \ + patch("builtins.compile") as compile_source, \ + self.assertRaisesRegex(RuntimeError, "independently fixed digest"): + exec(bootstrap, {}) + compile_source.assert_not_called() + + def test_independent_cleanup_survives_checkout_state_and_docker_config_refusal(self): + workflow = WORKFLOW.read_text() + body = workflow.split("<<'PYCLEAN' | tee command-key-cleanup.json\n", 1)[1].split("\n PYCLEAN", 1)[0] + cleanup = compile(textwrap.dedent(body), "literal-always-cleanup", "exec") + for foreign_consumer, mixed_error in ((False, False), (True, False), (False, True)): + calls = [] + removed = set() + + def invoke(arguments, **options): + self.assertEqual(options["env"]["DOCKER_CONFIG"], "/inert-independent-configuration") + calls.append(arguments) + identity = arguments[-1] + if arguments[1] == "rm": + removed.add(identity) + return SimpleNamespace(returncode=0, stdout="", stderr="") + if identity in removed: + extra = "engine unavailable\n" if mixed_error and identity != IDENTITY else "" + return SimpleNamespace(returncode=1, stdout="[]\n", stderr="error: no such object: " + identity + "\n" + extra) + role = "server" if identity == IDENTITY else "consumer" + labels = {"durable-workflow.php-command-keys": "foreign" if foreign_consumer and role == "consumer" else "123-1", + "durable-workflow.fixture-role": role, "durable-workflow.fixture-nonce": "b" * 32} + return SimpleNamespace(returncode=0, stderr="", stdout=json.dumps([{"Id": identity, "Config": {"Labels": labels}}])) + + environment = {"GITHUB_RUN_ID": "123", "GITHUB_RUN_ATTEMPT": "1", "FIXTURE_NONCE": "b" * 32, + "SERVER_ID": IDENTITY, "CONSUMER_ID": "c" * 64, "INCLUDE_ROOT": "", "INCLUDE_IDENTITY": "", + "GITHUB_SHA": "invalid", "COMMAND_KEY_STATE": "/missing-state", "TMPDIR": "/missing-temp", + "DOCKER_CONFIG": "/foreign-configuration", "DOCKER_HOST": "tcp://example.invalid:2375"} + with patch.dict(os.environ, environment), patch("tempfile.TemporaryDirectory") as temporary, \ + patch("subprocess.run", side_effect=invoke), patch("builtins.print"): + temporary.return_value.__enter__.return_value = "/inert-independent-configuration" + if foreign_consumer or mixed_error: + with self.assertRaises(SystemExit): + exec(cleanup, {}) + else: + exec(cleanup, {}) + self.assertIn(IDENTITY, removed) + self.assertEqual("c" * 64 in removed, not foreign_consumer) + self.assertTrue(all(call[0] == "/usr/bin/docker" for call in calls)) + temporary.assert_called_once_with(prefix="sdk-php-command-key-cleanup-", dir="/tmp") + + def test_receipt_comparison_preserves_original_identity_and_verified_intent(self): for kind in ("signal", "update"): original = {"command_id": "command", "run_id": "run", "request_id": "key", "signal_id": "signal", "update_id": "update", "accepted_at": "date", "command_sequence": 3} diff --git a/tests/ci/test_command_key_containers.py b/tests/ci/test_command_key_containers.py new file mode 100644 index 00000000..fa733547 --- /dev/null +++ b/tests/ci/test_command_key_containers.py @@ -0,0 +1,189 @@ +"""Inert captured-ID lifecycle controls; Docker is always replaced by a stub.""" + +import importlib.util +import json +import os +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + + +SOURCE = Path(__file__).resolve().parents[1] / "smoke/command_key_containers.py" +SPEC = importlib.util.spec_from_file_location("command_key_containers", SOURCE) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +IDENTITY = "a" * 64 +NONCE = "b" * 32 + + +def result(code=0, output="", error=""): + return SimpleNamespace(returncode=code, stdout=output, stderr=error) + + +def owned_record(role="server"): + return {"Id": IDENTITY, "Config": {"Labels": { + MODULE.OWNER: "123-1", MODULE.ROLE: role, MODULE.NONCE: NONCE}}} + + +class ContainerControls(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.configuration, self.state = [self.root / name for name in ("docker", "state")] + self.configuration.mkdir(mode=0o700) + self.state.mkdir(mode=0o700) + self.resources = MODULE.Containers(self.configuration, self.state, "123-1", NONCE) + self.addCleanup(self.resources.close) + + def test_only_exact_missing_object_forms_are_absence(self): + for prefix in ("Error: No such object: ", "error: no such object: "): + missing = result(1, "[]\n", prefix + IDENTITY + "\n") + self.assertTrue(MODULE.exact_missing(missing, IDENTITY)) + with patch.object(self.resources, "command", return_value=missing): + self.assertIsNone(self.resources.inspect(IDENTITY, allow_missing=True)) + for changed in (result(125, missing.stdout, missing.stderr), + result(1, "", missing.stderr), result(1, "[{}]\n", missing.stderr), + result(1, missing.stdout, missing.stderr + "permission denied\n"), + result(1, missing.stdout, "engine unavailable\n" + missing.stderr)): + with self.subTest(changed=vars(changed)): + self.assertFalse(MODULE.exact_missing(changed, IDENTITY)) + with patch.object(self.resources, "command", return_value=changed), self.assertRaises(RuntimeError): + self.resources.inspect(IDENTITY, allow_missing=True) + + def test_empty_local_docker_configuration_excludes_ambient_authority(self): + with patch.dict(os.environ, {"DOCKER_AUTH_CONFIG": "inert", "DOCKER_CONTEXT": "foreign", + "DOCKER_HOST": "tcp://example.invalid:2375"}): + self.assertEqual(self.resources.environment(), { + "PATH": "/usr/bin:/bin", "DOCKER_CONFIG": str(self.configuration), + "DOCKER_HOST": "unix:///var/run/docker.sock"}) + (self.configuration / "config.json").write_text('{}') + with self.assertRaisesRegex(RuntimeError, "remain empty"): + self.resources.environment() + + def test_inspect_requires_full_exact_returned_identity(self): + for identity in ("short", "name", "A" * 64): + with self.assertRaises(RuntimeError): + self.resources.inspect(identity) + for rows in ([], [owned_record(), owned_record()], [owned_record() | {"Id": "c" * 64}]): + with patch.object(self.resources, "command", return_value=result(output=json.dumps(rows))), \ + self.assertRaises(RuntimeError): + self.resources.inspect(IDENTITY) + + def test_foreign_owner_role_and_nonce_are_preserved(self): + for field in (MODULE.OWNER, MODULE.ROLE, MODULE.NONCE): + record = owned_record() + record["Config"]["Labels"][field] = "foreign" + with patch.object(self.resources, "inspect", return_value=record), \ + patch.object(self.resources, "command") as invoke, \ + self.assertRaisesRegex(RuntimeError, "preserve the replacement"): + self.resources.remove(IDENTITY, "server") + invoke.assert_not_called() + + def test_create_captures_stdout_to_held_record_and_workflow_output(self): + captured = [] + self.resources.record_created = lambda role, identity: captured.append((role, identity)) + with patch.object(self.resources, "command", return_value=result(output=IDENTITY + "\n")) as invoke, \ + patch.object(self.resources, "owned", return_value=owned_record()): + self.assertEqual(self.resources.create("server", ["inert-image"]), IDENTITY) + self.assertEqual(captured, [("server", IDENTITY)]) + self.assertEqual(json.loads((self.state / "server.json").read_text()), { + "id": IDENTITY, "owner": "123-1", "role": "server", "nonce": NONCE}) + flags = invoke.call_args.args[0] + self.assertIn("--read-only", flags) + self.assertEqual(flags[flags.index("--memory") + 1], "1g") + self.assertEqual(flags[flags.index("--cpus") + 1], "1") + self.assertEqual(flags[flags.index("--pids-limit") + 1], "256") + with self.assertRaises(RuntimeError): + self.resources.create("server", ["inert-image"]) + + def test_record_path_substitution_does_not_select_or_modify_replacement(self): + original = self.root / "held-state" + self.state.rename(original) + self.state.mkdir(mode=0o700) + replacement = self.state / "server.json" + replacement.write_text('foreign sentinel') + with patch.object(self.resources, "command", return_value=result(output=IDENTITY + "\n")), \ + patch.object(self.resources, "owned", return_value=owned_record()): + self.resources.create("server", ["inert-image"]) + self.assertEqual(replacement.read_text(), 'foreign sentinel') + self.assertEqual(json.loads((original / "server.json").read_text())["id"], IDENTITY) + + def test_preexisting_record_and_symlink_refuse_before_create(self): + path = self.state / "server.json" + target = self.root / "sentinel" + target.write_text('unchanged') + for symlink in (False, True): + if symlink: + path.symlink_to(target) + else: + path.write_text('existing') + with patch.object(self.resources, "command") as invoke, self.assertRaises(OSError): + self.resources.create("server", ["inert-image"]) + invoke.assert_not_called() + path.unlink() + self.assertEqual(target.read_text(), 'unchanged') + + def test_state_replacement_before_directory_open_refuses(self): + original_open = MODULE.os.open + displaced = self.root / "displaced-state" + + def replace_before_open(path, flags, *arguments, **keywords): + if path == self.state: + self.state.rename(displaced) + self.state.mkdir(mode=0o700) + return original_open(path, flags, *arguments, **keywords) + + with patch.object(MODULE.os, "open", side_effect=replace_before_open), \ + self.assertRaisesRegex(RuntimeError, "changed before it was held"): + MODULE.Containers(self.configuration, self.state, "123-1", NONCE) + + def test_nonzero_create_with_full_id_remains_available_for_cleanup(self): + captured = [] + self.resources.record_created = lambda *value: captured.append(value) + with patch.object(self.resources, "command", return_value=result(124, IDENTITY + "\n")), \ + self.assertRaisesRegex(RuntimeError, "after returning"): + self.resources.create("server", ["inert-image"]) + self.assertEqual(captured, [("server", IDENTITY)]) + self.assertEqual(self.resources.identities, {"server": IDENTITY}) + + def test_timed_out_create_still_records_a_complete_stdout_id(self): + captured = [] + self.resources.record_created = lambda *value: captured.append(value) + with patch.object(self.resources, "command", side_effect=MODULE.FixtureTimeout((IDENTITY + "\n").encode())), \ + self.assertRaisesRegex(RuntimeError, "after returning"): + self.resources.create("server", ["inert-image"]) + self.assertEqual(captured, [("server", IDENTITY)]) + + def test_ambiguous_create_stdout_is_never_interpreted_as_a_name_or_id(self): + for output in ("name\n", IDENTITY, IDENTITY + "\nother\n"): + with self.subTest(output=output), tempfile.TemporaryDirectory() as temporary: + state = Path(temporary) + resources = MODULE.Containers(self.configuration, state, "123-1", NONCE) + try: + with patch.object(resources, "command", return_value=result(output=output)), self.assertRaises(RuntimeError): + resources.create("server", ["inert-image"]) + self.assertEqual(resources.identities, {}) + finally: + resources.close() + + def test_cleanup_uses_captured_id_and_observes_exact_absence(self): + absent = result(1, "[]\n", "error: no such object: " + IDENTITY + "\n") + with patch.object(self.resources, "command", side_effect=[ + result(output=json.dumps([owned_record()])), result(), absent]) as invoke: + self.resources.remove(IDENTITY, "server") + self.assertEqual([call.args[0] for call in invoke.call_args_list], [ + ["inspect", IDENTITY], ["rm", "--force", "--volumes", IDENTITY], ["inspect", IDENTITY]]) + + def test_one_cleanup_refusal_does_not_skip_the_other_role(self): + pairs = [("consumer", "c" * 64), ("server", IDENTITY)] + with patch.object(self.resources, "remove", side_effect=[RuntimeError("preserve"), None]) as remove, \ + self.assertRaisesRegex(RuntimeError, "could not be removed"): + self.resources.remove_all(pairs) + self.assertEqual([call.args for call in remove.call_args_list], [(identity, role) for role, identity in pairs]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ci/test_command_key_supply.py b/tests/ci/test_command_key_supply.py index dba56b06..2e4877b6 100644 --- a/tests/ci/test_command_key_supply.py +++ b/tests/ci/test_command_key_supply.py @@ -1,10 +1,10 @@ """Inert archive/installation provenance controls; no PHP or network.""" from copy import deepcopy -import hashlib import importlib.util import json from pathlib import Path +import stat import tempfile import unittest import zipfile @@ -36,7 +36,7 @@ def setUp(self): self.supply = {"sdk_version": "1.2.3", "sdk_reference": "a" * 40, "sdk_source_url": "https://example.invalid/sdk.git", "sdk_dist_url": "https://example.invalid/sdk.zip", - "sdk_archive_sha256": hashlib.sha256(self.archive.read_bytes()).hexdigest()} + "sdk_archive_members_sha256": MODULE.member_digest({"src/Client.php": (self.data, 0o644)})} entry = {"name": "durable-workflow/sdk", "version": "1.2.3", "source": {"url": self.supply["sdk_source_url"], "reference": self.supply["sdk_reference"]}, "dist": {"url": self.supply["sdk_dist_url"], "reference": self.supply["sdk_reference"]}} @@ -66,11 +66,33 @@ def test_github_dos_members_have_nonexecutable_default_permissions(self): member.external_attr = 0 self.assertEqual(("ordinary.php", 0o644), MODULE.archive_member(member)) - def test_archive_must_equal_the_approved_bytes(self): - self.supply["sdk_archive_sha256"] = "0" * 64 + def test_archive_members_must_equal_the_approved_inventory(self): + self.supply["sdk_archive_members_sha256"] = "0" * 64 with self.assertRaisesRegex(ValueError, "approved SDK supply"): self.verify() + def test_regenerated_wrapper_is_allowed_only_with_identical_members(self): + original = self.verify() + member = zipfile.ZipInfo("different-generated-root/src/Client.php", date_time=(2026, 1, 1, 0, 0, 0)) + member.external_attr = 0o100644 << 16 + with zipfile.ZipFile(self.archive, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.comment = b"inert wrapper change" + archive.writestr(member, self.data) + regenerated = self.verify() + self.assertNotEqual(original["archive_sha256"], regenerated["archive_sha256"]) + self.assertEqual(original["archive_members_sha256"], regenerated["archive_members_sha256"]) + + def test_changed_member_bytes_modes_and_inventory_refuse(self): + for raw, mode, extra in ((b"changed", 0o644, False), (self.data, 0o755, False), (self.data, 0o644, True)): + member = zipfile.ZipInfo("sdk/src/Client.php") + member.external_attr = (stat.S_IFREG | mode) << 16 + with zipfile.ZipFile(self.archive, "w") as archive: + archive.writestr(member, raw) + if extra: + archive.writestr("sdk/extra.php", b"inert") + with self.subTest(mode=mode, extra=extra), self.assertRaisesRegex(ValueError, "approved SDK supply"): + self.verify() + def test_changed_installed_module_refuses(self): (self.package / "src/Client.php").write_bytes(b"changed") with self.assertRaisesRegex(ValueError, "bytes differ"): diff --git a/tests/smoke/command-key-supply.json b/tests/smoke/command-key-supply.json index 11c78627..e5cd2b00 100644 --- a/tests/smoke/command-key-supply.json +++ b/tests/smoke/command-key-supply.json @@ -3,7 +3,6 @@ "sdk_reference": "f9483bb2b5b45f591a82d9e5419c5b60a6e967c6", "sdk_source_url": "https://github.com/durable-workflow/sdk-php.git", "sdk_dist_url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/f9483bb2b5b45f591a82d9e5419c5b60a6e967c6", - "sdk_archive_sha256": "32dc5d1a05a96e9e772e964bea95ae8142882b901d612c97a4aadee699f4ec6b", "server_version": "3.9.2", "server_source": "5614274d2629d03f92932a07dfee0fb10349ddd9", "server_index": "sha256:8ffcdb2a36af3129a1987849aafe8715fe0d3bf9a9648467f0b5cb8eeef43ba9", @@ -14,5 +13,6 @@ "php_amd64": "sha256:b2d2be78de4de803e20d1ae3df91c196e332a40cc851754472be4fab0fac1f03", "php_config": "sha256:6d664d15c9c1ad38a202a9b91736126c7c804d7669c3b7bfddd7ff6f7fdadba5", "loss_proxy_source": "117118c83093822469b3824ba9587ff4068ed29f", - "loss_proxy_sha256": "bd1e8ccbbf76f63fc2eafa278a023eb181d405b68d9c30016392e395a1eff725" + "loss_proxy_sha256": "bd1e8ccbbf76f63fc2eafa278a023eb181d405b68d9c30016392e395a1eff725", + "sdk_archive_members_sha256": "c08c3a741f21eff2a42bd9b8e383c88c02a162098d411659eae644497508b7bc" } diff --git a/tests/smoke/command-keys.md b/tests/smoke/command-keys.md index fd00df32..b31dafac 100644 --- a/tests/smoke/command-keys.md +++ b/tests/smoke/command-keys.md @@ -7,8 +7,8 @@ reference, runtime version and PHP version. The adapter bounds input to 64 KiB, accepts only a numeric loopback origin, and restricts fixture workflow and queue names to the `sdk-php-216-` prefix. -Before invocation, the owning acceptance runner verifies the registry archive -checksum, Composer lock/source/dist identities and installed SDK file bytes +Before invocation, the owning acceptance runner verifies the complete registry +archive member digest, Composer lock/source/dist identities and installed SDK file bytes against that selected supply. It owns the isolated runtime and endpoint, synthetic credential, namespace provisioning, finite process timeout and exact cleanup. Mount or capture the reviewed adapter, supply and autoloader inputs @@ -18,10 +18,13 @@ the runner supplies the independent archive/provenance proof. `verify_command_key_supply.py ` performs the package inspection before PHP loading. It checks the exact root requirement, absence of repository overrides, lock/source/dist URLs and refs, -dist installation, approved archive digest and every installed SDK file. It +dist installation, the approved digest of every archive path/body/mode and every installed SDK file. It accepts private umasks, normalizes GitHub's DOS nonexecutable entries and rejects added permissions, missing/extra/changed files or aliases. Its output -is explicitly inspection-only. Run its inert controls with +is explicitly inspection-only. Exact commit and member identities are the +durable binding because GitHub can regenerate ZIP wrapper bytes; each download's +raw ZIP hash is still retained as an observation. Changed, added or missing +members and changed executable modes refuse. Run its inert controls with `python3 -I -S -B tests/ci/test_command_key_supply.py`. Set `COMMAND_KEY_RUNTIME_URL` to the owned numeric loopback URL and @@ -68,31 +71,50 @@ generation until archive/installed-file inspection passes. The runner captures its exact committed source and the unchanged accepted shared loss fixture before loading them. Source checks and independent review precede execution. -The controller pulls the selected Server index for linux/amd64 and verifies -its image config/source. It creates one read-only, nonprivileged 1-CPU/1-GiB/ -256-PID runtime with private bounded tmpfs and one numeric loopback binding. -Every operation checks the captured container ID/owner/endpoint and installed -SDK bytes again. A private CID file supports cleanup on exceptions and the -workflow's separate always-run cleanup. Removal uses that ID after checking -its label; foreign replacements are preserved. A missing-object response, -rather than a general engine error, establishes absence. Sanitized evidence +The proposed workflow has three phases. Staging inspects the installed package +and captures its complete consumer/vendor/fixture include tree without loading +PHP. A root-controlled directory receives the verified copy. Preparation +creates the selected linux/amd64 Server and an idle, unprivileged PHP consumer. +Both have read-only root filesystems, 1-CPU/1-GiB/256-PID caps and bounded tmpfs. +The PHP consumer mounts the entire include tree read-only at `/closure`; only +its separate `/tmp` is writable. Cohort execution invokes the adapter inside +that consumer and checks the complete include inventory, mount configuration, +image identity and captured runtime endpoint before and after each operation. + +Each phase loads the controller from exact Git bytes through an independently +fixed literal bootstrap. Preparation captures full IDs from Docker create +stdout, keeps an exclusive private record descriptor open, and publishes the +IDs to workflow outputs before executing package code. The separate literal +always-run cleanup uses those outputs, a fresh empty Docker configuration and +owner/role/nonce labels. It needs no checkout, controller import or state file. +It attempts both removals, preserves foreign replacements, and accepts only an +exact missing-object response as proof of absence. Root-stage removal also +requires the original root-owned directory device/inode. Sanitized evidence and the dependency lock are retained for seven days. -Run inert runner controls with -`python3 -I -S -B tests/ci/test_command_key_cohort.py`. They exercise synthetic -inspection metadata and receipts without creating a runtime or loading PHP. +Run the inert controls with: + +```sh +python3 -I -S -B tests/ci/test_command_key_cohort.py +python3 -I -S -B tests/ci/test_command_key_containers.py +python3 -I -S -B tests/ci/test_command_key_include_closure.py +``` + +They exercise synthetic inspection metadata, captured-ID substitution, +independent cleanup, include inventories and receipts without creating a +runtime or loading PHP. -The proposed include-tree capture is data-only preparation for the required -read-only consumer boundary. `command_key_include_closure.py` inventories every +`command_key_include_closure.py` inventories every consumer/vendor/fixture leaf, rejects aliases, hardlinks, repository metadata and unexpected root members, and copies verified bytes with read-only modes. -Its output includes a complete member/hash inventory. Before any installed -execution, that complete tree still needs a root-controlled staging location, -a kernel-enforced read-only consumer mount, before/after source and mount -identity checks, independent container-ID cleanup and actual boundary controls. -File modes and the inert inventory tests alone provide no isolation verdict. -The proposed official PHP image identities in the supply are raw-registry -metadata bindings; this prototype has not pulled or qualified that consumer. +Its output includes a complete member/hash inventory. The workflow wiring is +proposed source, with installed execution on hold. Actual nested-file +overwrite/rename controls, a writable-mount countercontrol, full consumer +profile qualification and independent review are still required. File modes +and the inert inventory tests alone provide no isolation verdict. The proposed +official PHP image identities in the supply are raw-registry metadata bindings; +this prototype has not pulled or qualified that consumer. The complete SDK +member binding is proposed source and also requires independent review. Run the offline input controls against an existing immutable PHP image: diff --git a/tests/smoke/command_key_cohort.py b/tests/smoke/command_key_cohort.py index a29724cb..7a257bea 100644 --- a/tests/smoke/command_key_cohort.py +++ b/tests/smoke/command_key_cohort.py @@ -2,14 +2,12 @@ import argparse import concurrent.futures -from contextlib import contextmanager import hashlib import json import os from pathlib import Path import re import secrets -import shutil import stat import subprocess import threading @@ -19,8 +17,8 @@ HERE = Path(__file__).resolve().parent -INPUTS = ("command-key-client.php", "command-key-supply.json", "verify_command_key_supply.py", "command_key_cohort.py") -DOCKER_CONFIG = None +INPUTS = ("command-key-client.php", "command-key-supply.json", "verify_command_key_supply.py", + "command_key_cohort.py", "command_key_containers.py", "command_key_include_closure.py") def require(condition, message): @@ -49,131 +47,102 @@ def module_from_bytes(name, raw): return module -def docker_environment(): - require(DOCKER_CONFIG is not None, "An explicit empty Docker configuration is required") - private_state(DOCKER_CONFIG) - require(not list(DOCKER_CONFIG.iterdir()), "Docker configuration must remain empty") - return {"PATH": "/usr/bin:/bin", "DOCKER_CONFIG": str(DOCKER_CONFIG), - "DOCKER_HOST": "unix:///var/run/docker.sock"} - - -def exact_missing(process, identity): - return (process.returncode == 1 and process.stdout == "[]\n" - and process.stderr in {"Error: No such object: " + identity + "\n", - "error: no such object: " + identity + "\n"}) - - -def inspect_runtime(identity, allow_missing=False): - process = subprocess.run(["docker", "inspect", identity], capture_output=True, text=True, timeout=15, - env=docker_environment()) - if allow_missing and exact_missing(process, identity): - return None - require(process.returncode == 0, "The captured runtime cannot be inspected") - records = json.loads(process.stdout) - require(isinstance(records, list) and len(records) == 1, "The captured runtime is ambiguous") - return records[0] - - -def runtime_origin(identity, owner, supply): - require(re.fullmatch("[0-9a-f]{64}", identity) is not None, "A captured full runtime ID is required") - require(re.fullmatch("[0-9]+-[0-9]+", owner) is not None, "The workflow run owner is required") - record = inspect_runtime(identity) - require(record["Id"] == identity and record["State"]["Running"], "The captured runtime is not running") - require(record["Config"]["Labels"].get("durable-workflow.php-command-keys") == owner - and record["Config"]["Labels"].get("org.opencontainers.image.revision") == supply["server_source"], - "The runtime belongs to another run") - require(record["Image"] == supply["server_config"], "The runtime image config differs") +def stage_inventory(root, expected, root_identity, closure): + require(root.parent == Path("/run") and re.fullmatch(r"sdk-php-command-keys\.[A-Za-z0-9]{6}", root.name), + "The include closure must use its root-controlled staging location") + metadata = root.lstat() + require(root.resolve() == root and stat.S_ISDIR(metadata.st_mode) and metadata.st_uid == 0, + "The include staging root is not root controlled") + require(root_identity == str(metadata.st_dev) + ":" + str(metadata.st_ino), "The include staging inode differs") + result = closure.inventory(root, 0, sealed=True) + require(result["sha256"] == expected, "The complete include inventory differs") + return result + + +def confined(record, configuration): + require(record["State"]["Running"] and record["Image"] == configuration, + "The fixture is not running the selected image configuration") host = record["HostConfig"] require(host["ReadonlyRootfs"] and not host["Privileged"] and not host.get("Binds") and host["Init"] and host["CapDrop"] == ["ALL"] and not host.get("CapAdd") - and "no-new-privileges" in host["SecurityOpt"] and not host.get("Devices") - and host["NetworkMode"] == "bridge", - "The runtime filesystem is outside the owned fixture profile") + and "no-new-privileges" in host["SecurityOpt"] and not host.get("Devices"), + "The fixture privileges differ") require(0 < host["Memory"] <= 1024**3 and 0 < host["NanoCpus"] <= 10**9 - and 0 < host["PidsLimit"] <= 256, "The runtime resource caps differ") - require(all(mount["Type"] == "tmpfs" for mount in record["Mounts"]), "Only private tmpfs mounts are allowed") - require(set(host["Tmpfs"]) == {"/tmp", "/app/database", "/app/storage"}, "Unexpected runtime writable paths") - require(set(record["NetworkSettings"]["Ports"]) == {"8080/tcp"}, "Unexpected published runtime ports") - ports = record["NetworkSettings"]["Ports"].get("8080/tcp") + and 0 < host["PidsLimit"] <= 256, "The fixture resource caps differ") + + +def runtime_origin(resources, identity, supply): + record = resources.owned(identity, "server") + confined(record, supply["server_config"]) + require(record["Config"]["Labels"].get("org.opencontainers.image.revision") == supply["server_source"], + "The native source identity differs") + require(record["HostConfig"]["NetworkMode"] == "bridge", "The native network profile differs") + require(all(mount["Type"] == "tmpfs" for mount in record["Mounts"]), "Unexpected native bind or volume") + require(set(record["HostConfig"]["Tmpfs"]) == {"/tmp", "/app/database", "/app/storage"}, + "Unexpected native writable paths") + require({mount["Destination"] for mount in record["Mounts"]} == {"/tmp", "/app/database", "/app/storage"}, + "The native writable mount destinations differ") + require(set(record["NetworkSettings"]["Ports"]) == {"8080/tcp"}, "Unexpected native published ports") + ports = record["NetworkSettings"]["Ports"]["8080/tcp"] require(isinstance(ports, list) and len(ports) == 1 and ports[0]["HostIp"] == "127.0.0.1", - "The runtime must have one numeric loopback binding") + "The native fixture must have one numeric loopback binding") port = ports[0]["HostPort"] - require(re.fullmatch("[1-9][0-9]{0,4}", port) is not None and int(port) <= 65535, - "The runtime port is invalid") + require(re.fullmatch(r"[1-9][0-9]{0,4}", port) and int(port) <= 65535, "Invalid fixture port") return "http://127.0.0.1:" + port -def remove_runtime(identity, owner): - record = inspect_runtime(identity, allow_missing=True) - if record is None: - return - require(record["Id"] == identity - and record["Config"]["Labels"].get("durable-workflow.php-command-keys") == owner, - "Runtime ownership changed; preserve the replacement") - subprocess.run(["docker", "rm", "--force", "--volumes", identity], check=True, - stdout=subprocess.DEVNULL, timeout=30, env=docker_environment()) - missing = subprocess.run(["docker", "inspect", identity], capture_output=True, text=True, timeout=15, - env=docker_environment()) - require(exact_missing(missing, identity), - "Exact runtime absence was not established") - - -def private_state(directory): - metadata = directory.lstat() - require(directory.resolve() == directory and stat.S_ISDIR(metadata.st_mode) - and metadata.st_uid == os.getuid() and stat.S_IMODE(metadata.st_mode) == 0o700, - "Runtime state must be a canonical private owned directory") - return directory / "runtime.cid" - - -def cleanup_state(directory, owner): - cidfile = private_state(directory) - if not cidfile.exists() and not cidfile.is_symlink(): - return - metadata = cidfile.lstat() - require(stat.S_ISREG(metadata.st_mode) and metadata.st_uid == os.getuid() - and metadata.st_nlink == 1 and metadata.st_size == 64, - "Runtime cleanup needs the captured single-link CID file") - identity = cidfile.read_text() - require(re.fullmatch("[0-9a-f]{64}", identity) is not None, "Invalid cleanup runtime ID") - remove_runtime(identity, owner) - - -@contextmanager -def owned_runtime(owner, supply, token, directory): - require(re.fullmatch("[0-9]+-[0-9]+", owner) is not None, "The workflow run owner is required") - image = "durableworkflow/server@" + supply["server_index"] - subprocess.run(["docker", "pull", "--platform", "linux/amd64", image], check=True, - stdout=subprocess.DEVNULL, timeout=180, env=docker_environment()) - raw = subprocess.check_output(["docker", "image", "inspect", image], timeout=15, env=docker_environment()) - selected = json.loads(raw)[0] - require(selected["Id"] == supply["server_config"] and selected["Os"] == "linux" - and selected["Architecture"] == "amd64" - and selected["Config"]["Labels"].get("org.opencontainers.image.revision") == supply["server_source"], - "The pulled immutable runtime identity differs") - cidfile = private_state(directory) - require(not list(directory.iterdir()), "Runtime state must start empty") +def consumer_record(resources, identity, supply, root): + record = resources.owned(identity, "consumer") + confined(record, supply["php_config"]) + host = record["HostConfig"] + require(host["NetworkMode"] == "host" and not record["NetworkSettings"]["Ports"], + "The PHP fixture network profile differs") + require(record["Config"]["User"] == str(os.getuid()) + ":" + str(os.getgid()) and os.getuid() != 0, + "The PHP fixture must use the unprivileged caller identity") + require(set(host["Tmpfs"]) == {"/tmp"}, "Unexpected PHP writable paths") + binds = [mount for mount in record["Mounts"] if mount["Type"] == "bind"] + require(len(binds) == 1 and binds[0]["Source"] == str(root) + and binds[0]["Destination"] == "/closure" and binds[0]["RW"] is False, + "The complete include closure must be mounted read-only") + require(all(mount["Type"] == "tmpfs" or mount in binds for mount in record["Mounts"]), + "Unexpected PHP volume or mount") + require({mount["Destination"] for mount in record["Mounts"]} == {"/closure", "/tmp"}, + "Unexpected PHP writable mount destinations") + return record + + +def publish_outputs(values): + output = os.environ.get("GITHUB_OUTPUT") + require(isinstance(output, str) and output, "The trusted workflow output file is required") + descriptor = os.open(output, os.O_WRONLY | os.O_APPEND | os.O_NOFOLLOW) + try: + metadata = os.fstat(descriptor) + require(stat.S_ISREG(metadata.st_mode) and metadata.st_uid == os.getuid() and metadata.st_nlink == 1, + "The workflow output file identity differs") + raw = "".join(key + "=" + value + "\n" for key, value in values.items()).encode() + offset = 0 + while offset < len(raw): + written = os.write(descriptor, raw[offset:]) + require(written > 0, "The workflow outputs could not be written") + offset += written + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def prepare_containers(resources, supply, token, root): try: - subprocess.run(["docker", "create", "--cidfile", str(cidfile), "--init", "--read-only", - "--name", "sdk-php-command-keys-" + owner + "-" + secrets.token_hex(4), - "--label", "durable-workflow.php-command-keys=" + owner, - "--cpus", "1", "--memory", "1g", "--pids-limit", "256", "--network", "bridge", - "--cap-drop", "ALL", "--security-opt", "no-new-privileges", - "--tmpfs", "/tmp:mode=1777,size=64m", - "--tmpfs", "/app/storage:uid=1000,gid=1000,mode=0700,size=64m", - "--tmpfs", "/app/database:uid=1000,gid=1000,mode=0700,size=32m", - "--publish", "127.0.0.1::8080", "--env", "DB_CONNECTION=sqlite", - "--env", "DW_AUTH_DRIVER=token", "--env", "DW_AUTH_TOKEN=" + token, image], - check=True, stdout=subprocess.DEVNULL, timeout=30, env=docker_environment()) - identity = cidfile.read_text() - require(re.fullmatch("[0-9a-f]{64}", identity) is not None, "Docker did not capture a full runtime ID") - created = inspect_runtime(identity) - require(created["Id"] == identity - and created["Config"]["Labels"].get("durable-workflow.php-command-keys") == owner, - "The created runtime identity differs") - subprocess.run(["docker", "start", identity], check=True, stdout=subprocess.DEVNULL, timeout=30, - env=docker_environment()) - origin = runtime_origin(identity, owner, supply) + server_image = "durableworkflow/server@" + supply["server_index"] + native = resources.pull(server_image, supply["server_config"]) + require(native["Config"]["Labels"].get("org.opencontainers.image.revision") == supply["server_source"], + "The selected native source differs") + server = resources.create("server", ["--network", "bridge", "--tmpfs", "/tmp:mode=1777,size=64m", + "--tmpfs", "/app/storage:uid=1000,gid=1000,mode=0700,size=64m", + "--tmpfs", "/app/database:uid=1000,gid=1000,mode=0700,size=32m", + "--publish", "127.0.0.1::8080", "--env", "DB_CONNECTION=sqlite", + "--env", "DW_AUTH_DRIVER=token", "--env", "DW_AUTH_TOKEN=" + token, server_image]) + resources.start(server, "server") + origin = runtime_origin(resources, server, supply) opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) for _ in range(30): try: @@ -184,43 +153,62 @@ def owned_runtime(owner, supply, token, directory): pass time.sleep(1) else: - raise RuntimeError("The selected runtime did not become ready") - yield identity, origin - finally: - # The workflow repeats this cleanup if its outer timeout interrupts us. - cleanup_state(directory, owner) + raise RuntimeError("The selected native fixture did not become ready") + php_image = "php@" + supply["php_index"] + php = resources.pull(php_image, supply["php_config"]) + require("PHP_VERSION=" + supply["php_version"] in php["Config"]["Env"], "The PHP image version differs") + user = str(os.getuid()) + ":" + str(os.getgid()) + require(os.getuid() > 0, "The PHP fixture cannot run as root") + consumer = resources.create("consumer", ["--network", "host", "--user", user, + "--workdir", "/closure/consumer", "--tmpfs", "/tmp:mode=1777,size=128m", + "--mount", "type=bind,src=" + str(root) + ",dst=/closure,readonly", php_image, + "php", "-c", "/usr/local/etc/php/php.ini-production", "-r", "sleep(900);"]) + resources.start(consumer, "consumer") + consumer_record(resources, consumer, supply, root) + probe = resources.command(["exec", consumer, "php", "-c", "/usr/local/etc/php/php.ini-production", "-r", + 'echo json_encode(["version"=>PHP_VERSION,"ini"=>php_ini_loaded_file(),' + '"coverage"=>extension_loaded("xdebug")||extension_loaded("pcov")]);']) + require(probe.returncode == 0, "The PHP profile probe failed") + observed = json.loads(probe.stdout) + require(observed == {"version": supply["php_version"], "ini": "/usr/local/etc/php/php.ini-production", + "coverage": False}, "The PHP production profile differs") + return server, consumer + except BaseException: + resources.remove_all(reversed(list(resources.identities.items()))) + raise class Consumer: - def __init__(self, consumer, archive, supply, sources, verifier, origin, token, runtime_id, owner): - self.consumer, self.archive, self.supply = consumer, archive, supply - self.sources, self.verifier, self.origin, self.token = sources, verifier, origin, token - self.runtime_id, self.owner = runtime_id, owner - self.php = shutil.which("php") - require(self.php is not None, "The qualified PHP executable is required") - require(isinstance(token, str) and token.startswith("sdk-php-216-"), "A synthetic fixture token is required") - self.package = verifier.verify(consumer, archive, supply) + def __init__(self, resources, container, runtime_id, supply, sources, origin, token, root, expected, root_identity, closure): + self.resources, self.container, self.runtime_id = resources, container, runtime_id + self.supply, self.sources, self.origin, self.token = supply, sources, origin, token + self.root, self.expected, self.root_identity, self.closure = root, expected, root_identity, closure + self.include_inventory = stage_inventory(root, expected, root_identity, closure) def call(self, packet, origin=None): - require(runtime_origin(self.runtime_id, self.owner, self.supply) == self.origin, - "The captured runtime endpoint changed") + require(runtime_origin(self.resources, self.runtime_id, self.supply) == self.origin, + "The captured native endpoint changed") + consumer_record(self.resources, self.container, self.supply, self.root) + require(stage_inventory(self.root, self.expected, self.root_identity, self.closure) == self.include_inventory, + "The complete include tree changed before invocation") for name, raw in self.sources.items(): require((HERE / name).read_bytes() == raw, "The captured runner source changed") - require(self.verifier.verify(self.consumer, self.archive, self.supply) == self.package, - "The installed package changed before invocation") - environment = {"PATH": "/usr/bin:/bin", "COMMAND_KEY_RUNTIME_URL": origin or self.origin, - "COMMAND_KEY_TOKEN": self.token} - process = subprocess.run([self.php, str(HERE / "command-key-client.php"), - str(self.consumer / "vendor/autoload.php")], input=json.dumps(packet), - text=True, capture_output=True, env=environment, timeout=20) - require(process.returncode == 0, "The PHP fixture operation failed") - require(0 < len(process.stdout) <= 1024**2, "The PHP fixture evidence is empty or oversized") - result = json.loads(process.stdout) - require(result.get("sdk_version") == self.supply["sdk_version"] - and result.get("sdk_reference") == self.supply["sdk_reference"] - and result.get("server_version") == self.supply["server_version"] - and result.get("source_fallback") is False, "The API operation used another supply") - return result + result = self.resources.command(["exec", "--interactive", "--env", "COMMAND_KEY_RUNTIME_URL=" + (origin or self.origin), + "--env", "COMMAND_KEY_TOKEN=" + self.token, self.container, "php", "-c", + "/usr/local/etc/php/php.ini-production", "/closure/fixture/command-key-client.php", + "/closure/consumer/vendor/autoload.php"], input=json.dumps(packet), timeout=20) + require(result.returncode == 0, "The isolated PHP fixture operation failed") + require(0 < len(result.stdout) <= 1024**2, "The PHP fixture evidence is empty or oversized") + require(stage_inventory(self.root, self.expected, self.root_identity, self.closure) == self.include_inventory, + "The complete include tree changed during invocation") + consumer_record(self.resources, self.container, self.supply, self.root) + response = json.loads(result.stdout) + require(response.get("sdk_version") == self.supply["sdk_version"] + and response.get("sdk_reference") == self.supply["sdk_reference"] + and response.get("server_version") == self.supply["server_version"] + and response.get("php_version") == self.supply["php_version"] + and response.get("source_fallback") is False, "The API operation used another supply") + return response def answered(self, packet, origin=None): response = self.call(packet, origin) @@ -394,46 +382,91 @@ def loss_cell(client, module, kind): def main(): - global DOCKER_CONFIG parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--phase", choices=("stage", "prepare", "cohort"), required=True) parser.add_argument("--consumer", type=Path) parser.add_argument("--archive", type=Path) parser.add_argument("--proxy-source", type=Path) + parser.add_argument("--stage-root", type=Path, required=True) + parser.add_argument("--include-sha256") + parser.add_argument("--root-identity") parser.add_argument("--runtime-state", type=Path, required=True) parser.add_argument("--docker-config", type=Path, required=True) parser.add_argument("--runtime-owner", required=True) parser.add_argument("--runner-source", required=True) - parser.add_argument("--cleanup-only", action="store_true") + parser.add_argument("--server-id") + parser.add_argument("--consumer-id") + parser.add_argument("--nonce") options = parser.parse_args() - DOCKER_CONFIG = options.docker_config - docker_environment() + require(re.fullmatch(r"[0-9]+-[0-9]+", options.runtime_owner), "Invalid fixture run owner") sources = capture_inputs(options.runner_source) - require(re.fullmatch("[0-9]+-[0-9]+", options.runtime_owner) is not None, "Invalid runtime owner") - if options.cleanup_only: - cleanup_state(options.runtime_state, options.runtime_owner) - print(json.dumps({"runtime_removed": True})) - return - require(options.consumer is not None and options.archive is not None and options.proxy_source is not None, - "The registry consumer, archive and proxy source are required") supply = json.loads(sources["command-key-supply.json"]) - proxy_raw = options.proxy_source.read_bytes() - require(hashlib.sha256(proxy_raw).hexdigest() == supply["loss_proxy_sha256"], "The shared loss fixture source differs") - verifier = module_from_bytes("verify_command_key_supply", sources["verify_command_key_supply.py"]) - verifier.verify(options.consumer, options.archive, supply) - proxy = module_from_bytes("committed_response_loss", proxy_raw) - token = "sdk-php-216-" + secrets.token_hex(24) - with owned_runtime(options.runtime_owner, supply, token, options.runtime_state) as (runtime_id, origin): - client = Consumer(options.consumer, options.archive, supply, sources, verifier, origin, - token, runtime_id, options.runtime_owner) - direct = [direct_cell(client, mode) for mode in ("open", "close", "continue")] - unicode = unicode_cell(client) - loss = [loss_cell(client, proxy, kind) for kind in ("signal", "update")] - print(json.dumps({"outcome": "pass", "scope": "modern-direct-and-postcommit-loss-only", - "runner_source": options.runner_source, - "runner_inputs": {name: hashlib.sha256(raw).hexdigest() for name, raw in sources.items()}, - "package": client.package, "server_index": supply["server_index"], - "server_platform": "linux/amd64", "server_config": supply["server_config"], - "direct": direct, "unicode": unicode, "loss": loss, "runtime_removed": True})) + closure = module_from_bytes("command_key_include_closure", sources["command_key_include_closure.py"]) + if options.phase == "stage": + require(options.consumer is not None and options.archive is not None, + "The registry consumer and approved archive are required") + verifier = module_from_bytes("verify_command_key_supply", sources["verify_command_key_supply.py"]) + package = verifier.verify(options.consumer, options.archive, supply) + fixture = options.stage_root.parent / "captured-fixture" + fixture.mkdir(mode=0o700) + for name in ("command-key-client.php", "command-key-supply.json"): + descriptor = os.open(fixture / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400) + with os.fdopen(descriptor, "wb") as output: + output.write(sources[name]) + include = closure.stage(options.consumer, fixture, options.stage_root) + print(json.dumps({"include_sha256": include["sha256"], "members": len(include["entries"]), + "bytes": include["bytes"], "package": package, "inspection_only": True})) + return + require(isinstance(options.include_sha256, str) and re.fullmatch(r"[0-9a-f]{64}", options.include_sha256), + "The captured include digest is required") + include = stage_inventory(options.stage_root, options.include_sha256, options.root_identity, closure) + containers = module_from_bytes("command_key_containers", sources["command_key_containers.py"]) + nonce = secrets.token_hex(16) if options.phase == "prepare" else options.nonce + created = (lambda role, identity: publish_outputs({role + "_id": identity})) if options.phase == "prepare" else None + resources = containers.Containers(options.docker_config, options.runtime_state, options.runtime_owner, nonce, created) + try: + if options.phase == "prepare": + try: + token = "sdk-php-216-" + secrets.token_hex(24) + publish_outputs({"nonce": nonce, "token": token}) + server, consumer = prepare_containers(resources, supply, token, options.stage_root) + stage_inventory(options.stage_root, options.include_sha256, options.root_identity, closure) + # This step ends before any package code executes. GitHub retains + # these outputs independently of the mutable checkout/state. + print(json.dumps({"prepared": True, "package_executed": False, + "server_config": supply["server_config"], "php_config": supply["php_config"]})) + return + except BaseException: + resources.remove_all(reversed(list(resources.identities.items()))) + raise + token = os.environ.get("COMMAND_KEY_TOKEN") + require(isinstance(token, str) and re.fullmatch(r"sdk-php-216-[0-9a-f]{48}", token), + "The prepared synthetic credential is required") + require(options.proxy_source is not None, "The accepted response-loss fixture is required") + proxy_raw = options.proxy_source.read_bytes() + require(hashlib.sha256(proxy_raw).hexdigest() == supply["loss_proxy_sha256"], "The accepted loss fixture differs") + proxy = module_from_bytes("committed_response_loss", proxy_raw) + try: + origin = runtime_origin(resources, options.server_id, supply) + consumer_record(resources, options.consumer_id, supply, options.stage_root) + client = Consumer(resources, options.consumer_id, options.server_id, supply, sources, origin, token, + options.stage_root, options.include_sha256, options.root_identity, closure) + direct = [direct_cell(client, mode) for mode in ("open", "close", "continue")] + unicode = unicode_cell(client) + loss = [loss_cell(client, proxy, kind) for kind in ("signal", "update")] + finally: + resources.remove_all([("consumer", options.consumer_id), ("server", options.server_id)]) + print(json.dumps({"outcome": "pass", "scope": "modern-direct-and-postcommit-loss-only", + "runner_source": options.runner_source, + "runner_inputs": {name: hashlib.sha256(raw).hexdigest() for name, raw in sources.items()}, + "sdk_version": supply["sdk_version"], "sdk_reference": supply["sdk_reference"], + "include_sha256": include["sha256"], "include_members": len(include["entries"]), + "server_index": supply["server_index"], "server_platform": "linux/amd64", + "server_config": supply["server_config"], "php_index": supply["php_index"], + "php_config": supply["php_config"], "php_version": supply["php_version"], + "direct": direct, "unicode": unicode, "loss": loss, "runtime_removed": True})) + finally: + resources.close() if __name__ == "__main__": diff --git a/tests/smoke/command_key_containers.py b/tests/smoke/command_key_containers.py new file mode 100644 index 00000000..c85b3aac --- /dev/null +++ b/tests/smoke/command_key_containers.py @@ -0,0 +1,172 @@ +"""Two finite command-key fixture roles with captured-ID lifecycle control.""" + +import json +import os +from pathlib import Path +import re +import stat +import subprocess +from types import SimpleNamespace + + +OWNER = "durable-workflow.php-command-keys" +ROLE = "durable-workflow.fixture-role" +NONCE = "durable-workflow.fixture-nonce" +ROLES = {"server", "consumer"} + + +class FixtureTimeout(RuntimeError): + def __init__(self, output): + super().__init__("The bounded fixture command timed out") + self.output = output.decode() if isinstance(output, bytes) else (output or "") + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def private_directory(path): + metadata = path.lstat() + require(path.resolve() == path and stat.S_ISDIR(metadata.st_mode) + and metadata.st_uid == os.getuid() and stat.S_IMODE(metadata.st_mode) == 0o700, + "Fixture control directories must be canonical, private and owned") + return metadata + + +def exact_missing(process, identity): + return (process.returncode == 1 and process.stdout == "[]\n" + and process.stderr in {"Error: No such object: " + identity + "\n", + "error: no such object: " + identity + "\n"}) + + +class Containers: + def __init__(self, configuration, state, owner, nonce, record_created=None): + require(isinstance(owner, str) and re.fullmatch(r"[0-9]+-[0-9]+", owner), "Invalid fixture run owner") + require(isinstance(nonce, str) and re.fullmatch(r"[0-9a-f]{32}", nonce), "Invalid fixture nonce") + private_directory(configuration) + initial = private_directory(state) + self.configuration, self.state, self.owner, self.nonce = configuration, state, owner, nonce + self.state_descriptor = os.open(state, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + metadata = os.fstat(self.state_descriptor) + current = state.lstat() + if ((metadata.st_dev, metadata.st_ino) != (current.st_dev, current.st_ino) + or (metadata.st_dev, metadata.st_ino) != (initial.st_dev, initial.st_ino) + or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o700): + os.close(self.state_descriptor) + raise RuntimeError("The fixture state directory changed before it was held") + self.records = {} + self.identities = {} + self.record_created = record_created + + def close(self): + for descriptor in self.records.values(): + os.close(descriptor) + self.records.clear() + if self.state_descriptor is not None: + os.close(self.state_descriptor) + self.state_descriptor = None + + def environment(self): + private_directory(self.configuration) + require(not list(self.configuration.iterdir()), "Docker configuration must remain empty") + return {"PATH": "/usr/bin:/bin", "DOCKER_CONFIG": str(self.configuration), + "DOCKER_HOST": "unix:///var/run/docker.sock"} + + def command(self, arguments, *, timeout=30, input=None): + try: + return subprocess.run(["/usr/bin/docker", *arguments], env=self.environment(), input=input, + capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired as error: + raise FixtureTimeout(error.stdout) from None + + def inspect(self, identity, *, allow_missing=False): + require(isinstance(identity, str) and re.fullmatch(r"[0-9a-f]{64}", identity), + "A full captured container ID is required") + result = self.command(["inspect", identity], timeout=15) + if allow_missing and exact_missing(result, identity): + return None + require(result.returncode == 0, "The captured container cannot be inspected") + rows = json.loads(result.stdout) + require(isinstance(rows, list) and len(rows) == 1 and rows[0]["Id"] == identity, + "The captured container identity differs") + return rows[0] + + def owned(self, identity, role, *, allow_missing=False): + require(role in ROLES, "Unexpected fixture role") + record = self.inspect(identity, allow_missing=allow_missing) + if record is None: + return None + labels = record["Config"].get("Labels") or {} + require((labels.get(OWNER), labels.get(ROLE), labels.get(NONCE)) == (self.owner, role, self.nonce), + "Container ownership changed; preserve the replacement") + return record + + def pull(self, image, configuration): + require(re.fullmatch(r"(?:php|durableworkflow/server)@sha256:[0-9a-f]{64}", image) is not None, + "An exact public fixture image is required") + require(self.command(["pull", "--platform", "linux/amd64", image], timeout=180).returncode == 0, + "The selected public fixture image could not be pulled") + result = self.command(["image", "inspect", image], timeout=15) + require(result.returncode == 0, "The selected fixture image cannot be inspected") + rows = json.loads(result.stdout) + require(len(rows) == 1 and rows[0]["Id"] == configuration + and rows[0]["Os"] == "linux" and rows[0]["Architecture"] == "amd64", + "The selected public image configuration differs") + return rows[0] + + def create(self, role, arguments): + require(role in ROLES and role not in self.records, "A fixture role may be created only once") + descriptor = os.open(role + ".json", os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=self.state_descriptor) + self.records[role] = descriptor + flags = ["create", "--name", "sdk-php-command-keys-" + self.owner + "-" + self.nonce[:10] + "-" + role, + "--label", OWNER + "=" + self.owner, "--label", ROLE + "=" + role, + "--label", NONCE + "=" + self.nonce, "--init", "--read-only", + "--cpus", "1", "--memory", "1g", "--pids-limit", "256", + "--cap-drop", "ALL", "--security-opt", "no-new-privileges"] + try: + result = self.command([*flags, *arguments], timeout=30) + except FixtureTimeout as error: + result = SimpleNamespace(returncode=124, stdout=error.output) + require(re.fullmatch(r"[0-9a-f]{64}\n", result.stdout) is not None, + "Container creation did not return one full ID") + identity = result.stdout.rstrip("\n") + self.identities[role] = identity + # Retain the create stdout independently before any later preparation + # operation can fail. No package code runs in this preparation phase. + if self.record_created is not None: + self.record_created(role, identity) + record = {"id": identity, "owner": self.owner, "role": role, "nonce": self.nonce} + raw = (json.dumps(record, sort_keys=True) + "\n").encode() + offset = 0 + while offset < len(raw): + written = os.write(descriptor, raw[offset:]) + require(written > 0, "The held fixture record could not be written") + offset += written + os.fsync(descriptor) + # Keep this descriptor held for the complete preparation phase. + require(result.returncode == 0, "Container creation failed after returning its identity") + self.owned(identity, role) + return identity + + def start(self, identity, role): + self.owned(identity, role) + require(self.command(["start", identity]).returncode == 0, "The captured fixture could not start") + + def remove(self, identity, role): + if self.owned(identity, role, allow_missing=True) is None: + return + require(self.command(["rm", "--force", "--volumes", identity]).returncode == 0, + "The captured fixture could not be removed") + require(self.owned(identity, role, allow_missing=True) is None, + "Exact fixture absence was not established") + + def remove_all(self, pairs): + failed = False + for role, identity in pairs: + try: + self.remove(identity, role) + except Exception: + failed = True + require(not failed, "One or more captured fixtures could not be removed") diff --git a/tests/smoke/verify_command_key_supply.py b/tests/smoke/verify_command_key_supply.py index 7e19163f..1f1d9c06 100644 --- a/tests/smoke/verify_command_key_supply.py +++ b/tests/smoke/verify_command_key_supply.py @@ -79,8 +79,6 @@ def archive_files(archive, supply): if not 0 < size <= MAX_ARCHIVE_BYTES: raise ValueError("The registry archive exceeds its byte bound") digest = hashlib.sha256(archive.read_bytes()).hexdigest() - if digest != supply["sdk_archive_sha256"]: - raise ValueError("The registry archive differs from the approved SDK supply") expected = {} total = 0 @@ -101,9 +99,17 @@ def archive_files(archive, supply): if total > MAX_PACKAGE_BYTES: raise ValueError("The package expands beyond its byte bound") expected[name] = (source.read(entry), mode) + if member_digest(expected) != supply["sdk_archive_members_sha256"]: + raise ValueError("The registry archive members differ from the approved SDK supply") return expected, digest, total +def member_digest(files): + members = {name: {"mode": mode, "bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()} + for name, (raw, mode) in files.items()} + return hashlib.sha256(json.dumps(members, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + def installed_files(package, expected): actual = set() for path in package.rglob("*"): @@ -135,7 +141,7 @@ def verify(consumer, archive, supply): expected, digest, total = archive_files(Path(archive), supply) count = installed_files(package, expected) return {"sdk_version": supply["sdk_version"], "sdk_reference": supply["sdk_reference"], - "archive_sha256": digest, "installed_files": count, + "archive_sha256": digest, "archive_members_sha256": member_digest(expected), "installed_files": count, "installed_bytes": total, "source_fallback": False, "inspection_only": True} From 3ec47331bd8b2c7f88d38cd64737ccfb995bdf3f Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:57:17 +0000 Subject: [PATCH 09/17] Clarify inert closure assertions for static analysis --- tests/ci/test_command_key_cohort.py | 2 +- tests/ci/test_command_key_include_closure.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/ci/test_command_key_cohort.py b/tests/ci/test_command_key_cohort.py index 04b8d091..641ffcdb 100644 --- a/tests/ci/test_command_key_cohort.py +++ b/tests/ci/test_command_key_cohort.py @@ -100,7 +100,7 @@ def test_native_writable_paths_and_mounts_cannot_expand(self): self.origin(record) def test_complete_php_include_closure_is_readonly_and_unprivileged(self): - self.assertEqual(self.php(consumer())["Mounts"][0]["RW"], False) + self.assertFalse(self.php(consumer())["Mounts"][0]["RW"]) def test_writable_partial_replaced_or_shadowed_include_mount_refuses(self): mutations = [lambda r: r["Mounts"][0].update(RW=True), diff --git a/tests/ci/test_command_key_include_closure.py b/tests/ci/test_command_key_include_closure.py index b41ec0ec..df04e902 100644 --- a/tests/ci/test_command_key_include_closure.py +++ b/tests/ci/test_command_key_include_closure.py @@ -88,11 +88,12 @@ def test_writable_sealed_member_and_wrong_owner_refuse(self): self.stage() member = self.output / "consumer/vendor/autoload.php" member.chmod(0o600) + owner = os.getuid() with self.assertRaisesRegex(ValueError, "write permissions"): - MODULE.inventory(self.output, os.getuid(), sealed=True) + MODULE.inventory(self.output, owner, sealed=True) member.chmod(0o400) with self.assertRaisesRegex(ValueError, "ownership"): - MODULE.inventory(self.output, os.getuid() + 1, sealed=True) + MODULE.inventory(self.output, owner + 1, sealed=True) if __name__ == "__main__": From 1c5d56230cc7dbaffec5be22fbca2882ecf703f6 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:08:55 +0000 Subject: [PATCH 10/17] Bind staged SDK leaves to approved archive authority --- .github/workflows/published-command-keys.yml | 6 +- tests/ci/test_command_key_cohort.py | 28 +++++- tests/ci/test_command_key_include_closure.py | 95 +++++++++++++++++++- tests/smoke/command-keys.md | 11 +++ tests/smoke/command_key_cohort.py | 30 +++++-- tests/smoke/command_key_include_closure.py | 95 +++++++++++++++----- tests/smoke/verify_command_key_supply.py | 21 +++-- 7 files changed, 242 insertions(+), 44 deletions(-) diff --git a/.github/workflows/published-command-keys.yml b/.github/workflows/published-command-keys.yml index 2c9249f1..7d3560e5 100644 --- a/.github/workflows/published-command-keys.yml +++ b/.github/workflows/published-command-keys.yml @@ -90,7 +90,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "53feca91964f24382d2a4f90967efa51970ba05405b9b2c7a16b0a521b125f84": + if hashlib.sha256(raw).hexdigest() != "8204003c5064670869168c1e759778debdc2529a6620048026e3a1540e7dca18": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ @@ -128,7 +128,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "53feca91964f24382d2a4f90967efa51970ba05405b9b2c7a16b0a521b125f84": + if hashlib.sha256(raw).hexdigest() != "8204003c5064670869168c1e759778debdc2529a6620048026e3a1540e7dca18": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ @@ -155,7 +155,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "53feca91964f24382d2a4f90967efa51970ba05405b9b2c7a16b0a521b125f84": + if hashlib.sha256(raw).hexdigest() != "8204003c5064670869168c1e759778debdc2529a6620048026e3a1540e7dca18": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ diff --git a/tests/ci/test_command_key_cohort.py b/tests/ci/test_command_key_cohort.py index 641ffcdb..0f1c1550 100644 --- a/tests/ci/test_command_key_cohort.py +++ b/tests/ci/test_command_key_cohort.py @@ -1,6 +1,7 @@ """Inert closure, workflow-bootstrap and receipt controls; no runtime calls.""" from copy import deepcopy +import hashlib import importlib.util import json import os @@ -121,21 +122,42 @@ def test_stage_requires_root_owner_inode_sealed_inventory_and_digest(self): closure = Mock() closure.inventory.return_value = {"sha256": "d" * 64} with patch.object(Path, "lstat", return_value=metadata), patch.object(Path, "resolve", lambda path: path): - self.assertEqual(MODULE.stage_inventory(ROOT, "d" * 64, "3:4", closure), closure.inventory.return_value) + self.assertEqual(MODULE.stage_inventory(ROOT, "d" * 64, "3:4", closure, SUPPLY, {}), closure.inventory.return_value) closure.inventory.assert_called_with(ROOT, 0, sealed=True) + closure.verify_sealed_sdk.assert_called_once_with(closure.inventory.return_value, SUPPLY["sdk_archive_members_sha256"]) + closure.bind_members.assert_called_once_with(closure.inventory.return_value, "fixture/", {}, sealed=True) for root, digest, identity in ((Path("/tmp") / ROOT.name, "d" * 64, "3:4"), (ROOT, "e" * 64, "3:4"), (ROOT, "d" * 64, "3:5")): with self.assertRaises(RuntimeError): - MODULE.stage_inventory(root, digest, identity, closure) + MODULE.stage_inventory(root, digest, identity, closure, SUPPLY, {}) metadata.st_uid = 1001 with self.assertRaises(RuntimeError): - MODULE.stage_inventory(ROOT, "d" * 64, "3:4", closure) + MODULE.stage_inventory(ROOT, "d" * 64, "3:4", closure, SUPPLY, {}) def bootstraps(self): bodies = WORKFLOW.read_text().split("python3 -I -S -B -c '\n")[1:] return [compile(textwrap.dedent(body.split("\n ' \\", 1)[0]), "literal-controller-bootstrap", "exec") for body in bodies] + def test_matching_dynamic_root_hash_cannot_authorize_a_replaced_sdk_leaf(self): + path = SOURCE.with_name("command_key_include_closure.py") + spec = importlib.util.spec_from_file_location("root_closure", path) + closure = importlib.util.module_from_spec(spec) + spec.loader.exec_module(closure) + approved = {"src/Client.php": {"mode": 0o644, "bytes": 5, "sha256": hashlib.sha256(b"safe!").hexdigest()}} + supply = {"sdk_archive_members_sha256": closure.members_digest(approved)} + prefix = "consumer/vendor/durable-workflow/sdk" + snapshot = {"sha256": "d" * 64, "entries": { + prefix: {"kind": "directory", "mode": 0o555}, + prefix + "/src": {"kind": "directory", "mode": 0o555}, + prefix + "/src/Client.php": {"kind": "file", "mode": 0o444, "bytes": 5, + "sha256": hashlib.sha256(b"evil!").hexdigest()}}} + metadata = SimpleNamespace(st_mode=stat.S_IFDIR | 0o555, st_uid=0, st_dev=3, st_ino=4) + with patch.object(Path, "lstat", return_value=metadata), patch.object(Path, "resolve", lambda value: value), \ + patch.object(closure, "inventory", return_value=snapshot), \ + self.assertRaisesRegex(ValueError, "approved archive member authority"): + MODULE.stage_inventory(ROOT, snapshot["sha256"], "3:4", closure, supply, {}) + def test_all_three_phase_bootstraps_bind_git_bytes_before_execution(self): bootstraps = self.bootstraps() self.assertEqual(len(bootstraps), 3) diff --git a/tests/ci/test_command_key_include_closure.py b/tests/ci/test_command_key_include_closure.py index df04e902..f394805e 100644 --- a/tests/ci/test_command_key_include_closure.py +++ b/tests/ci/test_command_key_include_closure.py @@ -1,16 +1,22 @@ """Inert include-tree inventory controls; no package code or runtime loading.""" import importlib.util +import json import os from pathlib import Path import tempfile import unittest +from unittest.mock import patch +import zipfile SOURCE = Path(__file__).resolve().parents[1] / "smoke/command_key_include_closure.py" SPEC = importlib.util.spec_from_file_location("command_key_include_closure", SOURCE) MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MODULE) +SUPPLY_SPEC = importlib.util.spec_from_file_location("command_key_supply", SOURCE.with_name("verify_command_key_supply.py")) +SUPPLY = importlib.util.module_from_spec(SUPPLY_SPEC) +SUPPLY_SPEC.loader.exec_module(SUPPLY) class IncludeClosureControls(unittest.TestCase): @@ -27,8 +33,29 @@ def setUp(self): (self.consumer / "vendor/autoload.php").write_text('inert autoload bytes') (self.consumer / "vendor/composer/installed.json").write_text('{}') (self.consumer / "vendor/composer/nested.php").write_text('inert nested include bytes') + self.sdk = self.consumer / "vendor/durable-workflow/sdk" + (self.sdk / "src").mkdir(parents=True) + (self.sdk / "src/Client.php").write_bytes(b"safe!") + self.approved_sdk = SUPPLY.member_inventory({"src/Client.php": (b"safe!", 0o644)}) + self.approved_digest = MODULE.members_digest(self.approved_sdk) + self.supply = {"sdk_version": "1.2.3", "sdk_reference": "a" * 40, + "sdk_source_url": "https://example.invalid/sdk.git", "sdk_dist_url": "https://example.invalid/sdk.zip", + "sdk_archive_members_sha256": self.approved_digest} + package = {"name": "durable-workflow/sdk", "version": "1.2.3", + "source": {"url": self.supply["sdk_source_url"], "reference": self.supply["sdk_reference"]}, + "dist": {"url": self.supply["sdk_dist_url"], "reference": self.supply["sdk_reference"]}} + (self.consumer / "composer.json").write_text(json.dumps({"require": {"durable-workflow/sdk": "1.2.3"}})) + (self.consumer / "composer.lock").write_text(json.dumps({"packages": [package]})) + (self.consumer / "vendor/composer/installed.json").write_text(json.dumps({"packages": [package | { + "installation-source": "dist", "install-path": "../durable-workflow/sdk"}]})) + self.archive = self.root / "approved.zip" + member = zipfile.ZipInfo("sdk/src/Client.php") + member.external_attr = 0o100644 << 16 + with zipfile.ZipFile(self.archive, "w") as archive: + archive.writestr(member, b"safe!") (self.fixture / "command-key-client.php").write_text('inert fixture bytes') (self.fixture / "command-key-supply.json").write_text('{}') + self.approved_fixture = SUPPLY.member_inventory({path.name: (path.read_bytes(), 0o644) for path in self.fixture.iterdir()}) self.addCleanup(self.unseal) def unseal(self): @@ -37,7 +64,8 @@ def unseal(self): path.chmod(0o700) def stage(self): - return MODULE.stage(self.consumer, self.fixture, self.output) + return MODULE.stage(self.consumer, self.fixture, self.output, self.approved_sdk, + self.approved_digest, self.approved_fixture) def test_complete_nested_include_inventory_and_readonly_copy(self): result = self.stage() @@ -95,6 +123,71 @@ def test_writable_sealed_member_and_wrong_owner_refuse(self): with self.assertRaisesRegex(ValueError, "ownership"): MODULE.inventory(self.output, owner + 1, sealed=True) + def test_post_verification_same_size_sdk_replacement_is_not_reapproved(self): + evidence, approved = SUPPLY.verify_with_members(self.consumer, self.archive, self.supply) + self.assertEqual(evidence["archive_members_sha256"], self.approved_digest) + (self.sdk / "src/Client.php").write_bytes(b"evil!") + with self.assertRaisesRegex(ValueError, "approved authority"): + MODULE.stage(self.consumer, self.fixture, self.output, approved, + evidence["archive_members_sha256"], self.approved_fixture) + self.assertEqual(list(self.output.iterdir()), []) + + def test_sdk_replacement_after_inventory_is_checked_through_source_descriptor(self): + original_copy = MODULE.copy_tree + + def replace_then_copy(source, destination, expected): + if source == self.consumer: + (self.sdk / "src/Client.php").write_bytes(b"evil!") + return original_copy(source, destination, expected) + + with patch.object(MODULE, "copy_tree", side_effect=replace_then_copy), \ + self.assertRaisesRegex(ValueError, "changed before copying"): + self.stage() + + def test_captured_fixture_replacement_is_not_reapproved(self): + (self.fixture / "command-key-client.php").write_text('changed fixture bytes') + with self.assertRaisesRegex(ValueError, "approved authority"): + self.stage() + + def test_sealed_sdk_recheck_uses_archive_authority_even_with_new_include_hash(self): + before = self.stage() + leaf = self.output / "consumer/vendor/durable-workflow/sdk/src/Client.php" + leaf.chmod(0o600) + leaf.write_bytes(b"evil!") + leaf.chmod(0o444) + changed = MODULE.inventory(self.output, os.getuid(), sealed=True) + self.assertNotEqual(before["sha256"], changed["sha256"]) + with self.assertRaisesRegex(ValueError, "approved archive member authority"): + MODULE.verify_sealed_sdk(changed, self.approved_digest) + + def test_approved_executable_mode_is_deterministic_after_private_umask(self): + leaf = self.sdk / "src/Client.php" + leaf.chmod(0o600) + self.approved_sdk["src/Client.php"]["mode"] = 0o755 + self.approved_digest = MODULE.members_digest(self.approved_sdk) + result = self.stage() + self.assertEqual(result["entries"]["consumer/vendor/durable-workflow/sdk/src/Client.php"]["mode"], 0o555) + MODULE.verify_sealed_sdk(result, self.approved_digest) + + def test_extra_sdk_empty_directory_and_file_refuse(self): + for directory in (False, True): + extra = self.sdk / "extra" + if directory: + extra.mkdir() + else: + extra.write_bytes(b"inert") + with self.subTest(directory=directory), self.assertRaisesRegex(ValueError, "member set|directory set"): + self.stage() + if directory: + extra.rmdir() + else: + extra.unlink() + + def test_changed_approved_map_does_not_change_the_selected_archive_authority(self): + self.approved_sdk["src/Client.php"]["sha256"] = "0" * 64 + with self.assertRaisesRegex(ValueError, "staging SDK authority"): + self.stage() + if __name__ == "__main__": unittest.main() diff --git a/tests/smoke/command-keys.md b/tests/smoke/command-keys.md index b31dafac..6a971a15 100644 --- a/tests/smoke/command-keys.md +++ b/tests/smoke/command-keys.md @@ -27,6 +27,17 @@ raw ZIP hash is still retained as an observation. Changed, added or missing members and changed executable modes refuse. Run its inert controls with `python3 -I -S -B tests/ci/test_command_key_supply.py`. +The controller also retains that approved archive member map in memory and +passes it into staging. Each SDK source descriptor is checked against the +approved path/body/mode policy before copying. Expected SDK bytes are never +derived from a later read of the mutable install tree. Staging assigns fixed +read-only modes from the approved modes, checks all members again after the +copy, and rejects missing or extra SDK files and directories. Every subsequent +root-controlled inventory rechecks the sealed SDK against the same digest from +the captured supply before container creation and around each invocation. +The fixture map comes independently from captured Git bytes and is checked +during copying and sealed-root checks too. + Set `COMMAND_KEY_RUNTIME_URL` to the owned numeric loopback URL and `COMMAND_KEY_TOKEN` to its synthetic credential. Pass the registry consumer's `vendor/autoload.php` as the first argument. Packet fields are: diff --git a/tests/smoke/command_key_cohort.py b/tests/smoke/command_key_cohort.py index 7a257bea..ee8a43a2 100644 --- a/tests/smoke/command_key_cohort.py +++ b/tests/smoke/command_key_cohort.py @@ -47,7 +47,12 @@ def module_from_bytes(name, raw): return module -def stage_inventory(root, expected, root_identity, closure): +def fixture_authority(sources): + return {name: {"mode": 0o644, "bytes": len(sources[name]), "sha256": hashlib.sha256(sources[name]).hexdigest()} + for name in ("command-key-client.php", "command-key-supply.json")} + + +def stage_inventory(root, expected, root_identity, closure, supply, approved_fixture): require(root.parent == Path("/run") and re.fullmatch(r"sdk-php-command-keys\.[A-Za-z0-9]{6}", root.name), "The include closure must use its root-controlled staging location") metadata = root.lstat() @@ -55,6 +60,8 @@ def stage_inventory(root, expected, root_identity, closure): "The include staging root is not root controlled") require(root_identity == str(metadata.st_dev) + ":" + str(metadata.st_ino), "The include staging inode differs") result = closure.inventory(root, 0, sealed=True) + closure.verify_sealed_sdk(result, supply["sdk_archive_members_sha256"]) + closure.bind_members(result, "fixture/", approved_fixture, sealed=True) require(result["sha256"] == expected, "The complete include inventory differs") return result @@ -183,13 +190,15 @@ def __init__(self, resources, container, runtime_id, supply, sources, origin, to self.resources, self.container, self.runtime_id = resources, container, runtime_id self.supply, self.sources, self.origin, self.token = supply, sources, origin, token self.root, self.expected, self.root_identity, self.closure = root, expected, root_identity, closure - self.include_inventory = stage_inventory(root, expected, root_identity, closure) + self.approved_fixture = fixture_authority(sources) + self.include_inventory = stage_inventory(root, expected, root_identity, closure, supply, self.approved_fixture) def call(self, packet, origin=None): require(runtime_origin(self.resources, self.runtime_id, self.supply) == self.origin, "The captured native endpoint changed") consumer_record(self.resources, self.container, self.supply, self.root) - require(stage_inventory(self.root, self.expected, self.root_identity, self.closure) == self.include_inventory, + require(stage_inventory(self.root, self.expected, self.root_identity, self.closure, + self.supply, self.approved_fixture) == self.include_inventory, "The complete include tree changed before invocation") for name, raw in self.sources.items(): require((HERE / name).read_bytes() == raw, "The captured runner source changed") @@ -199,7 +208,8 @@ def call(self, packet, origin=None): "/closure/consumer/vendor/autoload.php"], input=json.dumps(packet), timeout=20) require(result.returncode == 0, "The isolated PHP fixture operation failed") require(0 < len(result.stdout) <= 1024**2, "The PHP fixture evidence is empty or oversized") - require(stage_inventory(self.root, self.expected, self.root_identity, self.closure) == self.include_inventory, + require(stage_inventory(self.root, self.expected, self.root_identity, self.closure, + self.supply, self.approved_fixture) == self.include_inventory, "The complete include tree changed during invocation") consumer_record(self.resources, self.container, self.supply, self.root) response = json.loads(result.stdout) @@ -401,25 +411,28 @@ def main(): require(re.fullmatch(r"[0-9]+-[0-9]+", options.runtime_owner), "Invalid fixture run owner") sources = capture_inputs(options.runner_source) supply = json.loads(sources["command-key-supply.json"]) + approved_fixture = fixture_authority(sources) closure = module_from_bytes("command_key_include_closure", sources["command_key_include_closure.py"]) if options.phase == "stage": require(options.consumer is not None and options.archive is not None, "The registry consumer and approved archive are required") verifier = module_from_bytes("verify_command_key_supply", sources["verify_command_key_supply.py"]) - package = verifier.verify(options.consumer, options.archive, supply) + package, approved_sdk = verifier.verify_with_members(options.consumer, options.archive, supply) fixture = options.stage_root.parent / "captured-fixture" fixture.mkdir(mode=0o700) for name in ("command-key-client.php", "command-key-supply.json"): descriptor = os.open(fixture / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400) with os.fdopen(descriptor, "wb") as output: output.write(sources[name]) - include = closure.stage(options.consumer, fixture, options.stage_root) + include = closure.stage(options.consumer, fixture, options.stage_root, approved_sdk, + supply["sdk_archive_members_sha256"], approved_fixture) print(json.dumps({"include_sha256": include["sha256"], "members": len(include["entries"]), "bytes": include["bytes"], "package": package, "inspection_only": True})) return require(isinstance(options.include_sha256, str) and re.fullmatch(r"[0-9a-f]{64}", options.include_sha256), "The captured include digest is required") - include = stage_inventory(options.stage_root, options.include_sha256, options.root_identity, closure) + include = stage_inventory(options.stage_root, options.include_sha256, options.root_identity, + closure, supply, approved_fixture) containers = module_from_bytes("command_key_containers", sources["command_key_containers.py"]) nonce = secrets.token_hex(16) if options.phase == "prepare" else options.nonce created = (lambda role, identity: publish_outputs({role + "_id": identity})) if options.phase == "prepare" else None @@ -430,7 +443,8 @@ def main(): token = "sdk-php-216-" + secrets.token_hex(24) publish_outputs({"nonce": nonce, "token": token}) server, consumer = prepare_containers(resources, supply, token, options.stage_root) - stage_inventory(options.stage_root, options.include_sha256, options.root_identity, closure) + stage_inventory(options.stage_root, options.include_sha256, options.root_identity, + closure, supply, approved_fixture) # This step ends before any package code executes. GitHub retains # these outputs independently of the mutable checkout/state. print(json.dumps({"prepared": True, "package_executed": False, diff --git a/tests/smoke/command_key_include_closure.py b/tests/smoke/command_key_include_closure.py index 2d2dd720..0acffe95 100644 --- a/tests/smoke/command_key_include_closure.py +++ b/tests/smoke/command_key_include_closure.py @@ -1,15 +1,64 @@ """Capture and inventory the complete PHP include tree without loading PHP.""" -import argparse import hashlib import json import os -from pathlib import Path +from pathlib import Path, PurePosixPath import stat MAX_FILES = 10000 MAX_BYTES = 128 * 1024 * 1024 +SDK_PREFIX = "consumer/vendor/durable-workflow/sdk/" + + +def members_digest(members): + return hashlib.sha256(json.dumps(members, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def approved_directories(prefix, approved): + directories = {prefix.rstrip("/")} + for name, expected in approved.items(): + if expected["mode"] not in (0o644, 0o755): + raise ValueError("The approved member mode is unsupported") + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or path.as_posix() != name: + raise ValueError("The approved member path is not canonical") + directories.update(prefix + parent.as_posix() for parent in path.parents if parent.as_posix() != ".") + return directories + + +def bind_members(snapshot, prefix, approved, *, sealed): + files = {name.removeprefix(prefix): entry for name, entry in snapshot["entries"].items() + if name.startswith(prefix) and entry["kind"] == "file"} + if not approved or set(files) != set(approved): + raise ValueError("The staged member set differs from its approved authority") + expected_directories = approved_directories(prefix, approved) + for name, expected in approved.items(): + actual = files[name] + mode_matches = (actual["mode"] == (expected["mode"] & ~0o222) if sealed + else not (actual["mode"] & ~expected["mode"])) + if not mode_matches or actual["bytes"] != expected["bytes"] or actual["sha256"] != expected["sha256"]: + raise ValueError("The staged member differs from its approved authority") + actual_directories = {name for name, entry in snapshot["entries"].items() + if (name == prefix.rstrip("/") or name.startswith(prefix)) and entry["kind"] == "directory"} + if actual_directories != expected_directories: + raise ValueError("The staged directory set differs from its approved authority") + return {prefix + name: {"kind": "file", **member} for name, member in approved.items()} + + +def verify_sealed_sdk(snapshot, approved_digest): + approved = {} + for name, entry in snapshot["entries"].items(): + if name.startswith(SDK_PREFIX) and entry["kind"] == "file": + if entry["mode"] not in (0o444, 0o555): + raise ValueError("The sealed SDK member mode differs") + approved[name.removeprefix(SDK_PREFIX)] = { + "mode": 0o755 if entry["mode"] == 0o555 else 0o644, + "bytes": entry["bytes"], "sha256": entry["sha256"]} + if members_digest(approved) != approved_digest: + raise ValueError("The sealed SDK differs from the approved archive member authority") + bind_members(snapshot, SDK_PREFIX, approved, sealed=True) def inventory(root, owner, *, sealed): @@ -63,8 +112,9 @@ def copy_tree(source, destination, expected): descriptor = os.open(original, os.O_RDONLY | os.O_NOFOLLOW) try: metadata = os.fstat(descriptor) - if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: - raise ValueError("The include source changed its leaf type") + if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 + or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) & ~member["mode"]): + raise ValueError("The include source type, owner or mode changed") with os.fdopen(descriptor, "rb", closefd=False) as stream: raw = stream.read(MAX_BYTES + 1) if len(raw) != member["bytes"] or hashlib.sha256(raw).hexdigest() != member["sha256"]: @@ -81,7 +131,9 @@ def copy_tree(source, destination, expected): destination.chmod(0o555) -def stage(consumer, fixture, destination): +def stage(consumer, fixture, destination, approved_sdk, approved_digest, approved_fixture): + if members_digest(approved_sdk) != approved_digest: + raise ValueError("The staging SDK authority differs from the approved archive") if set(path.name for path in consumer.iterdir()) != {"composer.json", "composer.lock", "vendor"}: raise ValueError("Unexpected consumer root members") if set(path.name for path in fixture.iterdir()) != {"command-key-client.php", "command-key-supply.json"}: @@ -89,6 +141,15 @@ def stage(consumer, fixture, destination): owner = os.getuid() before = inventory(consumer, owner, sealed=False) fixture_before = inventory(fixture, owner, sealed=False) + # Expected SDK and fixture leaf bytes come from approved archive/Git data, + # never from a fresh read of the mutable install tree. + consumer_copy = {"entries": dict(before["entries"])} + consumer_copy["entries"].update(bind_members(before, "vendor/durable-workflow/sdk/", approved_sdk, sealed=False)) + fixture_snapshot = {"entries": {"fixture" if name == "." else "fixture/" + name: entry + for name, entry in fixture_before["entries"].items()}} + fixture_copy = {"entries": dict(fixture_before["entries"])} + fixture_copy["entries"].update({name.removeprefix("fixture/"): entry for name, entry in + bind_members(fixture_snapshot, "fixture/", approved_fixture, sealed=False).items()}) for required in ("vendor/autoload.php", "vendor/composer/installed.json"): if before["entries"].get(required, {}).get("kind") != "file": raise ValueError("The complete installed autoloader metadata is required") @@ -97,24 +158,12 @@ def stage(consumer, fixture, destination): or metadata.st_uid != owner or stat.S_IMODE(metadata.st_mode) != 0o700 or list(destination.iterdir())): raise ValueError("The include destination must be a fresh private directory") - copy_tree(consumer, destination / "consumer", before) - copy_tree(fixture, destination / "fixture", fixture_before) + copy_tree(consumer, destination / "consumer", consumer_copy) + copy_tree(fixture, destination / "fixture", fixture_copy) if inventory(consumer, owner, sealed=False) != before or inventory(fixture, owner, sealed=False) != fixture_before: raise ValueError("An include source changed during staging") destination.chmod(0o555) - return inventory(destination, owner, sealed=True) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--consumer", type=Path, required=True) - parser.add_argument("--fixture", type=Path, required=True) - parser.add_argument("--destination", type=Path, required=True) - options = parser.parse_args() - result = stage(options.consumer, options.fixture, options.destination) - print(json.dumps({"inspection_only": True, "include_sha256": result["sha256"], - "members": len(result["entries"]), "bytes": result["bytes"]})) - - -if __name__ == "__main__": - main() + result = inventory(destination, owner, sealed=True) + verify_sealed_sdk(result, approved_digest) + bind_members(result, "fixture/", approved_fixture, sealed=True) + return result diff --git a/tests/smoke/verify_command_key_supply.py b/tests/smoke/verify_command_key_supply.py index 1f1d9c06..1014c82e 100644 --- a/tests/smoke/verify_command_key_supply.py +++ b/tests/smoke/verify_command_key_supply.py @@ -105,11 +105,15 @@ def archive_files(archive, supply): def member_digest(files): - members = {name: {"mode": mode, "bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()} - for name, (raw, mode) in files.items()} + members = member_inventory(files) return hashlib.sha256(json.dumps(members, sort_keys=True, separators=(",", ":")).encode()).hexdigest() +def member_inventory(files): + return {name: {"mode": mode, "bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()} + for name, (raw, mode) in files.items()} + + def installed_files(package, expected): actual = set() for path in package.rglob("*"): @@ -133,16 +137,21 @@ def installed_files(package, expected): return len(actual) -def verify(consumer, archive, supply): +def verify_with_members(consumer, archive, supply): consumer = Path(consumer) if consumer.resolve() != consumer or not consumer.is_dir(): raise ValueError("The consumer must be a canonical directory") package = package_metadata(consumer, supply) expected, digest, total = archive_files(Path(archive), supply) count = installed_files(package, expected) - return {"sdk_version": supply["sdk_version"], "sdk_reference": supply["sdk_reference"], - "archive_sha256": digest, "archive_members_sha256": member_digest(expected), "installed_files": count, - "installed_bytes": total, "source_fallback": False, "inspection_only": True} + evidence = {"sdk_version": supply["sdk_version"], "sdk_reference": supply["sdk_reference"], + "archive_sha256": digest, "archive_members_sha256": member_digest(expected), "installed_files": count, + "installed_bytes": total, "source_fallback": False, "inspection_only": True} + return evidence, member_inventory(expected) + + +def verify(consumer, archive, supply): + return verify_with_members(consumer, archive, supply)[0] if __name__ == "__main__": From bdbc8de5f69922f3e559e848b69805e678b950e5 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:26:43 +0000 Subject: [PATCH 11/17] Bind containerd image indexes to exact platform manifests --- .github/workflows/published-command-keys.yml | 6 +-- tests/ci/test_command_key_cohort.py | 49 +++++++++++++++++++- tests/ci/test_command_key_containers.py | 34 ++++++++++++++ tests/smoke/command-keys.md | 9 +++- tests/smoke/command_key_cohort.py | 30 ++++++++++-- tests/smoke/command_key_containers.py | 18 +++++-- 6 files changed, 133 insertions(+), 13 deletions(-) diff --git a/.github/workflows/published-command-keys.yml b/.github/workflows/published-command-keys.yml index 7d3560e5..1815bfed 100644 --- a/.github/workflows/published-command-keys.yml +++ b/.github/workflows/published-command-keys.yml @@ -90,7 +90,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "8204003c5064670869168c1e759778debdc2529a6620048026e3a1540e7dca18": + if hashlib.sha256(raw).hexdigest() != "0aa2f6b87f68c67ef67bd32929e10bff6bf0226918aaea44bb9ea065e2efb1db": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ @@ -128,7 +128,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "8204003c5064670869168c1e759778debdc2529a6620048026e3a1540e7dca18": + if hashlib.sha256(raw).hexdigest() != "0aa2f6b87f68c67ef67bd32929e10bff6bf0226918aaea44bb9ea065e2efb1db": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ @@ -155,7 +155,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "8204003c5064670869168c1e759778debdc2529a6620048026e3a1540e7dca18": + if hashlib.sha256(raw).hexdigest() != "0aa2f6b87f68c67ef67bd32929e10bff6bf0226918aaea44bb9ea065e2efb1db": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ diff --git a/tests/ci/test_command_key_cohort.py b/tests/ci/test_command_key_cohort.py index 0f1c1550..0ca3bde3 100644 --- a/tests/ci/test_command_key_cohort.py +++ b/tests/ci/test_command_key_cohort.py @@ -25,7 +25,8 @@ def runtime(): return {"Id": IDENTITY, "Image": SUPPLY["server_config"], "State": {"Running": True}, - "Config": {"Labels": {"org.opencontainers.image.revision": SUPPLY["server_source"]}}, + "Config": {"Image": "durableworkflow/server@" + SUPPLY["server_index"], + "Labels": {"org.opencontainers.image.revision": SUPPLY["server_source"]}}, "HostConfig": {"ReadonlyRootfs": True, "Privileged": False, "Binds": None, "Init": True, "CapDrop": ["ALL"], "CapAdd": None, "SecurityOpt": ["no-new-privileges"], "Devices": [], "NetworkMode": "bridge", @@ -38,6 +39,7 @@ def runtime(): def consumer(): record = runtime() record["Image"] = SUPPLY["php_config"] + record["Config"]["Image"] = "php@" + SUPPLY["php_index"] record["Config"]["User"] = "1001:1001" record["HostConfig"].update(NetworkMode="host", Tmpfs={"/tmp": "size=128m"}) record["Mounts"] = [{"Type": "bind", "Source": str(ROOT), "Destination": "/closure", "RW": False}, @@ -65,6 +67,51 @@ def php(self, record): def test_complete_native_profile_selects_numeric_loopback(self): self.assertEqual(self.origin(runtime()), "http://127.0.0.1:43123") + def test_containerd_index_identity_requires_the_exact_platform_manifest(self): + for factory, role, check in ((runtime, "server", self.origin), (consumer, "php", self.php)): + record = factory() + record["Image"] = SUPPLY[role + "_index"] + record["ImageManifestDescriptor"] = { + "digest": SUPPLY[role + "_amd64"], "mediaType": "application/vnd.oci.image.manifest.v1+json", + "platform": {"os": "linux", "architecture": "amd64"}} + check(record) + for mutation in (lambda r: r.update(ImageManifestDescriptor=None), + lambda r: r["ImageManifestDescriptor"].update(digest="sha256:" + "0" * 64), + lambda r: r["ImageManifestDescriptor"]["platform"].update(architecture="arm64"), + lambda r: r["ImageManifestDescriptor"]["platform"].update(os="windows"), + lambda r: r["ImageManifestDescriptor"].update(mediaType="application/vnd.oci.image.index.v1+json"), + lambda r: r["Config"].update(Image="php:latest"), + lambda r: r.update(Image="sha256:" + "0" * 64)): + changed = deepcopy(record) + mutation(changed) + with self.subTest(role=role), self.assertRaises(RuntimeError): + check(changed) + + def test_present_wrong_manifest_refuses_even_with_matching_classic_config(self): + record = runtime() + record["ImageManifestDescriptor"] = {"digest": "sha256:" + "0" * 64, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "platform": {"os": "linux", "architecture": "amd64"}} + with self.assertRaisesRegex(RuntimeError, "platform manifest differs"): + self.origin(record) + + def test_wrong_selected_manifest_refuses_before_native_process_start(self): + resources = Mock() + resources.identities = {"server": IDENTITY} + resources.pull.return_value = {"Config": {"Labels": {"org.opencontainers.image.revision": SUPPLY["server_source"]}}} + resources.create.return_value = IDENTITY + record = runtime() + record["State"]["Running"] = False + record["Image"] = SUPPLY["server_index"] + record["ImageManifestDescriptor"] = {"digest": "sha256:" + "0" * 64, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "platform": {"os": "linux", "architecture": "amd64"}} + resources.owned.return_value = record + with self.assertRaisesRegex(RuntimeError, "platform manifest differs"): + MODULE.prepare_containers(resources, SUPPLY, "synthetic", ROOT) + resources.start.assert_not_called() + resources.remove_all.assert_called_once() + def test_native_source_configuration_network_and_ports_are_closed(self): mutations = [lambda r: r.update(Image="sha256:" + "0" * 64), lambda r: r["Config"]["Labels"].update({"org.opencontainers.image.revision": "0" * 40}), diff --git a/tests/ci/test_command_key_containers.py b/tests/ci/test_command_key_containers.py index fa733547..3fbd98bc 100644 --- a/tests/ci/test_command_key_containers.py +++ b/tests/ci/test_command_key_containers.py @@ -72,6 +72,40 @@ def test_inspect_requires_full_exact_returned_identity(self): self.assertRaises(RuntimeError): self.resources.inspect(IDENTITY) + def test_pulled_image_accepts_config_id_or_explicit_index_descriptor(self): + configuration = "sha256:" + "c" * 64 + index = "sha256:" + "d" * 64 + image = "php@" + index + for record in ({"Id": configuration, "Os": "linux", "Architecture": "amd64"}, + {"Id": index, "Os": "linux", "Architecture": "amd64", "Descriptor": { + "digest": index, "mediaType": "application/vnd.oci.image.index.v1+json"}}): + with patch.object(self.resources, "command", side_effect=[result(), result(output=json.dumps([record]))]) as invoke: + self.assertEqual(self.resources.pull(image, configuration), record) + self.assertEqual(invoke.call_args_list[0].args[0], ["pull", "--platform", "linux/amd64", image]) + + def test_index_image_refuses_wrong_digest_type_or_platform(self): + configuration = "sha256:" + "c" * 64 + index = "sha256:" + "d" * 64 + valid = {"Id": index, "Os": "linux", "Architecture": "amd64", "Descriptor": { + "digest": index, "mediaType": "application/vnd.oci.image.index.v1+json"}} + changes = [{"Id": "sha256:" + "0" * 64}, {"Os": "windows"}, {"Architecture": "arm64"}, + {"Descriptor": None}, {"Descriptor": {"digest": "sha256:" + "0" * 64, + "mediaType": "application/vnd.oci.image.index.v1+json"}}, + {"Id": configuration, "Descriptor": {"digest": "sha256:" + "0" * 64, + "mediaType": "application/vnd.oci.image.index.v1+json"}}, + {"Descriptor": {"digest": index, "mediaType": "application/vnd.oci.image.manifest.v1+json"}}] + for change in changes: + record = valid | change + with self.subTest(change=change), patch.object(self.resources, "command", side_effect=[ + result(), result(output=json.dumps([record]))]), self.assertRaisesRegex(RuntimeError, "configuration differs"): + self.resources.pull("php@" + index, configuration) + + def test_image_tags_and_unselected_repositories_refuse_before_pull(self): + for image in ("php:latest", "php:8.3", "other/image@sha256:" + "d" * 64): + with self.subTest(image=image), patch.object(self.resources, "command") as invoke, self.assertRaises(RuntimeError): + self.resources.pull(image, "sha256:" + "c" * 64) + invoke.assert_not_called() + def test_foreign_owner_role_and_nonce_are_preserved(self): for field in (MODULE.OWNER, MODULE.ROLE, MODULE.NONCE): record = owned_record() diff --git a/tests/smoke/command-keys.md b/tests/smoke/command-keys.md index 6a971a15..433ec586 100644 --- a/tests/smoke/command-keys.md +++ b/tests/smoke/command-keys.md @@ -87,6 +87,12 @@ and captures its complete consumer/vendor/fixture include tree without loading PHP. A root-controlled directory receives the verified copy. Preparation creates the selected linux/amd64 Server and an idle, unprivileged PHP consumer. Both have read-only root filesystems, 1-CPU/1-GiB/256-PID caps and bounded tmpfs. +Creation explicitly selects linux/amd64. Classic image stores must report the +selected config digest. Multi-platform stores may report the exact selected +index instead, with its matching index descriptor; the created container must +then report the independently pinned linux/amd64 platform manifest before it +starts. A present manifest descriptor must always agree, even when the config +digest matches. Missing, conflicting or other-platform identities refuse. The PHP consumer mounts the entire include tree read-only at `/closure`; only its separate `/tmp` is writable. Cohort execution invokes the adapter inside that consumer and checks the complete include inventory, mount configuration, @@ -124,7 +130,8 @@ overwrite/rename controls, a writable-mount countercontrol, full consumer profile qualification and independent review are still required. File modes and the inert inventory tests alone provide no isolation verdict. The proposed official PHP image identities in the supply are raw-registry metadata bindings; -this prototype has not pulled or qualified that consumer. The complete SDK +read-only base-profile observations on the PR do not qualify the SDK consumer. +The complete SDK member binding is proposed source and also requires independent review. Run the offline input controls against an existing immutable PHP image: diff --git a/tests/smoke/command_key_cohort.py b/tests/smoke/command_key_cohort.py index ee8a43a2..a6bfb6dc 100644 --- a/tests/smoke/command_key_cohort.py +++ b/tests/smoke/command_key_cohort.py @@ -66,8 +66,28 @@ def stage_inventory(root, expected, root_identity, closure, supply, approved_fix return result -def confined(record, configuration): - require(record["State"]["Running"] and record["Image"] == configuration, +def selected_image(record, supply, role): + configuration = supply[role + "_config"] + index = supply[role + "_index"] + repository = "durableworkflow/server" if role == "server" else "php" + require(record["Config"]["Image"] == repository + "@" + index, + "The fixture must select its exact public image index") + descriptor = record.get("ImageManifestDescriptor") + if record["Image"] == configuration and descriptor is None: + return + require(record["Image"] in {configuration, index} and isinstance(descriptor, dict), + "The fixture image or selected manifest is missing") + platform = descriptor.get("platform") or {} + require(descriptor.get("digest") == supply[role + "_amd64"] + and descriptor.get("mediaType") in {"application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json"} + and platform.get("os") == "linux" and platform.get("architecture") == "amd64", + "The selected fixture platform manifest differs") + + +def confined(record, supply, role): + selected_image(record, supply, role) + require(record["State"]["Running"], "The fixture is not running the selected image configuration") host = record["HostConfig"] require(host["ReadonlyRootfs"] and not host["Privileged"] and not host.get("Binds") @@ -80,7 +100,7 @@ def confined(record, configuration): def runtime_origin(resources, identity, supply): record = resources.owned(identity, "server") - confined(record, supply["server_config"]) + confined(record, supply, "server") require(record["Config"]["Labels"].get("org.opencontainers.image.revision") == supply["server_source"], "The native source identity differs") require(record["HostConfig"]["NetworkMode"] == "bridge", "The native network profile differs") @@ -100,7 +120,7 @@ def runtime_origin(resources, identity, supply): def consumer_record(resources, identity, supply, root): record = resources.owned(identity, "consumer") - confined(record, supply["php_config"]) + confined(record, supply, "php") host = record["HostConfig"] require(host["NetworkMode"] == "host" and not record["NetworkSettings"]["Ports"], "The PHP fixture network profile differs") @@ -148,6 +168,7 @@ def prepare_containers(resources, supply, token, root): "--tmpfs", "/app/database:uid=1000,gid=1000,mode=0700,size=32m", "--publish", "127.0.0.1::8080", "--env", "DB_CONNECTION=sqlite", "--env", "DW_AUTH_DRIVER=token", "--env", "DW_AUTH_TOKEN=" + token, server_image]) + selected_image(resources.owned(server, "server"), supply, "server") resources.start(server, "server") origin = runtime_origin(resources, server, supply) opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) @@ -170,6 +191,7 @@ def prepare_containers(resources, supply, token, root): "--workdir", "/closure/consumer", "--tmpfs", "/tmp:mode=1777,size=128m", "--mount", "type=bind,src=" + str(root) + ",dst=/closure,readonly", php_image, "php", "-c", "/usr/local/etc/php/php.ini-production", "-r", "sleep(900);"]) + selected_image(resources.owned(consumer, "consumer"), supply, "php") resources.start(consumer, "consumer") consumer_record(resources, consumer, supply, root) probe = resources.command(["exec", consumer, "php", "-c", "/usr/local/etc/php/php.ini-production", "-r", diff --git a/tests/smoke/command_key_containers.py b/tests/smoke/command_key_containers.py index c85b3aac..00ef9278 100644 --- a/tests/smoke/command_key_containers.py +++ b/tests/smoke/command_key_containers.py @@ -110,10 +110,20 @@ def pull(self, image, configuration): result = self.command(["image", "inspect", image], timeout=15) require(result.returncode == 0, "The selected fixture image cannot be inspected") rows = json.loads(result.stdout) - require(len(rows) == 1 and rows[0]["Id"] == configuration - and rows[0]["Os"] == "linux" and rows[0]["Architecture"] == "amd64", + require(isinstance(rows, list) and len(rows) == 1, "The selected image inspection is ambiguous") + selected = rows[0] + descriptor = selected.get("Descriptor") or {} + index = image.split("@", 1)[1] + descriptor_matches = (descriptor.get("digest") == index and descriptor.get("mediaType") in { + "application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json"}) + index_identity = selected["Id"] == index and descriptor_matches + require((selected["Id"] == configuration or index_identity) + and (not descriptor or descriptor_matches) + and selected["Os"] == "linux" and selected["Architecture"] == "amd64", "The selected public image configuration differs") - return rows[0] + # The created container must independently bind its selected platform + # manifest before start when the engine uses the index as its image ID. + return selected def create(self, role, arguments): require(role in ROLES and role not in self.records, "A fixture role may be created only once") @@ -122,7 +132,7 @@ def create(self, role, arguments): self.records[role] = descriptor flags = ["create", "--name", "sdk-php-command-keys-" + self.owner + "-" + self.nonce[:10] + "-" + role, "--label", OWNER + "=" + self.owner, "--label", ROLE + "=" + role, - "--label", NONCE + "=" + self.nonce, "--init", "--read-only", + "--label", NONCE + "=" + self.nonce, "--platform", "linux/amd64", "--init", "--read-only", "--cpus", "1", "--memory", "1g", "--pids-limit", "256", "--cap-drop", "ALL", "--security-opt", "no-new-privileges"] try: From 7078b5559e163ebe8aa0d84d6bc882d2fe2076e1 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:55:30 +0000 Subject: [PATCH 12/17] Prepare synthetic consumer mount boundary controls --- .github/workflows/ci.yml | 1 + .github/workflows/published-command-keys.yml | 7 +- tests/ci/test_command_key_mount_boundary.py | 246 +++++++++++++++++++ tests/smoke/command-keys.md | 31 ++- tests/smoke/command_key_cohort.py | 15 +- 5 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 tests/ci/test_command_key_mount_boundary.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d36926d8..1438af41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,7 @@ jobs: python3 -I -S -B tests/ci/test_command_key_cohort.py python3 -I -S -B tests/ci/test_command_key_containers.py python3 -I -S -B tests/ci/test_command_key_include_closure.py + python3 -I -S -B tests/ci/test_command_key_mount_boundary.py test: name: PHP ${{ matrix.php }} diff --git a/.github/workflows/published-command-keys.yml b/.github/workflows/published-command-keys.yml index 1815bfed..8e6ce5a3 100644 --- a/.github/workflows/published-command-keys.yml +++ b/.github/workflows/published-command-keys.yml @@ -26,6 +26,7 @@ jobs: /usr/bin/python3 -I -S -B tests/ci/test_command_key_cohort.py /usr/bin/python3 -I -S -B tests/ci/test_command_key_containers.py /usr/bin/python3 -I -S -B tests/ci/test_command_key_include_closure.py + /usr/bin/python3 -I -S -B tests/ci/test_command_key_mount_boundary.py - name: Create private registry consumer and runtime state run: | umask 077 @@ -90,7 +91,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "0aa2f6b87f68c67ef67bd32929e10bff6bf0226918aaea44bb9ea065e2efb1db": + if hashlib.sha256(raw).hexdigest() != "275b3741eeff87a3257237e597c78a5de02fd4d7b64c005f3437099e043d3705": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ @@ -128,7 +129,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "0aa2f6b87f68c67ef67bd32929e10bff6bf0226918aaea44bb9ea065e2efb1db": + if hashlib.sha256(raw).hexdigest() != "275b3741eeff87a3257237e597c78a5de02fd4d7b64c005f3437099e043d3705": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ @@ -155,7 +156,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "0aa2f6b87f68c67ef67bd32929e10bff6bf0226918aaea44bb9ea065e2efb1db": + if hashlib.sha256(raw).hexdigest() != "275b3741eeff87a3257237e597c78a5de02fd4d7b64c005f3437099e043d3705": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ diff --git a/tests/ci/test_command_key_mount_boundary.py b/tests/ci/test_command_key_mount_boundary.py new file mode 100644 index 00000000..11320275 --- /dev/null +++ b/tests/ci/test_command_key_mount_boundary.py @@ -0,0 +1,246 @@ +"""Opt-in mount controls using generated include files, never package code.""" + +from contextlib import contextmanager +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import stat +import subprocess +import tempfile +import time +import types +import unittest + + +HERE = Path(__file__).resolve().parent +SMOKE = HERE.parent / "smoke" +IMAGE = os.environ.get("SDK_PHP_COMMAND_KEY_MOUNT_IMAGE", "") +OBSERVATIONS = [] +TARGETS = ("consumer/vendor/autoload.php", "consumer/vendor/composer/nested.php", + "fixture/command-key-client.php", "fixture/nested/leaf.php") +PROBE = r''' +$targets=['consumer/vendor/autoload.php','consumer/vendor/composer/nested.php', + 'fixture/command-key-client.php','fixture/nested/leaf.php']; +$results=[]; +foreach($targets as $index=>$relative){ + $target='/closure/'.$relative; + $results[]=@file_put_contents($target,"synthetic replacement\n")!==false; + $results[]=@rename(dirname($target).'/candidate-'.$index,$target); +} +echo json_encode($results,JSON_THROW_ON_ERROR),"\n"; +''' + + +def selected_bytes(path): + if not IMAGE: + return path.read_bytes() + source = os.environ.get("SDK_PHP_COMMAND_KEY_BOUNDARY_SOURCE", "") + if re.fullmatch(r"[0-9a-f]{40}", source) is None: + raise RuntimeError("The boundary test needs its exact reviewed Git source") + repository = HERE.parents[1] + raw = subprocess.check_output(["/usr/bin/git", "--no-replace-objects", "-C", str(repository), + "show", source + ":" + path.relative_to(repository).as_posix()], timeout=10) + if path.read_bytes() != raw: + raise RuntimeError("Boundary test inputs differ from the reviewed source") + return raw + + +def load(name): + path = SMOKE / (name + ".py") + module = types.ModuleType(name) + module.__file__ = str(path) + exec(compile(selected_bytes(path), str(path), "exec"), module.__dict__) + return module + + +COHORT = load("command_key_cohort") +CONTAINERS = load("command_key_containers") +CLOSURE = load("command_key_include_closure") +SUPPLY = json.loads(selected_bytes(SMOKE / "command-key-supply.json")) + + +def boundary_arguments(root, *, writable=False): + arguments = ["--pull", "never", *COHORT.php_consumer_arguments(root, SUPPLY)] + if writable: + position = arguments.index("--mount") + 1 + if not arguments[position].endswith(",readonly"): + raise RuntimeError("The countercontrol may remove only the literal read-only mount flag") + arguments[position] = arguments[position].removesuffix(",readonly") + return arguments + + +class ConsumerArguments(unittest.TestCase): + def test_unprivileged_complete_closure_factory_has_no_writable_alias(self): + from unittest.mock import patch + root = Path("/run/sdk-php-command-keys.abcdef") + with patch.object(COHORT.os, "getuid", return_value=1001), patch.object(COHORT.os, "getgid", return_value=1001): + arguments = boundary_arguments(root) + countercontrol = boundary_arguments(root, writable=True) + self.assertEqual(arguments[:2], ["--pull", "never"]) + self.assertEqual(arguments[arguments.index("--mount") + 1], "type=bind,src=" + str(root) + ",dst=/closure,readonly") + self.assertEqual(arguments[arguments.index("--user") + 1], "1001:1001") + self.assertEqual(arguments[arguments.index("--tmpfs") + 1], "/tmp:mode=1777,size=128m") + self.assertEqual(arguments.count("--mount"), 1) + self.assertEqual([index for index, values in enumerate(zip(arguments, countercontrol)) if values[0] != values[1]], + [arguments.index("--mount") + 1]) + with patch.object(COHORT.os, "getuid", return_value=0), self.assertRaises(RuntimeError): + COHORT.php_consumer_arguments(root, SUPPLY) + + +@unittest.skipUnless(IMAGE, "Boundary controls require an exact reviewed source and cached selected PHP image.") +class SyntheticMountBoundary(unittest.TestCase): + def setUp(self): + self.assertEqual(IMAGE, "php@" + SUPPLY["php_index"]) + self.assertGreater(os.getuid(), 0) + source = os.environ.get("SDK_PHP_COMMAND_KEY_BOUNDARY_SOURCE", "") + self.assertRegex(source, r"\A[0-9a-f]{40}\Z") + repository = HERE.parents[1] + for path in (Path(__file__), *(SMOKE / (name + ".py") for name in + ("command_key_cohort", "command_key_containers", "command_key_include_closure")), + SMOKE / "command-key-supply.json"): + relative = path.relative_to(repository).as_posix() + approved = subprocess.check_output(["/usr/bin/git", "--no-replace-objects", "-C", str(repository), + "show", source + ":" + relative], timeout=10) + self.assertEqual(path.read_bytes(), approved) + self.work = Path(tempfile.mkdtemp(prefix="sdk-php-boundary-data-")) + self.active = set() + self.addCleanup(self.remove_owned_data) + + def remove_owned_data(self): + self.assertFalse(self.active, "Preserve fixture data until exact container absence is established") + self.assertEqual(self.work.resolve(), self.work) + self.assertEqual(self.work.lstat().st_uid, os.getuid()) + for path in [self.work, *self.work.rglob("*")]: + if path.is_dir() and not path.is_symlink(): + path.chmod(0o700) + self.assertTrue(shutil.rmtree.avoids_symlink_attacks) + shutil.rmtree(self.work) + + def synthetic_tree(self, name): + root = self.work / name + root.mkdir(mode=0o700) + for index, relative in enumerate(TARGETS): + path = root / relative + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + path.write_text("` from the selected supply, and +`SDK_PHP_COMMAND_KEY_BOUNDARY_SOURCE` to the exact reviewed Git commit. Launch +the test from captured Git bytes through a literal bootstrap that verifies an +independently fixed test digest before compile/exec. The helpers and supply are +also captured from that commit. A direct mutable-path launch does not supply +that outer binding. Default CI runs only the inert factory control and skips +the two effectful tests. Preparing this source does not qualify the actual +mount, dependency profile, cleanup-after-state-refusal or installed cohort. Run the offline input controls against an existing immutable PHP image: diff --git a/tests/smoke/command_key_cohort.py b/tests/smoke/command_key_cohort.py index a6bfb6dc..79c72f38 100644 --- a/tests/smoke/command_key_cohort.py +++ b/tests/smoke/command_key_cohort.py @@ -157,6 +157,14 @@ def publish_outputs(values): os.close(descriptor) +def php_consumer_arguments(root, supply): + require(os.getuid() > 0, "The PHP fixture cannot run as root") + return ["--network", "host", "--user", str(os.getuid()) + ":" + str(os.getgid()), + "--workdir", "/closure/consumer", "--tmpfs", "/tmp:mode=1777,size=128m", + "--mount", "type=bind,src=" + str(root) + ",dst=/closure,readonly", "php@" + supply["php_index"], + "php", "-c", "/usr/local/etc/php/php.ini-production", "-r", "sleep(900);"] + + def prepare_containers(resources, supply, token, root): try: server_image = "durableworkflow/server@" + supply["server_index"] @@ -185,12 +193,7 @@ def prepare_containers(resources, supply, token, root): php_image = "php@" + supply["php_index"] php = resources.pull(php_image, supply["php_config"]) require("PHP_VERSION=" + supply["php_version"] in php["Config"]["Env"], "The PHP image version differs") - user = str(os.getuid()) + ":" + str(os.getgid()) - require(os.getuid() > 0, "The PHP fixture cannot run as root") - consumer = resources.create("consumer", ["--network", "host", "--user", user, - "--workdir", "/closure/consumer", "--tmpfs", "/tmp:mode=1777,size=128m", - "--mount", "type=bind,src=" + str(root) + ",dst=/closure,readonly", php_image, - "php", "-c", "/usr/local/etc/php/php.ini-production", "-r", "sleep(900);"]) + consumer = resources.create("consumer", php_consumer_arguments(root, supply)) selected_image(resources.owned(consumer, "consumer"), supply, "php") resources.start(consumer, "consumer") consumer_record(resources, consumer, supply, root) From 90ee5d20beb7c41b389ecfeea538383aeb057ac3 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:15:35 +0000 Subject: [PATCH 13/17] Carry literal fixture authority across privileged staging --- tests/ci/test_command_key_mount_boundary.py | 122 ++++++++++++++++++-- tests/smoke/command-keys.md | 11 +- 2 files changed, 121 insertions(+), 12 deletions(-) diff --git a/tests/ci/test_command_key_mount_boundary.py b/tests/ci/test_command_key_mount_boundary.py index 11320275..9135b8b1 100644 --- a/tests/ci/test_command_key_mount_boundary.py +++ b/tests/ci/test_command_key_mount_boundary.py @@ -1,9 +1,10 @@ """Opt-in mount controls using generated include files, never package code.""" from contextlib import contextmanager +import hashlib import json import os -from pathlib import Path +from pathlib import Path, PurePosixPath import re import secrets import shutil @@ -13,6 +14,7 @@ import time import types import unittest +from unittest.mock import Mock, patch HERE = Path(__file__).resolve().parent @@ -21,6 +23,8 @@ OBSERVATIONS = [] TARGETS = ("consumer/vendor/autoload.php", "consumer/vendor/composer/nested.php", "fixture/command-key-client.php", "fixture/nested/leaf.php") +ORIGINAL = b" Date: Fri, 13 Nov 2026 22:17:24 +0000 Subject: [PATCH 14/17] Stop boundary entry on an inert control failure --- tests/ci/test_command_key_mount_boundary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ci/test_command_key_mount_boundary.py b/tests/ci/test_command_key_mount_boundary.py index 9135b8b1..aef57cf3 100644 --- a/tests/ci/test_command_key_mount_boundary.py +++ b/tests/ci/test_command_key_mount_boundary.py @@ -340,7 +340,7 @@ def test_writable_permissions_still_refuse_on_readonly_mount_and_countercontrol_ if __name__ == "__main__": - result = unittest.main(exit=False).result + result = unittest.main(exit=False, failfast=True).result if IMAGE: print(json.dumps({"scope": "synthetic-mount-controls-only", "observations": OBSERVATIONS})) raise SystemExit(not result.wasSuccessful()) From 85861c8bb4751b77275e55d56b6ccdef2ccc48f0 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:41:14 +0000 Subject: [PATCH 15/17] Handle legacy tmpfs inspection in command-key runner --- .github/workflows/published-command-keys.yml | 6 +-- tests/ci/test_command_key_cohort.py | 41 +++++++++++++++++++- tests/ci/test_command_key_mount_boundary.py | 14 +++++-- tests/smoke/command_key_cohort.py | 21 ++++++---- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/.github/workflows/published-command-keys.yml b/.github/workflows/published-command-keys.yml index 8e6ce5a3..92f44ef2 100644 --- a/.github/workflows/published-command-keys.yml +++ b/.github/workflows/published-command-keys.yml @@ -91,7 +91,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "275b3741eeff87a3257237e597c78a5de02fd4d7b64c005f3437099e043d3705": + if hashlib.sha256(raw).hexdigest() != "ce34c084c0a45c861a1cbe791a6be90936d59b3c7e3bce4066dad3c38e42e0c2": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ @@ -129,7 +129,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "275b3741eeff87a3257237e597c78a5de02fd4d7b64c005f3437099e043d3705": + if hashlib.sha256(raw).hexdigest() != "ce34c084c0a45c861a1cbe791a6be90936d59b3c7e3bce4066dad3c38e42e0c2": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ @@ -156,7 +156,7 @@ jobs: raise RuntimeError("An exact trusted runner source is required") path="tests/smoke/command_key_cohort.py" raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) - if hashlib.sha256(raw).hexdigest() != "275b3741eeff87a3257237e597c78a5de02fd4d7b64c005f3437099e043d3705": + if hashlib.sha256(raw).hexdigest() != "ce34c084c0a45c861a1cbe791a6be90936d59b3c7e3bce4066dad3c38e42e0c2": raise RuntimeError("The captured controller differs from its independently fixed digest") exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) ' \ diff --git a/tests/ci/test_command_key_cohort.py b/tests/ci/test_command_key_cohort.py index 0ca3bde3..60756af6 100644 --- a/tests/ci/test_command_key_cohort.py +++ b/tests/ci/test_command_key_cohort.py @@ -31,7 +31,9 @@ def runtime(): "Init": True, "CapDrop": ["ALL"], "CapAdd": None, "SecurityOpt": ["no-new-privileges"], "Devices": [], "NetworkMode": "bridge", "Memory": 1024**3, "NanoCpus": 10**9, "PidsLimit": 256, - "Tmpfs": {"/tmp": "size=64m", "/app/database": "size=32m", "/app/storage": "size=64m"}}, + "Tmpfs": {"/tmp": "mode=1777,size=64m", + "/app/database": "uid=1000,gid=1000,mode=0700,size=32m", + "/app/storage": "uid=1000,gid=1000,mode=0700,size=64m"}}, "Mounts": [{"Type": "tmpfs", "Destination": path} for path in ("/tmp", "/app/database", "/app/storage")], "NetworkSettings": {"Ports": {"8080/tcp": [{"HostIp": "127.0.0.1", "HostPort": "43123"}]}}} @@ -41,7 +43,7 @@ def consumer(): record["Image"] = SUPPLY["php_config"] record["Config"]["Image"] = "php@" + SUPPLY["php_index"] record["Config"]["User"] = "1001:1001" - record["HostConfig"].update(NetworkMode="host", Tmpfs={"/tmp": "size=128m"}) + record["HostConfig"].update(NetworkMode="host", Tmpfs={"/tmp": "mode=1777,size=128m"}) record["Mounts"] = [{"Type": "bind", "Source": str(ROOT), "Destination": "/closure", "RW": False}, {"Type": "tmpfs", "Destination": "/tmp"}] record["NetworkSettings"]["Ports"] = None @@ -67,6 +69,41 @@ def php(self, record): def test_complete_native_profile_selects_numeric_loopback(self): self.assertEqual(self.origin(runtime()), "http://127.0.0.1:43123") + def test_legacy_tmpfs_configuration_and_complete_reported_form_both_pass(self): + for factory, check in ((runtime, self.origin), (consumer, self.php)): + complete = factory() + check(complete) + legacy = deepcopy(complete) + legacy["Mounts"] = [mount for mount in legacy["Mounts"] if mount["Type"] != "tmpfs"] + check(legacy) + + def test_duplicate_reported_destinations_refuse_for_both_roles(self): + for factory, check in ((runtime, self.origin), (consumer, self.php)): + record = factory() + record["Mounts"].append(deepcopy(record["Mounts"][-1])) + with self.subTest(role=factory.__name__), self.assertRaisesRegex(RuntimeError, "mount destinations"): + check(record) + + def test_partial_reported_native_tmpfs_set_refuses(self): + record = runtime() + record["Mounts"].pop() + with self.assertRaisesRegex(RuntimeError, "mount destinations"): + self.origin(record) + + def test_tmpfs_size_permissions_and_declared_path_set_are_exact(self): + for factory, check in ((runtime, self.origin), (consumer, self.php)): + for options in ("mode=1777,size=1g", "mode=0777,size=128m", "", None): + record = factory() + record["HostConfig"]["Tmpfs"]["/tmp"] = options + with self.subTest(role=factory.__name__, options=options), self.assertRaisesRegex(RuntimeError, "tmpfs configuration"): + check(record) + for declared in ({}, {"/tmp": "mode=1777,size=128m", "/closure": "size=1m"}): + record = factory() + record["Mounts"] = [mount for mount in record["Mounts"] if mount["Type"] != "tmpfs"] + record["HostConfig"]["Tmpfs"] = declared + with self.subTest(role=factory.__name__, declared=declared), self.assertRaisesRegex(RuntimeError, "tmpfs configuration"): + check(record) + def test_containerd_index_identity_requires_the_exact_platform_manifest(self): for factory, role, check in ((runtime, "server", self.origin), (consumer, "php", self.php)): record = factory() diff --git a/tests/ci/test_command_key_mount_boundary.py b/tests/ci/test_command_key_mount_boundary.py index aef57cf3..ea508d39 100644 --- a/tests/ci/test_command_key_mount_boundary.py +++ b/tests/ci/test_command_key_mount_boundary.py @@ -181,9 +181,12 @@ def test_complete_literal_tree_crosses_simulated_copy_unchanged(self): def test_precopy_replacement_cannot_redefine_the_generated_authority(self): create_container = Mock() with self.copy_sandbox(replace_before_copy=True) as (case, _, copied): - with self.assertRaises(AssertionError): + def create_after_sealing(): root = case.sealed_root() create_container(root) + + with self.assertRaises(AssertionError): + create_after_sealing() self.assertEqual(copied, [b" Date: Fri, 13 Nov 2026 22:55:58 +0000 Subject: [PATCH 16/17] Prepare isolated cleanup control after fixture refusal --- tests/ci/test_command_key_cleanup_boundary.py | 166 ++++++++++++++++++ tests/smoke/command-keys.md | 20 +++ 2 files changed, 186 insertions(+) create mode 100644 tests/ci/test_command_key_cleanup_boundary.py diff --git a/tests/ci/test_command_key_cleanup_boundary.py b/tests/ci/test_command_key_cleanup_boundary.py new file mode 100644 index 00000000..d6131d4b --- /dev/null +++ b/tests/ci/test_command_key_cleanup_boundary.py @@ -0,0 +1,166 @@ +"""Opt-in idle-container proof of the literal cleanup after control refusal.""" + +import hashlib +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import stat +import subprocess +import tempfile +import textwrap +import time +import types +import unittest + + +REPOSITORY = Path(__file__).resolve().parents[2] +IMAGE = os.environ.get("SDK_PHP_COMMAND_KEY_CLEANUP_IMAGE", "") +SOURCE = os.environ.get("SDK_PHP_COMMAND_KEY_CLEANUP_SOURCE", "") +CLEANUP_SHA256 = "5cbfb06cd9ef5d6c73c006b48584f89e9c335d27d608c6616c133291316b0f83" +OBSERVATIONS = [] + + +def captured_bytes(relative): + path = REPOSITORY / relative + if not IMAGE: + return path.read_bytes() + if re.fullmatch(r"[0-9a-f]{40}", SOURCE) is None: + raise RuntimeError("The cleanup control needs its exact reviewed source") + raw = subprocess.check_output(["/usr/bin/git", "--no-replace-objects", "-C", str(REPOSITORY), + "show", SOURCE + ":" + relative], timeout=10) + if path.read_bytes() != raw: + raise RuntimeError("Cleanup control inputs differ from the reviewed source") + return raw + + +def load(name): + relative = "tests/smoke/" + name + ".py" + module = types.ModuleType(name) + module.__file__ = str(REPOSITORY / relative) + exec(compile(captured_bytes(relative), module.__file__, "exec"), module.__dict__) + return module + + +def cleanup_script(raw): + body = raw.decode().split("<<'PYCLEAN' | tee command-key-cleanup.json\n", 1)[1].split("\n PYCLEAN", 1)[0] + script = textwrap.dedent(body) + if hashlib.sha256(script.encode()).hexdigest() != CLEANUP_SHA256: + raise RuntimeError("The independently fixed literal cleanup digest differs") + return script + + +WORKFLOW = captured_bytes(".github/workflows/published-command-keys.yml") +CONTAINERS = load("command_key_containers") +COHORT = load("command_key_cohort") +SUPPLY = json.loads(captured_bytes("tests/smoke/command-key-supply.json")) + + +class CleanupSourceAuthority(unittest.TestCase): + def test_exact_literal_cleanup_is_bound(self): + script = cleanup_script(WORKFLOW) + compile(script, "literal-independent-cleanup", "exec") + + def test_changed_cleanup_refuses_before_compilation(self): + changed = WORKFLOW.replace(b"/usr/bin/docker", b"/bin/false", 1) + self.assertNotEqual(changed, WORKFLOW) + with self.assertRaisesRegex(RuntimeError, "fixed literal cleanup digest"): + cleanup_script(changed) + + +@unittest.skipUnless(IMAGE, "Cleanup control requires exact reviewed source and the cached selected PHP image.") +class SyntheticCleanupBoundary(unittest.TestCase): + def setUp(self): + self.assertEqual(IMAGE, "php@" + SUPPLY["php_index"]) + self.assertGreater(os.getuid(), 0) + captured_bytes("tests/ci/test_command_key_cleanup_boundary.py") + self.script = cleanup_script(WORKFLOW) + self.work = Path(tempfile.mkdtemp(prefix="sdk-php-cleanup-control-")) + self.active = set() + self.captured = {} + self.addCleanup(self.remove_owned_data) + + def remove_owned_data(self): + self.assertFalse(self.active, "Preserve control data until exact container absence is established") + metadata = self.work.lstat() + self.assertEqual(self.work.resolve(), self.work) + self.assertTrue(stat.S_ISDIR(metadata.st_mode)) + self.assertEqual(metadata.st_uid, os.getuid()) + self.assertTrue(shutil.rmtree.avoids_symlink_attacks) + shutil.rmtree(self.work) + + def record_created(self, role, identity): + self.assertNotIn(role, self.captured) + self.captured[role] = identity + self.active.add(identity) + + def test_literal_cleanup_removes_both_roles_after_checkout_state_and_config_refuse(self): + configuration, state, observer = (self.work / name for name in ("docker", "records", "observer")) + for path in (configuration, state, observer): + path.mkdir(mode=0o700) + run_id, attempt = str(os.getpid()), str(time.time_ns()) + nonce = secrets.token_hex(16) + resources = CONTAINERS.Containers(configuration, state, run_id + "-" + attempt, nonce, self.record_created) + observation = {"source": SOURCE, "cleanup_sha256": CLEANUP_SHA256, + "fixture": "two-idle-php-roles-only", "captured_ids": self.captured, + "checkout_refused": False, "state_refused": False, "config_refused": False, + "container_removed": False} + OBSERVATIONS.append(observation) + try: + for role in ("server", "consumer"): + identity = resources.create(role, ["--pull", "never", "--network", "none", "--user", + str(os.getuid()) + ":" + str(os.getgid()), IMAGE, "php", "-n", "-r", "sleep(90);"]) + COHORT.selected_image(resources.owned(identity, role), SUPPLY, "php") + resources.start(identity, role) + record = resources.owned(identity, role) + COHORT.confined(record, SUPPLY, "php") + self.assertEqual(record["HostConfig"]["NetworkMode"], "none") + self.assertEqual(record["Config"]["User"], str(os.getuid()) + ":" + str(os.getgid())) + self.assertEqual(record["Mounts"], []) + self.assertFalse(record["HostConfig"].get("Tmpfs")) + self.assertFalse(record["NetworkSettings"]["Ports"]) + with self.assertRaisesRegex(RuntimeError, "exact reviewed runner commit"): + COHORT.capture_inputs("invalid") + observation["checkout_refused"] = True + os.fchmod(resources.state_descriptor, 0o755) + with self.assertRaisesRegex(RuntimeError, "canonical, private and owned"): + CONTAINERS.Containers(configuration, state, run_id + "-" + attempt, nonce) + observation["state_refused"] = True + descriptor = os.open(configuration / "config.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "w") as output: + output.write("{}\n") + with self.assertRaisesRegex(RuntimeError, "configuration must remain empty"): + resources.environment() + observation["config_refused"] = True + finally: + resources.close() + environment = {"PATH": "/usr/bin:/bin", "GITHUB_RUN_ID": run_id, "GITHUB_RUN_ATTEMPT": attempt, + "FIXTURE_NONCE": nonce, "CONSUMER_ID": self.captured.get("consumer", ""), + "SERVER_ID": self.captured.get("server", ""), "INCLUDE_ROOT": "", "INCLUDE_IDENTITY": "", + "GITHUB_SHA": "invalid", "COMMAND_KEY_STATE": str(self.work / "missing-state"), + "TMPDIR": str(self.work / "missing-temp"), "DOCKER_CONFIG": str(configuration), + "DOCKER_HOST": "tcp://example.invalid:2375"} + cleanup = subprocess.run(["/usr/bin/python3", "-I", "-S", "-B", "-c", self.script], + env=environment, cwd=self.work, capture_output=True, text=True, timeout=120) + observation["cleanup_exit"] = cleanup.returncode + observation["cleanup_stdout"] = cleanup.stdout + self.assertEqual(cleanup.returncode, 0, cleanup.stderr) + self.assertEqual(json.loads(cleanup.stdout), {"runtime_removed": True, "errors": []}) + empty_environment = {"PATH": "/usr/bin:/bin", "DOCKER_CONFIG": str(observer), + "DOCKER_HOST": "unix:///var/run/docker.sock"} + for identity in self.captured.values(): + absent = subprocess.run(["/usr/bin/docker", "inspect", identity], env=empty_environment, + capture_output=True, text=True, timeout=15) + self.assertTrue(CONTAINERS.exact_missing(absent, identity)) + self.active.remove(identity) + observation["container_removed"] = not self.active + self.assertEqual(set(self.captured), {"server", "consumer"}) + + +if __name__ == "__main__": + result = unittest.main(exit=False, failfast=True).result + if IMAGE: + print(json.dumps({"scope": "synthetic-state-refusal-cleanup-only", "observations": OBSERVATIONS})) + raise SystemExit(not result.wasSuccessful()) diff --git a/tests/smoke/command-keys.md b/tests/smoke/command-keys.md index 5f31b80e..032a5892 100644 --- a/tests/smoke/command-keys.md +++ b/tests/smoke/command-keys.md @@ -170,6 +170,26 @@ that outer binding. Default CI runs only the inert factory control and skips the two effectful tests. Preparing this source does not qualify the actual mount, dependency profile, cleanup-after-state-refusal or installed cohort. +`tests/ci/test_command_key_cleanup_boundary.py` proposes a separate opt-in +cleanup control. It creates two idle containers from the selected cached PHP +image, using only the two fixture role labels. No native Server, SDK, API or +Worker runs. Both containers have read-only roots, unprivileged users, no +mounts or network, and the existing resource caps. Their full IDs are captured +before the production guards refuse an invalid source selection, nonprivate +disposable state and nonempty disposable Docker configuration. + +The control then runs the unchanged literal workflow cleanup from captured Git +bytes with an independently fixed digest. Its environment supplies the held +IDs and labels while deliberately invalidating checkout, state, temporary and +ambient Docker configuration values. The cleanup must use its own fresh Docker +configuration and establish both exact absences. A separate observer checks +the full IDs again before owned fixture data is removed. Cleanup failure +preserves that data. Default CI checks the cleanup source binding and skips the +container control. Any actual execution requires exact source/entry review, +`SDK_PHP_COMMAND_KEY_CLEANUP_IMAGE` and `SDK_PHP_COMMAND_KEY_CLEANUP_SOURCE`, +and a Git-blob bootstrap with an independently fixed outer test digest. This +control supplies no dependency, consumer profile or installed acceptance. + Run the offline input controls against an existing immutable PHP image: ```sh From 3d1d8315a967e730136757c8c8085fdc24d0db16 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:18:42 +0000 Subject: [PATCH 17/17] Reconcile lost create IDs during independent fixture cleanup --- .github/workflows/published-command-keys.yml | 42 +++- tests/ci/test_command_key_cleanup_boundary.py | 228 ++++++++++++++++-- tests/ci/test_command_key_cohort.py | 4 + tests/smoke/command-keys.md | 25 +- 4 files changed, 260 insertions(+), 39 deletions(-) diff --git a/.github/workflows/published-command-keys.yml b/.github/workflows/published-command-keys.yml index 92f44ef2..765c3e34 100644 --- a/.github/workflows/published-command-keys.yml +++ b/.github/workflows/published-command-keys.yml @@ -179,7 +179,7 @@ jobs: /usr/bin/python3 -I -S -B - <<'PYCLEAN' | tee command-key-cleanup.json import json,os,pathlib,re,stat,subprocess,tempfile owner=os.environ['GITHUB_RUN_ID']+'-'+os.environ['GITHUB_RUN_ATTEMPT'] - nonce=os.environ['FIXTURE_NONCE']; errors=[] + nonce=os.environ['FIXTURE_NONCE']; errors=[]; reconciled={}; role_absence={} with tempfile.TemporaryDirectory(prefix='sdk-php-command-key-cleanup-',dir='/tmp') as configuration: environment={'PATH':'/usr/bin:/bin','DOCKER_CONFIG':configuration,'DOCKER_HOST':'unix:///var/run/docker.sock'} def invoke(arguments): @@ -187,15 +187,40 @@ jobs: def absent(result,identity): return result.returncode==1 and result.stdout=='[]\n' and result.stderr in { 'Error: No such object: '+identity+'\n','error: no such object: '+identity+'\n'} + def candidates(role): + result=invoke(['ps','--all','--no-trunc','--last','2', + '--filter','label=durable-workflow.php-command-keys='+owner, + '--filter','label=durable-workflow.fixture-role='+role, + '--filter','label=durable-workflow.fixture-nonce='+nonce,'--format','{{.ID}}']) + if (result.returncode!=0 or result.stderr or len(result.stdout)>130 + or re.fullmatch(r'(?:[0-9a-f]{64}\n){0,2}',result.stdout) is None): + raise RuntimeError('Role reconciliation was not complete') + identities=result.stdout.splitlines() + if len(identities)>1: + raise RuntimeError('Ambiguous role candidates preserved') + return identities for role,key in [('consumer','CONSUMER_ID'),('server','SERVER_ID')]: - identity=os.environ[key] - if not identity: + identity=os.environ.get(key,'') + if not identity and not nonce: continue try: - if re.fullmatch('[0-9a-f]{64}',identity) is None or re.fullmatch('[0-9a-f]{32}',nonce) is None: + role_absence[role]=False + if (re.fullmatch('[0-9]+-[0-9]+',owner) is None or re.fullmatch('[0-9a-f]{32}',nonce) is None + or (identity and re.fullmatch('[0-9a-f]{64}',identity) is None)): raise RuntimeError('Invalid immutable fixture outputs') + matches=candidates(role) + if identity and matches and matches!=[identity]: + raise RuntimeError('Foreign role replacement preserved') + recovered=not identity and bool(matches) + identity=identity or (matches[0] if matches else '') + if not identity: + role_absence[role]=True + continue result=invoke(['inspect',identity]) if absent(result,identity): + if candidates(role): + raise RuntimeError('A labelled role remains') + role_absence[role]=True continue if result.returncode!=0: raise RuntimeError('The recorded fixture cannot be inspected') @@ -205,9 +230,13 @@ jobs: labels=rows[0]['Config'].get('Labels') or {} if (labels.get('durable-workflow.php-command-keys'),labels.get('durable-workflow.fixture-role'),labels.get('durable-workflow.fixture-nonce'))!=(owner,role,nonce): raise RuntimeError('Foreign replacement preserved') + if recovered: + reconciled[role]=identity removed=invoke(['rm','--force','--volumes',identity]) - if removed.returncode!=0 or not absent(invoke(['inspect',identity]),identity): + if (removed.returncode!=0 or not absent(invoke(['inspect',identity]),identity) + or candidates(role)): raise RuntimeError('Exact fixture absence was not established') + role_absence[role]=True except Exception as error: errors.append(role+':'+type(error).__name__) root=os.environ['INCLUDE_ROOT'] @@ -223,7 +252,8 @@ jobs: raise RuntimeError('Include root absence was not established') except Exception as error: errors.append('include:'+type(error).__name__) - print(json.dumps({'runtime_removed':not errors,'errors':errors})) + print(json.dumps({'runtime_removed':not errors,'errors':errors, + 'reconciled_ids':reconciled,'role_absence':role_absence})) if errors: raise SystemExit(1) PYCLEAN diff --git a/tests/ci/test_command_key_cleanup_boundary.py b/tests/ci/test_command_key_cleanup_boundary.py index d6131d4b..497ebc3a 100644 --- a/tests/ci/test_command_key_cleanup_boundary.py +++ b/tests/ci/test_command_key_cleanup_boundary.py @@ -14,12 +14,13 @@ import time import types import unittest +from unittest.mock import patch REPOSITORY = Path(__file__).resolve().parents[2] IMAGE = os.environ.get("SDK_PHP_COMMAND_KEY_CLEANUP_IMAGE", "") SOURCE = os.environ.get("SDK_PHP_COMMAND_KEY_CLEANUP_SOURCE", "") -CLEANUP_SHA256 = "5cbfb06cd9ef5d6c73c006b48584f89e9c335d27d608c6616c133291316b0f83" +CLEANUP_SHA256 = "8b30e60e4de5349c99521e0e258a5fa99be5831f8a6defed3db007ed4527f15f" OBSERVATIONS = [] @@ -69,6 +70,127 @@ def test_changed_cleanup_refuses_before_compilation(self): with self.assertRaisesRegex(RuntimeError, "fixed literal cleanup digest"): cleanup_script(changed) + def simulate(self, create_output="", fault=None, script=None): + server, consumer, extra = "a" * 64, "c" * 64, "e" * 64 + live = {consumer: {CONTAINERS.OWNER: "123-1", CONTAINERS.ROLE: "consumer", CONTAINERS.NONCE: "b" * 32}} + captured, removed = {}, set() + with tempfile.TemporaryDirectory(prefix="sdk-php-lost-create-inert-") as temporary: + root = Path(temporary) + configuration, state = root / "docker", root / "state" + configuration.mkdir(mode=0o700) + state.mkdir(mode=0o700) + resources = CONTAINERS.Containers(configuration, state, "123-1", "b" * 32, + lambda role, identity: captured.update({role: identity})) + + def create(arguments, **_): + self.assertEqual(arguments[0], "create") + live[server] = {CONTAINERS.OWNER: "123-1", CONTAINERS.ROLE: "server", CONTAINERS.NONCE: "b" * 32} + if isinstance(create_output, BaseException): + raise create_output + return types.SimpleNamespace(returncode=0, stdout=create_output, stderr="") + + try: + with patch.object(resources, "command", side_effect=create), self.assertRaisesRegex(RuntimeError, "one full ID"): + resources.create("server", []) + self.assertEqual(captured, {}) + finally: + resources.close() + if fault == "duplicate": + live[extra] = dict(live[server]) + + def invoke(arguments, **options): + self.assertEqual(arguments[0], "/usr/bin/docker") + self.assertEqual(options["env"], {"PATH": "/usr/bin:/bin", "DOCKER_CONFIG": "/inert-cleanup-config", + "DOCKER_HOST": "unix:///var/run/docker.sock"}) + if arguments[1] == "ps": + self.assertEqual(arguments[2:6], ["--all", "--no-trunc", "--last", "2"]) + self.assertIn("label=" + CONTAINERS.OWNER + "=123-1", arguments) + self.assertIn("label=" + CONTAINERS.NONCE + "=" + "b" * 32, arguments) + role = "server" if "label=" + CONTAINERS.ROLE + "=server" in arguments else "consumer" + if role == "consumer" and fault in {"listing-failed", "listing-stderr", "listing-partial", "listing-overflow"}: + outputs = {"listing-failed": (1, "", "engine unavailable\n"), + "listing-stderr": (0, consumer + "\n", "mixed error\n"), + "listing-partial": (0, "abc\n", ""), + "listing-overflow": (0, (consumer + "\n") * 3, "")} + code, stdout, stderr = outputs[fault] + return types.SimpleNamespace(returncode=code, stdout=stdout, stderr=stderr) + identities = [identity for identity, labels in live.items() if labels[CONTAINERS.ROLE] == role] + return types.SimpleNamespace(returncode=0, stdout="".join(identity + "\n" for identity in identities[:2]), stderr="") + identity = arguments[-1] + if arguments[1] == "rm": + self.assertIn(identity, live) + labels = live.pop(identity) + removed.add(identity) + if fault == "replacement" and identity == consumer: + live[extra] = labels + return types.SimpleNamespace(returncode=0, stdout=identity + "\n", stderr="") + self.assertEqual(arguments[1], "inspect") + if fault == "consumer-inspect" and identity == consumer: + return types.SimpleNamespace(returncode=1, stdout="[]\n", stderr="engine unavailable\n") + if identity not in live: + return types.SimpleNamespace(returncode=1, stdout="[]\n", stderr="error: no such object: " + identity + "\n") + labels = dict(live[identity]) + reported_identity = identity + if identity == server: + for label, selected_fault in ((CONTAINERS.OWNER, "foreign-owner"), (CONTAINERS.ROLE, "foreign-role"), + (CONTAINERS.NONCE, "foreign-nonce")): + if fault == selected_fault: + labels[label] = "foreign" + if fault == "foreign-id": + reported_identity = extra + return types.SimpleNamespace(returncode=0, stdout=json.dumps([{"Id": reported_identity, "Config": {"Labels": labels}}]), stderr="") + + environment = {"GITHUB_RUN_ID": "123", "GITHUB_RUN_ATTEMPT": "1", "FIXTURE_NONCE": "b" * 32, + "SERVER_ID": "", "CONSUMER_ID": consumer, "INCLUDE_ROOT": "", "INCLUDE_IDENTITY": "", + "GITHUB_SHA": "invalid", "COMMAND_KEY_STATE": "/missing-state", "TMPDIR": "/missing-temp", + "DOCKER_CONFIG": "/refused-config", "DOCKER_HOST": "tcp://example.invalid:2375"} + code = 0 + with patch.dict(os.environ, environment, clear=True), patch("tempfile.TemporaryDirectory") as temporary, \ + patch("subprocess.run", side_effect=invoke), patch("builtins.print") as printed: + temporary.return_value.__enter__.return_value = "/inert-cleanup-config" + try: + exec(compile(script if script is not None else cleanup_script(WORKFLOW), "inert-independent-cleanup", "exec"), {}) + except SystemExit as error: + code = error.code + return types.SimpleNamespace(code=code, outcome=json.loads(printed.call_args.args[0]), live=live, removed=removed) + + def test_lost_and_ambiguous_create_outputs_are_reconciled(self): + for output in ("", "ambiguous\n", CONTAINERS.FixtureTimeout("")): + result = self.simulate(output) + self.assertEqual(result.live, {}) + self.assertEqual(result.code, 0) + self.assertEqual(result.outcome["reconciled_ids"], {"server": "a" * 64}) + self.assertEqual(result.outcome["role_absence"], {"server": True, "consumer": True}) + + def test_duplicate_and_foreign_candidates_refuse(self): + for fault in ("duplicate", "foreign-owner", "foreign-role", "foreign-nonce", "foreign-id"): + result = self.simulate(fault=fault) + self.assertEqual(result.code, 1) + self.assertFalse(result.outcome["runtime_removed"]) + self.assertIn("a" * 64, result.live) + self.assertIn("c" * 64, result.removed) + + def test_one_role_error_does_not_skip_cleanup_of_the_other(self): + result = self.simulate(fault="consumer-inspect") + self.assertEqual(result.code, 1) + self.assertIn("c" * 64, result.live) + self.assertIn("a" * 64, result.removed) + self.assertEqual(result.outcome["role_absence"], {"consumer": False, "server": True}) + + def test_postdelete_role_replacement_cannot_be_reported_absent(self): + result = self.simulate(fault="replacement") + self.assertEqual(result.code, 1) + self.assertIn("e" * 64, result.live) + self.assertEqual(result.removed, {"a" * 64, "c" * 64}) + self.assertFalse(result.outcome["role_absence"]["consumer"]) + + def test_malformed_or_failed_role_listing_refuses(self): + for fault in ("listing-failed", "listing-stderr", "listing-partial", "listing-overflow"): + result = self.simulate(fault=fault) + self.assertEqual(result.code, 1) + self.assertIn("c" * 64, result.live) + self.assertIn("a" * 64, result.removed) + @unittest.skipUnless(IMAGE, "Cleanup control requires exact reviewed source and the cached selected PHP image.") class SyntheticCleanupBoundary(unittest.TestCase): @@ -80,9 +202,11 @@ def setUp(self): self.work = Path(tempfile.mkdtemp(prefix="sdk-php-cleanup-control-")) self.active = set() self.captured = {} + self.cleanup_proved = False self.addCleanup(self.remove_owned_data) def remove_owned_data(self): + self.assertTrue(self.cleanup_proved, "Preserve evidence until both labelled roles are independently absent") self.assertFalse(self.active, "Preserve control data until exact container absence is established") metadata = self.work.lstat() self.assertEqual(self.work.resolve(), self.work) @@ -104,14 +228,38 @@ def test_literal_cleanup_removes_both_roles_after_checkout_state_and_config_refu nonce = secrets.token_hex(16) resources = CONTAINERS.Containers(configuration, state, run_id + "-" + attempt, nonce, self.record_created) observation = {"source": SOURCE, "cleanup_sha256": CLEANUP_SHA256, - "fixture": "two-idle-php-roles-only", "captured_ids": self.captured, + "fixture": "idle-php-roles-only", "captured_ids": self.captured, + "lost_create_refused": False, "checkout_refused": False, "state_refused": False, "config_refused": False, "container_removed": False} OBSERVATIONS.append(observation) + observation["ownership"] = {"run_id": run_id, "attempt": attempt, "owner": run_id + "-" + attempt, "nonce": nonce} + # Flush the reconciliation tuple before create can lose its stdout. + print(json.dumps({"scope": "synthetic-cleanup-ownership", "source": SOURCE, + "ownership": observation["ownership"]}), flush=True) try: for role in ("server", "consumer"): - identity = resources.create(role, ["--pull", "never", "--network", "none", "--user", - str(os.getuid()) + ":" + str(os.getgid()), IMAGE, "php", "-n", "-r", "sleep(90);"]) + arguments = ["--pull", "never", "--network", "none", "--user", str(os.getuid()) + ":" + str(os.getgid()), + IMAGE, "php", "-n", "-r", "sleep(90);"] + if role == "server": + command = resources.command + + def lose_create_stdout(arguments, **options): + result = command(arguments, **options) + if arguments[0] == "create": + self.assertEqual(result.returncode, 0) + self.assertRegex(result.stdout, r"\A[0-9a-f]{64}\n\Z") + observation["lost_create_id_sha256"] = hashlib.sha256(result.stdout.strip().encode()).hexdigest() + return types.SimpleNamespace(returncode=0, stdout="", stderr=result.stderr) + return result + + with patch.object(resources, "command", side_effect=lose_create_stdout), \ + self.assertRaisesRegex(RuntimeError, "one full ID"): + resources.create(role, arguments) + self.assertNotIn(role, self.captured) + observation["lost_create_refused"] = True + continue + identity = resources.create(role, arguments) COHORT.selected_image(resources.owned(identity, role), SUPPLY, "php") resources.start(identity, role) record = resources.owned(identity, role) @@ -135,28 +283,56 @@ def test_literal_cleanup_removes_both_roles_after_checkout_state_and_config_refu resources.environment() observation["config_refused"] = True finally: - resources.close() - environment = {"PATH": "/usr/bin:/bin", "GITHUB_RUN_ID": run_id, "GITHUB_RUN_ATTEMPT": attempt, - "FIXTURE_NONCE": nonce, "CONSUMER_ID": self.captured.get("consumer", ""), - "SERVER_ID": self.captured.get("server", ""), "INCLUDE_ROOT": "", "INCLUDE_IDENTITY": "", - "GITHUB_SHA": "invalid", "COMMAND_KEY_STATE": str(self.work / "missing-state"), - "TMPDIR": str(self.work / "missing-temp"), "DOCKER_CONFIG": str(configuration), - "DOCKER_HOST": "tcp://example.invalid:2375"} - cleanup = subprocess.run(["/usr/bin/python3", "-I", "-S", "-B", "-c", self.script], - env=environment, cwd=self.work, capture_output=True, text=True, timeout=120) - observation["cleanup_exit"] = cleanup.returncode - observation["cleanup_stdout"] = cleanup.stdout - self.assertEqual(cleanup.returncode, 0, cleanup.stderr) - self.assertEqual(json.loads(cleanup.stdout), {"runtime_removed": True, "errors": []}) - empty_environment = {"PATH": "/usr/bin:/bin", "DOCKER_CONFIG": str(observer), - "DOCKER_HOST": "unix:///var/run/docker.sock"} - for identity in self.captured.values(): - absent = subprocess.run(["/usr/bin/docker", "inspect", identity], env=empty_environment, - capture_output=True, text=True, timeout=15) - self.assertTrue(CONTAINERS.exact_missing(absent, identity)) - self.active.remove(identity) - observation["container_removed"] = not self.active - self.assertEqual(set(self.captured), {"server", "consumer"}) + try: + resources.close() + finally: + self.independent_cleanup(configuration, observer, run_id, attempt, nonce, observation) + self.assertEqual(set(self.captured), {"consumer"}) + recovered = observation["cleanup"]["reconciled_ids"] + self.assertEqual(set(recovered), {"server"}) + self.assertEqual(hashlib.sha256(recovered["server"].encode()).hexdigest(), observation["lost_create_id_sha256"]) + + def independent_cleanup(self, configuration, observer, run_id, attempt, nonce, observation): + environment = {"PATH": "/usr/bin:/bin", "GITHUB_RUN_ID": run_id, "GITHUB_RUN_ATTEMPT": attempt, + "FIXTURE_NONCE": nonce, "CONSUMER_ID": self.captured.get("consumer", ""), + "SERVER_ID": self.captured.get("server", ""), "INCLUDE_ROOT": "", "INCLUDE_IDENTITY": "", + "GITHUB_SHA": "invalid", "COMMAND_KEY_STATE": str(self.work / "missing-state"), + "TMPDIR": str(self.work / "missing-temp"), "DOCKER_CONFIG": str(configuration), + "DOCKER_HOST": "tcp://example.invalid:2375"} + cleanup = subprocess.run(["/usr/bin/python3", "-I", "-S", "-B", "-c", self.script], + env=environment, cwd=self.work, capture_output=True, text=True, timeout=120) + observation["cleanup_exit"] = cleanup.returncode + self.assertEqual(cleanup.returncode, 0, cleanup.stderr) + outcome = json.loads(cleanup.stdout) + observation["cleanup"] = outcome + self.assertTrue(outcome["runtime_removed"]) + self.assertEqual(outcome["errors"], []) + self.assertEqual(outcome["role_absence"], {"server": True, "consumer": True}) + found = dict(self.captured) + for role, identity in outcome["reconciled_ids"].items(): + self.assertIn(role, {"server", "consumer"}) + self.assertRegex(identity, r"\A[0-9a-f]{64}\Z") + self.assertNotIn(role, found) + found[role] = identity + self.assertEqual(len(set(found.values())), len(found)) + observation["observed_ids"] = found + self.active.update(found.values()) + empty_environment = {"PATH": "/usr/bin:/bin", "DOCKER_CONFIG": str(observer), + "DOCKER_HOST": "unix:///var/run/docker.sock"} + for identity in found.values(): + absent = subprocess.run(["/usr/bin/docker", "inspect", identity], env=empty_environment, + capture_output=True, text=True, timeout=15) + self.assertTrue(CONTAINERS.exact_missing(absent, identity)) + self.active.remove(identity) + for role in ("server", "consumer"): + listed = subprocess.run(["/usr/bin/docker", "ps", "--all", "--no-trunc", "--last", "2", + "--filter", "label=" + CONTAINERS.OWNER + "=" + run_id + "-" + attempt, + "--filter", "label=" + CONTAINERS.ROLE + "=" + role, + "--filter", "label=" + CONTAINERS.NONCE + "=" + nonce, "--format", "{{.ID}}"], + env=empty_environment, capture_output=True, text=True, timeout=15) + self.assertEqual((listed.returncode, listed.stdout, listed.stderr), (0, "", "")) + self.cleanup_proved = True + observation["container_removed"] = not self.active if __name__ == "__main__": diff --git a/tests/ci/test_command_key_cohort.py b/tests/ci/test_command_key_cohort.py index 60756af6..61299802 100644 --- a/tests/ci/test_command_key_cohort.py +++ b/tests/ci/test_command_key_cohort.py @@ -275,6 +275,10 @@ def test_independent_cleanup_survives_checkout_state_and_docker_config_refusal(s def invoke(arguments, **options): self.assertEqual(options["env"]["DOCKER_CONFIG"], "/inert-independent-configuration") calls.append(arguments) + if arguments[1] == "ps": + role = "server" if "label=durable-workflow.fixture-role=server" in arguments else "consumer" + selected = IDENTITY if role == "server" else "c" * 64 + return SimpleNamespace(returncode=0, stdout="" if selected in removed else selected + "\n", stderr="") identity = arguments[-1] if arguments[1] == "rm": removed.add(identity) diff --git a/tests/smoke/command-keys.md b/tests/smoke/command-keys.md index 032a5892..f71912bb 100644 --- a/tests/smoke/command-keys.md +++ b/tests/smoke/command-keys.md @@ -104,6 +104,11 @@ stdout, keeps an exclusive private record descriptor open, and publishes the IDs to workflow outputs before executing package code. The separate literal always-run cleanup uses those outputs, a fresh empty Docker configuration and owner/role/nonce labels. It needs no checkout, controller import or state file. +If create loses its returned ID, cleanup reconciles the exact three labels with +a role-specific list limited to two candidates. Multiple candidates, malformed +IDs or conflicting inspection identity/labels refuse. Both roles are attempted +independently, and each role's labels must match no remaining container before +its absence is reported. It attempts both removals, preserves foreign replacements, and accepts only an exact missing-object response as proof of absence. Root-stage removal also requires the original root-owned directory device/inode. Sanitized evidence @@ -171,20 +176,26 @@ the two effectful tests. Preparing this source does not qualify the actual mount, dependency profile, cleanup-after-state-refusal or installed cohort. `tests/ci/test_command_key_cleanup_boundary.py` proposes a separate opt-in -cleanup control. It creates two idle containers from the selected cached PHP -image, using only the two fixture role labels. No native Server, SDK, API or +cleanup control. It creates two containers from the selected cached PHP image, +using only the two fixture role labels. One deliberately loses create stdout +and stays unstarted; the other runs a fixed idle PHP command. No native Server, SDK, API or Worker runs. Both containers have read-only roots, unprivileged users, no -mounts or network, and the existing resource caps. Their full IDs are captured -before the production guards refuse an invalid source selection, nonprivate +mounts or network, and the existing resource caps. Ownership is flushed before +creation. The missing-ID role is recovered only through label reconciliation; +the test retains only a ground-truth ID hash for comparison, not as cleanup +input. The production guards then refuse an invalid source selection, nonprivate disposable state and nonempty disposable Docker configuration. -The control then runs the unchanged literal workflow cleanup from captured Git +The control then runs the reviewed literal workflow cleanup from captured Git bytes with an independently fixed digest. Its environment supplies the held IDs and labels while deliberately invalidating checkout, state, temporary and ambient Docker configuration values. The cleanup must use its own fresh Docker configuration and establish both exact absences. A separate observer checks -the full IDs again before owned fixture data is removed. Cleanup failure -preserves that data. Default CI checks the cleanup source binding and skips the +the full IDs and both role-label sets again before owned fixture data is removed. +An empty captured-ID set cannot establish cleanup. Cleanup refusal or an +incomplete observer preserves that data. Default CI checks the cleanup source +binding and lost/ambiguous create, duplicate/foreign candidate and independent +role cleanup controls, and skips the container control. Any actual execution requires exact source/entry review, `SDK_PHP_COMMAND_KEY_CLEANUP_IMAGE` and `SDK_PHP_COMMAND_KEY_CLEANUP_SOURCE`, and a Git-blob bootstrap with an independently fixed outer test digest. This