From d007d2914a67bf4b74696f4af9cd8200eefabeba Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:23:30 +0000 Subject: [PATCH 1/6] Share the committed-response-loss fixture for command retry checks --- conformance/README.md | 5 + conformance/command-keys/README.md | 36 ++++++ .../command-keys/committed_response_loss.py | 85 ++++++++++++++ .../test_committed_response_loss.py | 109 ++++++++++++++++++ 4 files changed, 235 insertions(+) create mode 100644 conformance/command-keys/README.md create mode 100644 conformance/command-keys/committed_response_loss.py create mode 100644 conformance/command-keys/test_committed_response_loss.py diff --git a/conformance/README.md b/conformance/README.md index e975eac..cc8a278 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -141,6 +141,11 @@ actions work. Report its scope separately from the stable protocol experiments. ## Evidence scope +Command-admission retry experiments under #216 can reuse the +[committed-response-loss fixture](command-keys/README.md). Its socket control +proves response loss after upstream success; the installed SDK/CLI adapter and +producer-owned legacy fixtures supply the remaining admission/retention proof. + A pinned or successfully installed SDK is not evidence that its worker executed an experiment. For each claimed language or cross-language direction, check the scenario-level result for the actual caller/parent and worker/child runtime, diff --git a/conformance/command-keys/README.md b/conformance/command-keys/README.md new file mode 100644 index 0000000..e44ab38 --- /dev/null +++ b/conformance/command-keys/README.md @@ -0,0 +1,36 @@ +# Committed command-response loss + +This shared transport fixture supports [#216](https://github.com/durable-workflow/.github/issues/216). +It forwards one selected request to a loopback HTTP runtime, reads the complete +successful upstream response, then closes the client connection before sending +an HTTP status line. The next request forwards normally. The fixture does not +retry a request or decide whether command admission was idempotent. + +`CommittedResponseLoss(upstream)` binds an ephemeral loopback port. Start its +`serve_forever` method in a thread and route the installed client through `url`. +Call `arm(method, path)` with the exact admission route before the selected +request. The caller owns shutdown, the runtime, synthetic data and cleanup. + +`records` retains method/path, complete upstream status/body length/hash, parsed +synthetic JSON and the dropped flag. It records no headers. Use only synthetic +payloads and retain only the sanitized fields needed by the experiment. + +Run the socket controls without dependencies or an external runtime: + +```sh +python3 -I -S -B conformance/command-keys/test_committed_response_loss.py +``` + +The control proves upstream success was complete before the client received +`RemoteDisconnected`, and that one incoming request caused exactly one upstream +request. It also checks one-shot path selection, ordinary forwarding, errors +and loopback confinement. The proxy source is byte-identical to CLI's reviewed +fixture, SHA-256 `5418903ee570c7e205824adb3e8fcec6ab8a959daca6d52e13f59dddbde8834c`. + +An SDK/CLI adapter still verifies the actual published tuple, original/generated +key, durable admission count, concurrent identical retries, changed-intent +conflicts, closure/continuation, namespace/run fences and retained identities. +Record those outcomes separately from this transport control. Producer-owned +legacy-shape and physical-upgrade seeds remain separate; the proxy creates +neither. The [original published reproduction](https://github.com/durable-workflow/.github/issues/216#issuecomment-6296529480) +describes the earlier loss scenarios and is not current installed acceptance. diff --git a/conformance/command-keys/committed_response_loss.py b/conformance/command-keys/committed_response_loss.py new file mode 100644 index 0000000..f8ffa49 --- /dev/null +++ b/conformance/command-keys/committed_response_loss.py @@ -0,0 +1,85 @@ +"""Loopback test proxy: consume an upstream success, then lose one response. + +No credentials are recorded. arm(method, path) selects exactly one request; +only its complete 2xx upstream response is dropped. Other traffic is forwarded. +The caller owns server.shutdown()/server_close(), upstream and synthetic data. +""" +import hashlib +import http.client +import http.server +import json +import socket +import threading +import urllib.parse + + +class CommittedResponseLoss(http.server.ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, upstream): + parsed = urllib.parse.urlsplit(upstream) + if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost"}: + raise ValueError("fixture upstream must be loopback HTTP") + self.upstream = (parsed.hostname, parsed.port or 80) + self.records = [] + self.armed = None + self.lock = threading.Lock() + super().__init__(("127.0.0.1", 0), _Handler) + + def arm(self, method, path): + with self.lock: + if self.armed is not None: + raise ValueError("a response loss is already armed") + self.armed = (method, path) + + @property + def url(self): + return f"http://127.0.0.1:{self.server_port}" + + +class _Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def forward(self): + raw = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + with self.server.lock: + selected = self.server.armed == (self.command, self.path) + if selected: + self.server.armed = None + upstream = http.client.HTTPConnection(*self.server.upstream, timeout=40) + try: + headers = {k: v for k, v in self.headers.items() + if k.lower() not in {"host", "connection", "transfer-encoding"}} + upstream.request(self.command, self.path, body=raw or None, headers=headers) + response = upstream.getresponse() + body = response.read() # Complete upstream response BEFORE losing it. + try: + request_json = json.loads(raw) if raw else None + response_json = json.loads(body) if body else None + except (ValueError, UnicodeError): + request_json = response_json = None + drop = selected and 200 <= response.status < 300 + record = {"method": self.command, "path": self.path, + "upstream_status": response.status, "upstream_body_bytes": len(body), + "upstream_body_sha256": hashlib.sha256(body).hexdigest(), + "request": request_json, "response": response_json, "dropped": drop} + with self.server.lock: + self.server.records.append(record) + if drop: + self.close_connection = True + self.connection.shutdown(socket.SHUT_RDWR) + self.connection.close() + return + self.send_response(response.status) + for key, value in response.getheaders(): + if key.lower() not in {"content-length", "transfer-encoding", "connection", "server", "date"}: + self.send_header(key, value) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + finally: + upstream.close() + + do_GET = forward + do_POST = forward diff --git a/conformance/command-keys/test_committed_response_loss.py b/conformance/command-keys/test_committed_response_loss.py new file mode 100644 index 0000000..b8ecd4d --- /dev/null +++ b/conformance/command-keys/test_committed_response_loss.py @@ -0,0 +1,109 @@ +"""Socket-level controls for the shared command-admission loss fixture.""" + +import hashlib +import http.client +import http.server +import importlib.util +import json +from pathlib import Path +import threading +import unittest + + +SOURCE = Path(__file__).with_name("committed_response_loss.py") +SPEC = importlib.util.spec_from_file_location("committed_response_loss", SOURCE) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +CommittedResponseLoss = MODULE.CommittedResponseLoss + + +class Upstream(http.server.BaseHTTPRequestHandler): + def log_message(self, *_arguments): + pass + + def do_POST(self): + request = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + self.server.requests.append((self.path, request)) + body = json.dumps({"command_id": "synthetic-command", "request_id": request["request_id"]}).encode() + self.server.response_body = body + self.send_response(self.server.response_status) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +class CommittedResponseLossTest(unittest.TestCase): + def setUp(self): + self.upstream = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Upstream) + self.upstream.response_status = 202 + self.upstream.requests = [] + self.serve(self.upstream) + self.proxy = CommittedResponseLoss(f"http://127.0.0.1:{self.upstream.server_port}") + self.serve(self.proxy) + + def serve(self, server): + thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True) + thread.start() + + def close(): + server.shutdown() + server.server_close() + thread.join(timeout=2) + + self.addCleanup(close) + + def request(self, path="/commit"): + client = http.client.HTTPConnection("127.0.0.1", self.proxy.server_port, timeout=2) + try: + client.request("POST", path, body=json.dumps({"request_id": "synthetic-key"}), + headers={"Authorization": "Bearer synthetic-header-token"}) + response = client.getresponse() + return response.status, json.loads(response.read()) + finally: + client.close() + + def test_complete_upstream_success_precedes_loss_before_client_status_line(self): + self.proxy.arm("POST", "/commit") + with self.assertRaises(http.client.RemoteDisconnected): + self.request() + self.assertEqual(1, len(self.upstream.requests)) + record = self.proxy.records[0] + self.assertEqual(202, record["upstream_status"]) + self.assertTrue(record["dropped"]) + self.assertEqual(len(self.upstream.response_body), record["upstream_body_bytes"]) + self.assertEqual(hashlib.sha256(self.upstream.response_body).hexdigest(), record["upstream_body_sha256"]) + self.assertEqual("synthetic-key", record["response"]["request_id"]) + self.assertNotIn("synthetic-header-token", json.dumps(self.proxy.records)) + status, body = self.request() + self.assertEqual(202, status) + self.assertEqual("synthetic-command", body["command_id"]) + self.assertEqual(2, len(self.upstream.requests)) + self.assertFalse(self.proxy.records[1]["dropped"]) + + def test_loss_is_one_shot_for_only_the_exact_method_and_path(self): + self.proxy.arm("POST", "/commit") + with self.assertRaises(ValueError): + self.proxy.arm("POST", "/another") + self.assertEqual(202, self.request("/different")[0]) + self.assertFalse(self.proxy.records[0]["dropped"]) + with self.assertRaises(http.client.RemoteDisconnected): + self.request() + self.assertEqual(2, len(self.upstream.requests)) + self.assertTrue(self.proxy.records[1]["dropped"]) + + def test_selected_non_success_is_forwarded_and_not_retried(self): + self.upstream.response_status = 409 + self.proxy.arm("POST", "/commit") + self.assertEqual(409, self.request()[0]) + self.assertEqual(1, len(self.upstream.requests)) + self.assertFalse(self.proxy.records[0]["dropped"]) + self.assertIsNone(self.proxy.armed) + + def test_upstream_must_be_loopback_http(self): + for address in ("https://127.0.0.1:1234", "http://example.invalid:1234", "file:///tmp/fixture"): + with self.subTest(address=address), self.assertRaises(ValueError): + CommittedResponseLoss(address) + + +if __name__ == "__main__": + unittest.main() From a965410ba83e5f995923cb6a1bb32597b5fa334e Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:58:37 +0000 Subject: [PATCH 2/6] Constrain proxy authority, body reads and retained evidence --- conformance/command-keys/README.md | 27 ++- .../command-keys/committed_response_loss.py | 164 ++++++++++++++---- .../test_committed_response_loss.py | 124 ++++++++++++- 3 files changed, 266 insertions(+), 49 deletions(-) diff --git a/conformance/command-keys/README.md b/conformance/command-keys/README.md index e44ab38..c15fb62 100644 --- a/conformance/command-keys/README.md +++ b/conformance/command-keys/README.md @@ -6,14 +6,25 @@ successful upstream response, then closes the client connection before sending an HTTP status line. The next request forwards normally. The fixture does not retry a request or decide whether command admission was idempotent. -`CommittedResponseLoss(upstream)` binds an ephemeral loopback port. Start its +`CommittedResponseLoss(upstream)` accepts only `http://127.0.0.1:` with a +canonical explicit port. Credentials, hostnames, paths, query and fragment +components refuse. It binds an ephemeral loopback port. Start its `serve_forever` method in a thread and route the installed client through `url`. Call `arm(method, path)` with the exact admission route before the selected request. The caller owns shutdown, the runtime, synthetic data and cleanup. -`records` retains method/path, complete upstream status/body length/hash, parsed -synthetic JSON and the dropped flag. It records no headers. Use only synthetic -payloads and retain only the sanitized fields needed by the experiment. +Requests and responses each have a fixed 1 MiB wire-body cap and bounded socket +timeouts. POST requires one canonical Content-Length; request transfer encodings +refuse. GET is bodyless and may omit Content-Length. Complete bounded chunked +upstream responses are supported. Invalid, oversized or incomplete upstream +responses produce a generic 502 and no success record. A matching valid request +consumes the arm even if its upstream attempt fails; re-arming is explicit. + +`records` contains method/path, request byte count/hash, complete upstream status, +response byte count/hash and the dropped flag. It retains no headers, parsed +JSON, caller keys, results or payloads. SDK adapters compare their own request +and retained-admission identities independently. Use synthetic inputs and +retain only the metadata needed by the experiment. Run the socket controls without dependencies or an external runtime: @@ -21,11 +32,13 @@ Run the socket controls without dependencies or an external runtime: python3 -I -S -B conformance/command-keys/test_committed_response_loss.py ``` -The control proves upstream success was complete before the client received +The controls prove upstream success was complete before the client received `RemoteDisconnected`, and that one incoming request caused exactly one upstream request. It also checks one-shot path selection, ordinary forwarding, errors -and loopback confinement. The proxy source is byte-identical to CLI's reviewed -fixture, SHA-256 `5418903ee570c7e205824adb3e8fcec6ab8a959daca6d52e13f59dddbde8834c`. +and numeric loopback confinement. Negative controls cover framing, byte caps, +incomplete upstream responses and body/header evidence minimization. The earlier +CLI fixture is preserved in Git history; this correction tightens its reusable +authority, resource and evidence boundaries. An SDK/CLI adapter still verifies the actual published tuple, original/generated key, durable admission count, concurrent identical retries, changed-intent diff --git a/conformance/command-keys/committed_response_loss.py b/conformance/command-keys/committed_response_loss.py index f8ffa49..83134ab 100644 --- a/conformance/command-keys/committed_response_loss.py +++ b/conformance/command-keys/committed_response_loss.py @@ -1,32 +1,53 @@ -"""Loopback test proxy: consume an upstream success, then lose one response. - -No credentials are recorded. arm(method, path) selects exactly one request; -only its complete 2xx upstream response is dropped. Other traffic is forwarded. -The caller owns server.shutdown()/server_close(), upstream and synthetic data. -""" +"""Bounded loopback fixture: consume upstream success, then lose one response.""" import hashlib import http.client import http.server -import json +import re import socket import threading -import urllib.parse + + +MAX_REQUEST_BYTES = 1_048_576 +MAX_RESPONSE_BYTES = 1_048_576 +SOCKET_TIMEOUT_SECONDS = 40 + + +def origin_path(path): + return (isinstance(path, str) and len(path) <= 4096 and not path.startswith("//") + and re.fullmatch(r"/[^\x00-\x20\x7f#]*", path) is not None) + + +def content_length(headers, required): + values = headers.get_all("Content-Length", []) + if not values and not required: + return None + if len(values) != 1 or re.fullmatch(r"0|[1-9][0-9]{0,6}", values[0]) is None: + raise ValueError("A single canonical bounded Content-Length is required") + return int(values[0]) + + +class RequestRefused(ValueError): + def __init__(self, status, reason): + super().__init__(reason) + self.status = status class CommittedResponseLoss(http.server.ThreadingHTTPServer): daemon_threads = True def __init__(self, upstream): - parsed = urllib.parse.urlsplit(upstream) - if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost"}: - raise ValueError("fixture upstream must be loopback HTTP") - self.upstream = (parsed.hostname, parsed.port or 80) + match = re.fullmatch(r"http://127\.0\.0\.1:([1-9][0-9]{0,4})", upstream) if isinstance(upstream, str) else None + if match is None or not 1 <= int(match.group(1)) <= 65535: + raise ValueError("Fixture upstream must be canonical numeric loopback HTTP with an explicit port") + self.upstream_port = int(match.group(1)) self.records = [] self.armed = None self.lock = threading.Lock() super().__init__(("127.0.0.1", 0), _Handler) def arm(self, method, path): + if method not in {"GET", "POST"} or not origin_path(path): + raise ValueError("Arm requires an exact supported method and origin-form path") with self.lock: if self.armed is not None: raise ValueError("a response loss is already armed") @@ -39,47 +60,118 @@ def url(self): class _Handler(http.server.BaseHTTPRequestHandler): def log_message(self, *_args): + # Evidence uses closed metadata records rather than HTTP request logs. pass + def send_error(self, code, message=None, explain=None): + # Base parser messages can include an untrusted raw request line. + reasons = {400: "Fixture request framing refused", 413: "Fixture byte cap exceeded", + 501: "Fixture method unsupported", 502: "Fixture upstream response unverified"} + super().send_error(code, reasons.get(code, "Fixture request refused"), + "The fixture refused the request without retaining its content.") + + def request_body(self): + original_path = self.requestline.split()[1] + if (self.request_version not in {"HTTP/1.0", "HTTP/1.1"} or original_path != self.path + or not origin_path(original_path) or self.headers.get_all("Transfer-Encoding", [])): + raise RequestRefused(400, "Fixture requires an origin path and no request transfer encoding") + try: + length = content_length(self.headers, self.command == "POST") + except ValueError as error: + raise RequestRefused(400, "Fixture request length is invalid") from error + if self.command == "GET" and length not in (None, 0): + raise RequestRefused(400, "Fixture GET requests must be bodyless") + length = length or 0 + if length > MAX_REQUEST_BYTES: + raise RequestRefused(413, "Fixture request exceeds its fixed byte cap") + self.connection.settimeout(SOCKET_TIMEOUT_SECONDS) + try: + body = self.rfile.read(length) + except OSError as error: + raise RequestRefused(400, "Fixture request body is incomplete") from error + if len(body) != length: + raise RequestRefused(400, "Fixture request body is incomplete") + return body + + def response_body(self, response): + encodings = response.headers.get_all("Transfer-Encoding", []) + length = content_length(response.headers, False) + if encodings and (encodings != ["chunked"] or length is not None): + raise ValueError("Fixture upstream framing is unsupported") + if length is not None and length > MAX_RESPONSE_BYTES: + raise ValueError("Fixture upstream response exceeds its fixed byte cap") + body = response.read(MAX_RESPONSE_BYTES + 1) + if len(body) > MAX_RESPONSE_BYTES or (length is not None and len(body) != length): + raise ValueError("Fixture upstream body is oversized or incomplete") + if response.read(1): + raise ValueError("Fixture upstream body exceeds its fixed byte cap") + return body + + def upstream_response(self, raw): + # The destination address is literal; only the validated port varies. + upstream = http.client.HTTPConnection("127.0.0.1", self.server.upstream_port, + timeout=SOCKET_TIMEOUT_SECONDS) + try: + upstream.connect() + if upstream.sock.getpeername()[0] != "127.0.0.1": + raise ValueError("Fixture upstream peer is not loopback") + headers = {k: v for k, v in self.headers.items() + if k.lower() not in {"host", "connection", "transfer-encoding", "proxy-authorization"}} + headers["Connection"] = "close" + upstream.request(self.command, self.path, body=raw or None, headers=headers) + response = upstream.getresponse() + body = self.response_body(response) + return response.status, response.getheaders(), body + finally: + upstream.close() + + def refuse(self, status): + self.close_connection = True + try: + self.send_error(status) + except OSError: + # A failed client may already have closed its side of the fixture. + pass + def forward(self): - raw = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + try: + raw = self.request_body() + except RequestRefused as error: + self.refuse(error.status) + return with self.server.lock: selected = self.server.armed == (self.command, self.path) if selected: self.server.armed = None - upstream = http.client.HTTPConnection(*self.server.upstream, timeout=40) try: - headers = {k: v for k, v in self.headers.items() - if k.lower() not in {"host", "connection", "transfer-encoding"}} - upstream.request(self.command, self.path, body=raw or None, headers=headers) - response = upstream.getresponse() - body = response.read() # Complete upstream response BEFORE losing it. - try: - request_json = json.loads(raw) if raw else None - response_json = json.loads(body) if body else None - except (ValueError, UnicodeError): - request_json = response_json = None - drop = selected and 200 <= response.status < 300 - record = {"method": self.command, "path": self.path, - "upstream_status": response.status, "upstream_body_bytes": len(body), - "upstream_body_sha256": hashlib.sha256(body).hexdigest(), - "request": request_json, "response": response_json, "dropped": drop} - with self.server.lock: - self.server.records.append(record) + status, headers, body = self.upstream_response(raw) + except (http.client.HTTPException, OSError, ValueError): + self.refuse(502) + return + drop = selected and 200 <= status < 300 + record = {"method": self.command, "path": self.path, + "request_body_bytes": len(raw), "request_body_sha256": hashlib.sha256(raw).hexdigest(), + "upstream_status": status, "upstream_complete": True, + "upstream_body_bytes": len(body), "upstream_body_sha256": hashlib.sha256(body).hexdigest(), + "dropped": drop} + with self.server.lock: + self.server.records.append(record) + self.close_connection = True + try: if drop: - self.close_connection = True self.connection.shutdown(socket.SHUT_RDWR) self.connection.close() return - self.send_response(response.status) - for key, value in response.getheaders(): + self.send_response(status) + for key, value in headers: if key.lower() not in {"content-length", "transfer-encoding", "connection", "server", "date"}: self.send_header(key, value) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) - finally: - upstream.close() + except OSError: + # Unplanned client loss is never recorded as intentional injection. + pass do_GET = forward do_POST = forward diff --git a/conformance/command-keys/test_committed_response_loss.py b/conformance/command-keys/test_committed_response_loss.py index b8ecd4d..bb3f606 100644 --- a/conformance/command-keys/test_committed_response_loss.py +++ b/conformance/command-keys/test_committed_response_loss.py @@ -6,6 +6,7 @@ import importlib.util import json from pathlib import Path +import socket import threading import unittest @@ -23,13 +24,35 @@ def log_message(self, *_arguments): def do_POST(self): request = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + self.reply(request) + + def do_GET(self): + self.reply({"request_id": "synthetic-get"}) + + def reply(self, request): self.server.requests.append((self.path, request)) - body = json.dumps({"command_id": "synthetic-command", "request_id": request["request_id"]}).encode() + self.close_connection = True + if self.server.fail_before_headers: + return + document = {"command_id": "synthetic-command", "request_id": request["request_id"], + "unexpected_secret": "synthetic-response-secret"} + body = self.server.body_override or json.dumps(document).encode() self.server.response_body = body self.send_response(self.server.response_status) - self.send_header("Content-Length", str(len(body))) + if self.server.framing in {"chunked", "incomplete-chunked"}: + self.send_header("Transfer-Encoding", "chunked") + body = f"{len(body):x}\r\n".encode() + body + b"\r\n" + if self.server.framing == "chunked": + body += b"0\r\n\r\n" + elif self.server.framing == "length": + length = self.server.length_override + self.send_header("Content-Length", str(len(body) if length is None else length)) self.end_headers() - self.wfile.write(body) + try: + self.wfile.write(body) + except OSError: + # A refusal may close the proxy before this synthetic writer ends. + pass class CommittedResponseLossTest(unittest.TestCase): @@ -37,6 +60,10 @@ def setUp(self): self.upstream = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Upstream) self.upstream.response_status = 202 self.upstream.requests = [] + self.upstream.fail_before_headers = False + self.upstream.body_override = None + self.upstream.length_override = None + self.upstream.framing = "length" self.serve(self.upstream) self.proxy = CommittedResponseLoss(f"http://127.0.0.1:{self.upstream.server_port}") self.serve(self.proxy) @@ -55,13 +82,22 @@ def close(): def request(self, path="/commit"): client = http.client.HTTPConnection("127.0.0.1", self.proxy.server_port, timeout=2) try: - client.request("POST", path, body=json.dumps({"request_id": "synthetic-key"}), + client.request("POST", path, body=json.dumps({"request_id": "synthetic-key", "token": "synthetic-request-secret"}), headers={"Authorization": "Bearer synthetic-header-token"}) response = client.getresponse() return response.status, json.loads(response.read()) finally: client.close() + def raw_request(self, headers, body=b"", path="/commit", method="POST"): + with socket.create_connection(("127.0.0.1", self.proxy.server_port), timeout=2) as client: + head = f"{method} {path} HTTP/1.1\r\nHost: fixture\r\n{headers}\r\n" + client.sendall(head.encode("ascii") + body) + client.shutdown(socket.SHUT_WR) + response = http.client.HTTPResponse(client) + response.begin() + return response.status, response.read(8192) + def test_complete_upstream_success_precedes_loss_before_client_status_line(self): self.proxy.arm("POST", "/commit") with self.assertRaises(http.client.RemoteDisconnected): @@ -72,8 +108,13 @@ def test_complete_upstream_success_precedes_loss_before_client_status_line(self) self.assertTrue(record["dropped"]) self.assertEqual(len(self.upstream.response_body), record["upstream_body_bytes"]) self.assertEqual(hashlib.sha256(self.upstream.response_body).hexdigest(), record["upstream_body_sha256"]) - self.assertEqual("synthetic-key", record["response"]["request_id"]) + self.assertTrue(record["upstream_complete"]) + self.assertEqual({"method", "path", "request_body_bytes", "request_body_sha256", "upstream_status", + "upstream_complete", "upstream_body_bytes", "upstream_body_sha256", "dropped"}, set(record)) self.assertNotIn("synthetic-header-token", json.dumps(self.proxy.records)) + self.assertNotIn("synthetic-request-secret", json.dumps(self.proxy.records)) + self.assertNotIn("synthetic-response-secret", json.dumps(self.proxy.records)) + self.assertNotIn("synthetic-key", json.dumps(self.proxy.records)) status, body = self.request() self.assertEqual(202, status) self.assertEqual("synthetic-command", body["command_id"]) @@ -100,10 +141,81 @@ def test_selected_non_success_is_forwarded_and_not_retried(self): self.assertIsNone(self.proxy.armed) def test_upstream_must_be_loopback_http(self): - for address in ("https://127.0.0.1:1234", "http://example.invalid:1234", "file:///tmp/fixture"): + for address in ("https://127.0.0.1:1234", "http://example.invalid:1234", "file:///tmp/fixture", + "http://localhost:1234", "http://127.0.0.2:1234", "http://127.0.0.1", + "http://user:password@127.0.0.1:1234", "http://127.0.0.1:1234/", + "http://127.0.0.1:1234?q=x", "http://127.0.0.1:1234#fragment", + "http://127.0.0.1:0", "http://127.0.0.1:65536", "http://127.0.0.1:0123", None): with self.subTest(address=address), self.assertRaises(ValueError): CommittedResponseLoss(address) + def test_invalid_request_framing_is_refused_before_forwarding(self): + cases = [("", b"", 400), ("Content-Length: -1\r\n", b"", 400), + ("Content-Length: 01\r\n", b"x", 400), ("Content-Length: 1 \r\n", b"x", 400), + ("Content-Length: 0\r\nContent-Length: 0\r\n", b"", 400), + (f"Content-Length: {MODULE.MAX_REQUEST_BYTES + 1}\r\n", b"", 413), + ("Transfer-Encoding: chunked\r\nContent-Length: 0\r\n", b"", 400), + ("Content-Length: 2\r\n", b"x", 400)] + for headers, body, status in cases: + with self.subTest(headers=headers): + self.assertEqual(status, self.raw_request(headers, body)[0]) + self.assertEqual([], self.upstream.requests) + self.assertEqual([], self.proxy.records) + + def test_only_origin_form_paths_can_be_forwarded_or_armed(self): + for path in ("http://example.invalid/", "//example.invalid/", "/path#fragment"): + with self.subTest(path=path): + self.assertEqual(400, self.raw_request("Content-Length: 0\r\n", path=path)[0]) + self.assertEqual([], self.upstream.requests) + with self.assertRaises(ValueError): + self.proxy.arm("POST", path) + + def test_bodyless_get_needs_no_content_length(self): + status, body = self.raw_request("", method="GET") + self.assertEqual(202, status) + self.assertEqual("synthetic-get", json.loads(body)["request_id"]) + self.assertEqual(0, self.proxy.records[0]["request_body_bytes"]) + + def test_parser_errors_do_not_echo_request_content(self): + marker = "synthetic-private-request-line" + for method, path in (("POST " + marker, "/commit"), ("POST", "/" + marker + "#fragment")): + with self.subTest(method=method, path=path): + status, body = self.raw_request("Content-Length: 0\r\n", method=method, path=path) + self.assertEqual(400, status) + self.assertNotIn(marker.encode(), body) + self.assertEqual([], self.proxy.records) + self.assertEqual([], self.upstream.requests) + + def test_oversized_or_incomplete_upstream_is_not_an_injected_loss(self): + body = b'{"request_id":"synthetic-key"}' + marker = b"private-upstream-body" + oversized = marker + b"x" * (MODULE.MAX_RESPONSE_BYTES + 1 - len(marker)) + cases = [("length", MODULE.MAX_RESPONSE_BYTES + 1, marker, False), + ("close", None, oversized, False), + ("length", 40, marker, False), + ("incomplete-chunked", None, marker, False), + ("length", None, marker, True)] + for framing, length, response, fail in cases: + with self.subTest(framing=framing, length=length, fail=fail): + self.upstream.framing = framing + self.upstream.length_override = length + self.upstream.body_override = response + self.upstream.fail_before_headers = fail + self.proxy.arm("POST", "/commit") + status, reply = self.raw_request(f"Content-Length: {len(body)}\r\n", body) + self.assertEqual(502, status) + self.assertNotIn(marker, reply) + self.assertEqual([], self.proxy.records) + self.assertIsNone(self.proxy.armed) + + def test_complete_chunked_upstream_can_supply_a_bounded_loss(self): + self.upstream.framing = "chunked" + self.proxy.arm("POST", "/commit") + with self.assertRaises(http.client.RemoteDisconnected): + self.request() + self.assertTrue(self.proxy.records[0]["dropped"]) + self.assertTrue(self.proxy.records[0]["upstream_complete"]) + if __name__ == "__main__": unittest.main() From 8545c1395712ea9deaf7735d783caea5beddb0f7 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:42:35 +0000 Subject: [PATCH 3/6] Bound command-loss evidence and measure its socket controls --- .github/workflows/ci.yml | 1 + .github/workflows/sonar.yml | 2 +- conformance/command-keys/README.md | 8 ++- .../command-keys/committed_response_loss.py | 50 +++++++++++++------ .../test_committed_response_loss.py | 49 ++++++++++++++++-- tests/test_command_response_loss.py | 11 ++++ 6 files changed, 99 insertions(+), 22 deletions(-) create mode 100644 tests/test_command_response_loss.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f875dc..945ba41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,7 @@ jobs: tests.test_temporal_runtime tests.test_cancellation_workflow_policy tests.test_sdk_metrics_contract + tests.test_command_response_loss tests.test_ci_trust tests.test_pip_effective_trust tests.test_cli_effective_trust diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index d6a06f2..9423d38 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -53,7 +53,7 @@ jobs: - name: Produce source coverage run: | python -m pip install --only-binary :all: coverage==7.10.7 jsonschema==4.23.0 PyYAML==6.0.2 - coverage run --source=scripts,conformance/cancellation/temporal,.github/actions/bootstrap-patched-pip -m unittest tests.test_qualification_policy tests.test_protected_policy_context tests.test_protected_policy_check tests.test_protected_policy_inputs tests.test_protected_policy_issue tests.test_native_policy_check tests.test_native_policy_workflow tests.test_regression_corpus tests.test_regression_evidence_schema tests.test_server_codec_counterfactual_schema tests.test_visual_evidence tests.test_temporal_runtime tests.test_cancellation_workflow_policy tests.test_sdk_metrics_contract tests.test_ci_trust tests.test_pip_effective_trust tests.test_cli_effective_trust tests.test_composer_trust_contract tests.test_composer_container_trust tests.test_composer_container_effective_trust tests.test_php_linux_bootstrap tests.test_php_linux_archive tests.test_php_linux_normalize tests.test_php_linux_recipe tests.test_php_linux_tools tests.test_php_linux_alternatives tests.test_setup_php_linux tests.test_php_linux_qualification tests.test_php_linux_qualification_workflow tests.test_php_linux_configuration + coverage run --source=scripts,conformance/cancellation/temporal,conformance/command-keys,.github/actions/bootstrap-patched-pip -m unittest tests.test_qualification_policy tests.test_protected_policy_context tests.test_protected_policy_check tests.test_protected_policy_inputs tests.test_protected_policy_issue tests.test_native_policy_check tests.test_native_policy_workflow tests.test_regression_corpus tests.test_regression_evidence_schema tests.test_server_codec_counterfactual_schema tests.test_visual_evidence tests.test_temporal_runtime tests.test_cancellation_workflow_policy tests.test_sdk_metrics_contract tests.test_command_response_loss tests.test_ci_trust tests.test_pip_effective_trust tests.test_cli_effective_trust tests.test_composer_trust_contract tests.test_composer_container_trust tests.test_composer_container_effective_trust tests.test_php_linux_bootstrap tests.test_php_linux_archive tests.test_php_linux_normalize tests.test_php_linux_recipe tests.test_php_linux_tools tests.test_php_linux_alternatives tests.test_setup_php_linux tests.test_php_linux_qualification tests.test_php_linux_qualification_workflow tests.test_php_linux_configuration coverage xml -o coverage.xml - uses: SonarSource/sonarqube-scan-action@d209202bc7d53ff1cc128f7f907dac145c9d6ae9 # v8.3.0 env: diff --git a/conformance/command-keys/README.md b/conformance/command-keys/README.md index c15fb62..4eafcf8 100644 --- a/conformance/command-keys/README.md +++ b/conformance/command-keys/README.md @@ -10,8 +10,9 @@ retry a request or decide whether command admission was idempotent. canonical explicit port. Credentials, hostnames, paths, query and fragment components refuse. It binds an ephemeral loopback port. Start its `serve_forever` method in a thread and route the installed client through `url`. -Call `arm(method, path)` with the exact admission route before the selected -request. The caller owns shutdown, the runtime, synthetic data and cleanup. +Call `arm(method, path)` with the exact origin-form admission route before +the selected request. Query strings and fragments refuse before forwarding or +recording; put no credentials in route identifiers. The caller owns shutdown, the runtime, synthetic data and cleanup. Requests and responses each have a fixed 1 MiB wire-body cap and bounded socket timeouts. POST requires one canonical Content-Length; request transfer encodings @@ -19,6 +20,9 @@ refuse. GET is bodyless and may omit Content-Length. Complete bounded chunked upstream responses are supported. Invalid, oversized or incomplete upstream responses produce a generic 502 and no success record. A matching valid request consumes the arm even if its upstream attempt fails; re-arming is explicit. +At most 1024 metadata records, including reserved in-flight slots, are allowed. +Capacity exhaustion returns a generic 503 before contacting upstream and does +not consume the arm. Failed upstream attempts release their reserved slot. `records` contains method/path, request byte count/hash, complete upstream status, response byte count/hash and the dropped flag. It retains no headers, parsed diff --git a/conformance/command-keys/committed_response_loss.py b/conformance/command-keys/committed_response_loss.py index 83134ab..88b0094 100644 --- a/conformance/command-keys/committed_response_loss.py +++ b/conformance/command-keys/committed_response_loss.py @@ -9,19 +9,20 @@ MAX_REQUEST_BYTES = 1_048_576 MAX_RESPONSE_BYTES = 1_048_576 +MAX_RECORDS = 1024 SOCKET_TIMEOUT_SECONDS = 40 def origin_path(path): return (isinstance(path, str) and len(path) <= 4096 and not path.startswith("//") - and re.fullmatch(r"/[^\x00-\x20\x7f#]*", path) is not None) + and re.fullmatch(r"/[^\x00-\x20\x7f#?]*", path) is not None) def content_length(headers, required): values = headers.get_all("Content-Length", []) if not values and not required: return None - if len(values) != 1 or re.fullmatch(r"0|[1-9][0-9]{0,6}", values[0]) is None: + if len(values) != 1 or re.fullmatch(r"0|[1-9]\d{0,6}", values[0], re.ASCII) is None: raise ValueError("A single canonical bounded Content-Length is required") return int(values[0]) @@ -36,11 +37,12 @@ class CommittedResponseLoss(http.server.ThreadingHTTPServer): daemon_threads = True def __init__(self, upstream): - match = re.fullmatch(r"http://127\.0\.0\.1:([1-9][0-9]{0,4})", upstream) if isinstance(upstream, str) else None + match = re.fullmatch(r"http://127\.0\.0\.1:([1-9]\d{0,4})", upstream, re.ASCII) if isinstance(upstream, str) else None if match is None or not 1 <= int(match.group(1)) <= 65535: raise ValueError("Fixture upstream must be canonical numeric loopback HTTP with an explicit port") self.upstream_port = int(match.group(1)) self.records = [] + self.pending_records = 0 self.armed = None self.lock = threading.Lock() super().__init__(("127.0.0.1", 0), _Handler) @@ -53,12 +55,30 @@ def arm(self, method, path): raise ValueError("a response loss is already armed") self.armed = (method, path) + def reserve_record(self, method, path): + with self.lock: + if len(self.records) + self.pending_records >= MAX_RECORDS: + raise RequestRefused(503, "Fixture evidence capacity exhausted") + self.pending_records += 1 + selected = self.armed == (method, path) + if selected: + self.armed = None + return selected + + def finish_record(self, record): + with self.lock: + self.pending_records -= 1 + if record is not None: + self.records.append(record) + @property def url(self): return f"http://127.0.0.1:{self.server_port}" class _Handler(http.server.BaseHTTPRequestHandler): + timeout = SOCKET_TIMEOUT_SECONDS + def log_message(self, *_args): # Evidence uses closed metadata records rather than HTTP request logs. pass @@ -66,7 +86,8 @@ def log_message(self, *_args): def send_error(self, code, message=None, explain=None): # Base parser messages can include an untrusted raw request line. reasons = {400: "Fixture request framing refused", 413: "Fixture byte cap exceeded", - 501: "Fixture method unsupported", 502: "Fixture upstream response unverified"} + 501: "Fixture method unsupported", 502: "Fixture upstream response unverified", + 503: "Fixture evidence capacity exhausted"} super().send_error(code, reasons.get(code, "Fixture request refused"), "The fixture refused the request without retaining its content.") @@ -136,26 +157,23 @@ def refuse(self, status): def forward(self): try: raw = self.request_body() + selected = self.server.reserve_record(self.command, self.path) except RequestRefused as error: self.refuse(error.status) return - with self.server.lock: - selected = self.server.armed == (self.command, self.path) - if selected: - self.server.armed = None try: status, headers, body = self.upstream_response(raw) + drop = selected and 200 <= status < 300 + record = {"method": self.command, "path": self.path, + "request_body_bytes": len(raw), "request_body_sha256": hashlib.sha256(raw).hexdigest(), + "upstream_status": status, "upstream_complete": True, + "upstream_body_bytes": len(body), "upstream_body_sha256": hashlib.sha256(body).hexdigest(), + "dropped": drop} except (http.client.HTTPException, OSError, ValueError): + self.server.finish_record(None) self.refuse(502) return - drop = selected and 200 <= status < 300 - record = {"method": self.command, "path": self.path, - "request_body_bytes": len(raw), "request_body_sha256": hashlib.sha256(raw).hexdigest(), - "upstream_status": status, "upstream_complete": True, - "upstream_body_bytes": len(body), "upstream_body_sha256": hashlib.sha256(body).hexdigest(), - "dropped": drop} - with self.server.lock: - self.server.records.append(record) + self.server.finish_record(record) self.close_connection = True try: if drop: diff --git a/conformance/command-keys/test_committed_response_loss.py b/conformance/command-keys/test_committed_response_loss.py index bb3f606..9fe0d75 100644 --- a/conformance/command-keys/test_committed_response_loss.py +++ b/conformance/command-keys/test_committed_response_loss.py @@ -1,5 +1,6 @@ """Socket-level controls for the shared command-admission loss fixture.""" +import concurrent.futures import hashlib import http.client import http.server @@ -9,6 +10,7 @@ import socket import threading import unittest +from unittest import mock SOURCE = Path(__file__).with_name("committed_response_loss.py") @@ -31,6 +33,9 @@ def do_GET(self): def reply(self, request): self.server.requests.append((self.path, request)) + if self.server.response_gate is not None: + self.server.response_started.set() + self.server.response_gate.wait(timeout=2) self.close_connection = True if self.server.fail_before_headers: return @@ -64,6 +69,8 @@ def setUp(self): self.upstream.body_override = None self.upstream.length_override = None self.upstream.framing = "length" + self.upstream.response_gate = None + self.upstream.response_started = threading.Event() self.serve(self.upstream) self.proxy = CommittedResponseLoss(f"http://127.0.0.1:{self.upstream.server_port}") self.serve(self.proxy) @@ -145,7 +152,7 @@ def test_upstream_must_be_loopback_http(self): "http://localhost:1234", "http://127.0.0.2:1234", "http://127.0.0.1", "http://user:password@127.0.0.1:1234", "http://127.0.0.1:1234/", "http://127.0.0.1:1234?q=x", "http://127.0.0.1:1234#fragment", - "http://127.0.0.1:0", "http://127.0.0.1:65536", "http://127.0.0.1:0123", None): + "http://127.0.0.1:0", "http://127.0.0.1:65536", "http://127.0.0.1:0123", "http://127.0.0.1:1٢3", "http://127.0.0.1:123", None): with self.subTest(address=address), self.assertRaises(ValueError): CommittedResponseLoss(address) @@ -163,13 +170,48 @@ def test_invalid_request_framing_is_refused_before_forwarding(self): self.assertEqual([], self.proxy.records) def test_only_origin_form_paths_can_be_forwarded_or_armed(self): - for path in ("http://example.invalid/", "//example.invalid/", "/path#fragment"): + for path in ("http://example.invalid/", "//example.invalid/", "/path#fragment", "/commit?token=synthetic-private-query"): with self.subTest(path=path): - self.assertEqual(400, self.raw_request("Content-Length: 0\r\n", path=path)[0]) + status, body = self.raw_request("Content-Length: 0\r\n", path=path) + self.assertEqual(400, status) + self.assertNotIn(b"synthetic-private-query", body) + self.assertEqual([], self.proxy.records) self.assertEqual([], self.upstream.requests) with self.assertRaises(ValueError): self.proxy.arm("POST", path) + def test_content_length_accepts_only_ascii_digits(self): + for value in ("1٢3", "123", "١23", "+1", "1.0"): + headers = http.client.HTTPMessage() + headers.add_header("Content-Length", value) + with self.subTest(value=value), self.assertRaises(ValueError): + MODULE.content_length(headers, True) + + def test_record_capacity_reserves_inflight_before_any_upstream_contact(self): + with mock.patch.object(MODULE, "MAX_RECORDS", 1): + self.upstream.response_gate = threading.Event() + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + first = executor.submit(self.request) + try: + self.assertTrue(self.upstream.response_started.wait(timeout=2)) + self.assertEqual(1, self.proxy.pending_records) + self.proxy.arm("POST", "/blocked") + body = b'{"request_id":"synthetic-key"}' + status, _ = self.raw_request(f"Content-Length: {len(body)}\r\n", body, path="/blocked") + self.assertEqual(503, status) + self.assertEqual(1, len(self.upstream.requests)) + self.assertEqual([], self.proxy.records) + self.assertEqual(("POST", "/blocked"), self.proxy.armed) + finally: + self.upstream.response_gate.set() + self.assertEqual(202, first.result(timeout=2)[0]) + self.assertEqual(1, len(self.proxy.records)) + self.assertEqual(0, self.proxy.pending_records) + status, _ = self.raw_request(f"Content-Length: {len(body)}\r\n", body, path="/blocked") + self.assertEqual(503, status) + self.assertEqual(1, len(self.upstream.requests)) + self.assertEqual(1, len(self.proxy.records)) + def test_bodyless_get_needs_no_content_length(self): status, body = self.raw_request("", method="GET") self.assertEqual(202, status) @@ -207,6 +249,7 @@ def test_oversized_or_incomplete_upstream_is_not_an_injected_loss(self): self.assertNotIn(marker, reply) self.assertEqual([], self.proxy.records) self.assertIsNone(self.proxy.armed) + self.assertEqual(0, self.proxy.pending_records) def test_complete_chunked_upstream_can_supply_a_bounded_loss(self): self.upstream.framing = "chunked" diff --git a/tests/test_command_response_loss.py b/tests/test_command_response_loss.py new file mode 100644 index 0000000..4fcf8f7 --- /dev/null +++ b/tests/test_command_response_loss.py @@ -0,0 +1,11 @@ +"""Include the shared socket controls in repository checks and real coverage.""" +import importlib.util +from pathlib import Path + + +def load_tests(loader, _tests, _pattern): + source = Path(__file__).resolve().parents[1] / 'conformance/command-keys/test_committed_response_loss.py' + spec = importlib.util.spec_from_file_location('command_response_loss_controls', source) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return loader.loadTestsFromTestCase(module.CommittedResponseLossTest) From c16d2fdbca003d87d82a2d9bd4701d5a9f74e8b2 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:54:53 +0000 Subject: [PATCH 4/6] Admit exact reviewed response-loss CI workflow successors --- scripts/protected_policy_context.py | 7 ++++++ tests/test_protected_policy_context.py | 34 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/scripts/protected_policy_context.py b/scripts/protected_policy_context.py index ca673f6..8e760d8 100644 --- a/scripts/protected_policy_context.py +++ b/scripts/protected_policy_context.py @@ -68,6 +68,12 @@ REVIEWED_BOUND_PHP_SOURCE_CI_SUCCESSORS = { CI_WORKFLOW: "363540f9e81e4842099f0cad394ce3fc2a83835ce1355ffef5b16722547699b4", } +# Exact reviewed successors add the response-loss socket controls to CI and +# their actual source to coverage. Existing workflow identities remain valid. +REVIEWED_RESPONSE_LOSS_SOURCE_CI_SUCCESSORS = { + CI_WORKFLOW: "ee4ddc9ad665490681b3f1a2676cdd4ce791d90677e2a931bdb4fea7cc9deb15", + SONAR_WORKFLOW: "4eed5d82d6e5decf47d4aab1d1d203eb1060f8892216360b9308c3e710bbcd77", +} # The successor remains inert until its separate runtime pin and manual-only # workflow integration. Keep the consumed predecessor's identity unchanged. REVIEWED_INITIAL_PROFILE_SUCCESSORS = { @@ -116,6 +122,7 @@ def reviewed_context_hashes(policy_target: str) -> dict[str, set[str]]: REVIEWED_PHP_CONFIGURATION_SOURCE_CI_SUCCESSORS, REVIEWED_APT_NO_RETRIES_SOURCE_CI_SUCCESSORS, REVIEWED_BOUND_PHP_SOURCE_CI_SUCCESSORS, + REVIEWED_RESPONSE_LOSS_SOURCE_CI_SUCCESSORS, REVIEWED_INITIAL_PROFILE_SUCCESSORS, REVIEWED_NORMALIZED_PROFILE_SUCCESSORS, REVIEWED_ALTERNATIVES_PROFILE_SUCCESSORS, REVIEWED_SELECTED_ALTERNATIVES_PROFILE_SUCCESSORS, diff --git a/tests/test_protected_policy_context.py b/tests/test_protected_policy_context.py index 2ab3a55..e0446fe 100644 --- a/tests/test_protected_policy_context.py +++ b/tests/test_protected_policy_context.py @@ -31,6 +31,40 @@ def test_unknown_context_target_refuses_without_interpreting_a_workflow(self): with self.assertRaisesRegex(InputRefused, "context target"): context.validate_context({}, policy_target="elsewhere") + def test_response_loss_ci_successors_preserve_predecessors_and_require_exact_review(self): + predecessors = { + context.CI_WORKFLOW: "363540f9e81e4842099f0cad394ce3fc2a83835ce1355ffef5b16722547699b4", + context.SONAR_WORKFLOW: "f7fad8367df4a4e9565d97e13aa120d55038ed9c749fdfcd86e1312bfca3cced", + } + for path, previous_digest in predecessors.items(): + raw = (ROOT / path).read_bytes() + module = (b" tests.test_command_response_loss\n" if path == context.CI_WORKFLOW + else b" tests.test_command_response_loss") + self.assertEqual(1, raw.count(module)) + previous = raw.replace(module, b"", 1) + if path == context.SONAR_WORKFLOW: + self.assertEqual(1, previous.count(b",conformance/command-keys")) + previous = previous.replace(b",conformance/command-keys", b"", 1) + self.assertEqual(previous_digest, hashlib.sha256(previous).hexdigest()) + self.assertIn(previous_digest, context.reviewed_context_hashes("github-control-plane")[path]) + context.validate_context({path: previous}) + self.assertEqual(context.REVIEWED_RESPONSE_LOSS_SOURCE_CI_SUCCESSORS[path], + hashlib.sha256(raw).hexdigest()) + with self.subTest(path=path): + context.validate_context({path: raw}) + with patch.object(context, "REVIEWED_RESPONSE_LOSS_SOURCE_CI_SUCCESSORS", {}), \ + self.assertRaises(InputRefused): + context.validate_context({path: raw}) + for changed in (raw + b"\n# unreviewed\n", + raw.replace(b"tests.test_command_response_loss", b"tests.unreviewed", 1)): + self.assertNotEqual(raw, changed) + with self.assertRaises(InputRefused): + context.validate_context({path: changed}) + with self.assertRaises(InputRefused): + context.validate_context({WORKFLOW: raw}) + with self.assertRaises(InputRefused): + context.validate_context({path: raw}, policy_target="ai") + def test_bound_php_source_ci_successor_preserves_checks_and_requires_exact_review(self): path = context.CI_WORKFLOW raw = (ROOT / "tests/fixtures/ci-bound-php-source.yml").read_bytes() From 19e2790036621035dbeb364abf042cc032e77e3c Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:59:34 +0000 Subject: [PATCH 5/6] Freeze response-loss workflow successors as inert fixtures --- tests/fixtures/ci-response-loss-source.yml | 123 ++++++++++++++++++ tests/fixtures/sonar-response-loss-source.yml | 62 +++++++++ tests/test_protected_policy_context.py | 3 +- 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/ci-response-loss-source.yml create mode 100644 tests/fixtures/sonar-response-loss-source.yml diff --git a/tests/fixtures/ci-response-loss-source.yml b/tests/fixtures/ci-response-loss-source.yml new file mode 100644 index 0000000..945ba41 --- /dev/null +++ b/tests/fixtures/ci-response-loss-source.yml @@ -0,0 +1,123 @@ +name: Repository checks + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: repository-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + contracts: + name: Shared contract tests + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + + - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + with: + clients: pip,python,composer,curl,git,npm,cargo + + - name: Disable APT download retries (CVE-2026-109518) + run: echo 'Acquire::Retries "0";' | sudo tee /etc/apt/apt.conf.d/99-no-retries >/dev/null + + - 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 + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.22.1 + cache: npm + + - uses: dtolnay/rust-toolchain@4d407b29186a635f0cc27475ef0bc0ae605a8866 # 1.86.0 + with: + toolchain: 1.86.0 + + - uses: ./.github/actions/verify-composer-trust # local + + - name: Install Python test dependencies + run: >- + python -m pip install --only-binary :all: jsonschema==4.23.0 PyYAML==6.0.2 + + - name: Test retained shared contracts + run: >- + python -m unittest + tests.test_qualification_policy + tests.test_protected_policy_context + tests.test_protected_policy_check + tests.test_protected_policy_inputs + tests.test_protected_policy_issue + tests.test_native_policy_check + tests.test_native_policy_workflow + tests.test_regression_corpus + tests.test_regression_evidence_schema + tests.test_server_codec_counterfactual_schema + tests.test_visual_evidence + tests.test_temporal_runtime + tests.test_cancellation_workflow_policy + tests.test_sdk_metrics_contract + tests.test_command_response_loss + tests.test_ci_trust + tests.test_pip_effective_trust + tests.test_cli_effective_trust + tests.test_composer_trust_contract + tests.test_composer_container_trust + tests.test_composer_container_effective_trust + tests.test_php_linux_bootstrap + tests.test_php_linux_archive + tests.test_php_linux_normalize + tests.test_php_linux_recipe + tests.test_php_linux_tools + tests.test_php_linux_alternatives + tests.test_setup_php_linux + tests.test_php_linux_qualification + tests.test_php_linux_qualification_workflow + tests.test_php_linux_configuration + + - name: Test actual Composer trust selection + run: COMPOSER_BINARY="$(command -v composer)" phpunit --configuration phpunit-composer-trust.xml --do-not-cache-result + + - name: Install Node dependencies + run: npm ci --ignore-scripts + + - name: Test browser evidence boundary + run: npm run test:visual-capture + + protected-parser-install: + name: Protected parser installer controls + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - name: Create a disposable installer runtime + run: | + python -I -m venv "$RUNNER_TEMP/protected-parser-install" + printf '%s\n' "$RUNNER_TEMP/protected-parser-install/bin" >> "$GITHUB_PATH" + - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + with: + clients: pip,python + - name: Exercise the actual isolated hash-locked installer + run: python -I tests/test_policy_dependency_install.py diff --git a/tests/fixtures/sonar-response-loss-source.yml b/tests/fixtures/sonar-response-loss-source.yml new file mode 100644 index 0000000..9423d38 --- /dev/null +++ b/tests/fixtures/sonar-response-loss-source.yml @@ -0,0 +1,62 @@ +name: SonarQube + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: sonar-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + analysis: + name: SonarQube analysis + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + with: + clients: pip,python,composer,curl,git,npm,cargo + - name: Disable APT download retries (CVE-2026-109518) + run: echo 'Acquire::Retries "0";' | sudo tee /etc/apt/apt.conf.d/99-no-retries >/dev/null + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.3' + tools: composer:2.10.3, phpunit:11.5.57 + coverage: xdebug + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.22.1 + - uses: dtolnay/rust-toolchain@4d407b29186a635f0cc27475ef0bc0ae605a8866 # 1.86.0 + with: + toolchain: 1.86.0 + - uses: ./.github/actions/verify-composer-trust # local + - name: Produce actual Composer control coverage + run: | + mkdir -p build + COMPOSER_BINARY="$(command -v composer)" XDEBUG_MODE=coverage \ + phpunit --configuration phpunit-composer-trust.xml --do-not-cache-result \ + --coverage-clover build/composer-trust-coverage.xml + - name: Produce source coverage + run: | + python -m pip install --only-binary :all: coverage==7.10.7 jsonschema==4.23.0 PyYAML==6.0.2 + coverage run --source=scripts,conformance/cancellation/temporal,conformance/command-keys,.github/actions/bootstrap-patched-pip -m unittest tests.test_qualification_policy tests.test_protected_policy_context tests.test_protected_policy_check tests.test_protected_policy_inputs tests.test_protected_policy_issue tests.test_native_policy_check tests.test_native_policy_workflow tests.test_regression_corpus tests.test_regression_evidence_schema tests.test_server_codec_counterfactual_schema tests.test_visual_evidence tests.test_temporal_runtime tests.test_cancellation_workflow_policy tests.test_sdk_metrics_contract tests.test_command_response_loss tests.test_ci_trust tests.test_pip_effective_trust tests.test_cli_effective_trust tests.test_composer_trust_contract tests.test_composer_container_trust tests.test_composer_container_effective_trust tests.test_php_linux_bootstrap tests.test_php_linux_archive tests.test_php_linux_normalize tests.test_php_linux_recipe tests.test_php_linux_tools tests.test_php_linux_alternatives tests.test_setup_php_linux tests.test_php_linux_qualification tests.test_php_linux_qualification_workflow tests.test_php_linux_configuration + coverage xml -o coverage.xml + - uses: SonarSource/sonarqube-scan-action@d209202bc7d53ff1cc128f7f907dac145c9d6ae9 # v8.3.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: -Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=300 diff --git a/tests/test_protected_policy_context.py b/tests/test_protected_policy_context.py index e0446fe..ef3b752 100644 --- a/tests/test_protected_policy_context.py +++ b/tests/test_protected_policy_context.py @@ -37,7 +37,8 @@ def test_response_loss_ci_successors_preserve_predecessors_and_require_exact_rev context.SONAR_WORKFLOW: "f7fad8367df4a4e9565d97e13aa120d55038ed9c749fdfcd86e1312bfca3cced", } for path, previous_digest in predecessors.items(): - raw = (ROOT / path).read_bytes() + name = Path(path).stem + raw = (ROOT / f"tests/fixtures/{name}-response-loss-source.yml").read_bytes() module = (b" tests.test_command_response_loss\n" if path == context.CI_WORKFLOW else b" tests.test_command_response_loss") self.assertEqual(1, raw.count(module)) From de28eb12fe2f45e8c4bf30464118d8c35e7c5c3e Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:04:11 +0000 Subject: [PATCH 6/6] Use accepted response-loss policy runtime for workflow adoption --- .github/workflows/native-policy.yml | 2 +- tests/test_native_policy_workflow.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/native-policy.yml b/.github/workflows/native-policy.yml index 82613fd..a781af4 100644 --- a/.github/workflows/native-policy.yml +++ b/.github/workflows/native-policy.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: repository: durable-workflow/.github - ref: b7da972634348e9bb27ff3653c383e93965ce043 + ref: d4770e6555fdb59b044f2e5919e95a24c0a25073 path: .native-policy-control persist-credentials: false - name: Set up the policy interpreter diff --git a/tests/test_native_policy_workflow.py b/tests/test_native_policy_workflow.py index 4b7e8cc..d542b1f 100644 --- a/tests/test_native_policy_workflow.py +++ b/tests/test_native_policy_workflow.py @@ -28,6 +28,13 @@ def test_only_central_pinned_code_is_checked_out_without_persisted_credentials(s self.assertIs(False, settings["persist-credentials"]) self.assertEqual(".native-policy-control", settings["path"]) + def test_response_loss_adoption_binds_accepted_runtime_and_exact_workflow_fixtures(self): + checkout = self.workflow["jobs"]["policy"]["steps"][0] + self.assertEqual("d4770e6555fdb59b044f2e5919e95a24c0a25073", checkout["with"]["ref"]) + for name in ("ci", "sonar"): + self.assertEqual((ROOT / f"tests/fixtures/{name}-response-loss-source.yml").read_bytes(), + (ROOT / f".github/workflows/{name}.yml").read_bytes()) + def test_no_issuer_key_or_write_permission_is_introduced(self): self.assertEqual({"contents": "read", "pull-requests": "read"}, self.workflow["permissions"]) raw = (ROOT / ".github/workflows/native-policy.yml").read_text()