From 4514a9ef2011e5d4a8fdf47fcd88807dcb7cb53f Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:22:38 +0000 Subject: [PATCH 01/45] T078: 16.1, the OpenAI-compatible dialect joins DIALECTS (plan 034) `openai-chat-v1` joins doxbench_binding.DIALECTS as the second member, after `xfactory-prompt-v1`, which stays first. Its request is the chat-completions grammar (`model`, `messages`, the assembled prompt as one message in the user role), and its answer is read at `choices[0].message.content`. Both are spoken by one arm in doxbench_provider.py alone (`_DIALECT_ARMS`), beside the prompt grammar's arm, which sends the bytes it always sent. An unknown dialect is still refused when a binding is declared, and a test holds the arm table's keys equal to the vocabulary. Falsifier: F16.1's dialect assertion, `"openai-chat-v1" in b.DIALECTS`. Ruled: R1Q22 (a), openxFactory#656 comment 5817152735. doxbench_binding.py is a moved_verbatim row, and editing it needs no declared-edit act. Drafted ahead of T063 under Brett's phase-3 word ("Only the independent ones"). It does not land before T063. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 30 ++-- src/opendox/doxbench_provider.py | 106 ++++++++++-- tests/test_model_provider_broker.py | 249 +++++++++++++++++++++++++++- 3 files changed, 358 insertions(+), 27 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index fdd9e2ff..3b1e2131 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -93,21 +93,31 @@ AUTH_KINDS: tuple[str, ...] = (AUTH_KIND_API_KEY, AUTH_KIND_OAUTH) #: The CLOSED dialect vocabulary a binding may declare — the request grammar the -#: provider client speaks at the declared endpoint. ONE member today: this -#: repository's own already-declared turn shape, a prompt in and an -#: `assistant_prose` out, which is the shape `doxbench_model.dispatch_turn` -#: validates on the way back, so no second response grammar exists to keep -#: honest. CLOSED rather than open because an UNKNOWN dialect must REFUSE rather -#: than be guessed at: sending an assembled prompt to an endpoint whose grammar -#: this client does not know is a paid call that cannot succeed. A second member -#: joins here and an arm joins beside the first in `doxbench_provider`; the check -#: is never loosened. +#: provider client speaks at the declared endpoint. CLOSED rather than open +#: because an UNKNOWN dialect must REFUSE rather than be guessed at: sending an +#: assembled prompt to an endpoint whose grammar this client does not know is a +#: paid call that cannot succeed. A member joins here and an arm joins beside the +#: others in `doxbench_provider`; the check is never loosened. +#: +#: TWO MEMBERS, and the second joined exactly that way (#1144 box 16.1; plan 034 +#: T078): +#: +#: * `xfactory-prompt-v1` — this repository's own already-declared turn shape, +#: a POST of a model and a prompt answered by an `assistant_prose`, which is +#: the shape `doxbench_model.dispatch_turn` validates on the way back. It +#: stays FIRST, and it is unchanged byte for byte; +#: * `openai-chat-v1` — the OpenAI-compatible chat-completions grammar: a +#: request of a model and a list of messages, answered by the content of +#: the first choice's message. It is what a hosted API and the usual local +#: server both speak, which the first member does not. Its arm is in +#: `doxbench_provider` alone, beside the first one's. #: #: THE VOCABULARY LIVES HERE, on the record that declares it, and #: `doxbench_provider` reads it from this module — so an unknown dialect is #: refused when an operator DECLARES the binding rather than when a turn fails. DIALECT_XFACTORY_PROMPT_V1 = "xfactory-prompt-v1" -DIALECTS: tuple[str, ...] = (DIALECT_XFACTORY_PROMPT_V1,) +DIALECT_OPENAI_CHAT_V1 = "openai-chat-v1" +DIALECTS: tuple[str, ...] = (DIALECT_XFACTORY_PROMPT_V1, DIALECT_OPENAI_CHAT_V1) #: The URL schemes a declared endpoint may carry. `http://` is permitted for the #: on-this-host proxy posture an operator may legitimately run; a scheme this diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index fcff458b..42e9ad9e 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -34,7 +34,10 @@ console process. Brett's ruling of 2026-08-08: the broker mints, doxBench calls, because a broker in the request path adds a hop to every turn and to every chunk of a streamed one. WHERE to call and WHAT GRAMMAR to speak are - the BINDING's — the broker's declaration emits neither, deliberately; + the BINDING's — the broker's declaration emits neither, deliberately. Each + grammar the binding may declare has one arm here (`_DIALECT_ARMS`): this + repository's own prompt grammar, and the OpenAI-compatible chat-completions + grammar (#1144 box 16.1); * EXPIRY is handled by the 2026-08-26 ruling: re-mint and retry ONCE, with the re-mint and the paid retry visibly recorded, and a second expiry inside one turn surfaces the standard refusal rather than buying a third call. The @@ -175,8 +178,9 @@ #: it is the record that validates it). Aliased rather than respelled so the two #: modules cannot drift into two vocabularies. An unknown dialect is refused #: when an operator DECLARES the binding — earlier than a mint, and earlier than -#: a paid call. +#: a paid call. Each member has exactly one ARM below (`_DIALECT_ARMS`). DIALECT_XFACTORY_PROMPT_V1 = binding_mod.DIALECT_XFACTORY_PROMPT_V1 +DIALECT_OPENAI_CHAT_V1 = binding_mod.DIALECT_OPENAI_CHAT_V1 DIALECTS: tuple[str, ...] = binding_mod.DIALECTS #: How long a broker invocation may take. A mint is a local process doing local @@ -616,13 +620,27 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: # the provider transport # --------------------------------------------------------------------------- -#: The provider request's own field names, in the ONE dialect this client -#: speaks. Named constants rather than inline literals so the boundary test can -#: assert they exist only here. +#: The provider request's own field names, per dialect. Named constants rather +#: than inline literals so the boundary test can assert they exist only here. +#: `model` is the one field both grammars share. PROVIDER_REQUEST_MODEL_FIELD = "model" + +#: `xfactory-prompt-v1`: a model and a prompt in, an `assistant_prose` out. PROVIDER_REQUEST_PROMPT_FIELD = "prompt" PROVIDER_RESPONSE_PROSE_FIELD = "assistant_prose" +#: `openai-chat-v1` (#1144 box 16.1; plan 034 T078): the chat-completions +#: request, a model and a list of messages, and its answer, the content of the +#: first choice's message. The assembled prompt travels as ONE message in the +#: user role. Prompt assembly is on the other side of the port (D14), so this +#: arm carries the text it was given and composes no message of its own. +PROVIDER_REQUEST_MESSAGES_FIELD = "messages" +CHAT_MESSAGE_ROLE_FIELD = "role" +CHAT_MESSAGE_CONTENT_FIELD = "content" +CHAT_ROLE_USER = "user" +CHAT_RESPONSE_CHOICES_FIELD = "choices" +CHAT_RESPONSE_MESSAGE_FIELD = "message" + #: The status a provider returns when the presented token is no longer good. #: 401 only: a 403 is an authorization verdict about what the token may do, #: which re-minting the same scope cannot change, and retrying it would buy a @@ -630,6 +648,62 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: PROVIDER_STATUS_TOKEN_EXPIRED = 401 +def _prompt_request(model: str, prompt: str) -> dict: + """`xfactory-prompt-v1`'s request, exactly as it has always been sent.""" + return {PROVIDER_REQUEST_MODEL_FIELD: model, + PROVIDER_REQUEST_PROMPT_FIELD: prompt} + + +def _prompt_answer(document: dict) -> str: + """`xfactory-prompt-v1`'s answer: its `assistant_prose`, a string.""" + prose = document.get(PROVIDER_RESPONSE_PROSE_FIELD) + if not isinstance(prose, str): + raise BrokerRefused(DIAG_PROVIDER_MALFORMED) + return prose + + +def _chat_request(model: str, prompt: str) -> dict: + """`openai-chat-v1`'s request: the model, and the prompt as one message in + the user role.""" + return {PROVIDER_REQUEST_MODEL_FIELD: model, + PROVIDER_REQUEST_MESSAGES_FIELD: [ + {CHAT_MESSAGE_ROLE_FIELD: CHAT_ROLE_USER, + CHAT_MESSAGE_CONTENT_FIELD: prompt}]} + + +def _chat_answer(document: dict) -> str: + """`openai-chat-v1`'s answer: `choices[0].message.content`, a string. + + Read at exactly that path and nowhere else. A body with no first choice, a + choice with no message, or a message whose content is not text (a tool-call + answer carries null there) is not an answer this seam can hand back as + prose. Each lands on the fixed `DIAG_PROVIDER_MALFORMED` that every other + unusable answer lands on. Nothing past the first choice is read: the + request asks for one.""" + choices = document.get(CHAT_RESPONSE_CHOICES_FIELD) + if not isinstance(choices, list) or not choices: + raise BrokerRefused(DIAG_PROVIDER_MALFORMED) + first = choices[0] + message = (first.get(CHAT_RESPONSE_MESSAGE_FIELD) + if isinstance(first, dict) else None) + content = (message.get(CHAT_MESSAGE_CONTENT_FIELD) + if isinstance(message, dict) else None) + if not isinstance(content, str): + raise BrokerRefused(DIAG_PROVIDER_MALFORMED) + return content + + +#: ONE ARM PER DECLARED DIALECT: the function that builds its request and the +#: function that reads its answer. The record's closed vocabulary +#: (`doxbench_binding.DIALECTS`) refuses any other member at declaration, and a +#: test holds this table's keys equal to that vocabulary, so a member cannot +#: join one without the other. +_DIALECT_ARMS: dict[str, tuple] = { + DIALECT_XFACTORY_PROMPT_V1: (_prompt_request, _prompt_answer), + DIALECT_OPENAI_CHAT_V1: (_chat_request, _chat_answer), +} + + def _post_to_provider(token: MintedToken, *, model_id: str, prompt: str, timeout: float, opener) -> str: """The ONE place a provider is contacted. Returns the assistant prose. @@ -638,6 +712,11 @@ def _post_to_provider(token: MintedToken, *, model_id: str, prompt: str, it is not in the URL (which a proxy logs), not in the body (which an error handler might echo), and not in this function's return value. + THE GRAMMAR IS THE BINDING'S DIALECT (#1144 box 16.1), which the token + carries from the binding. Its arm in `_DIALECT_ARMS` builds the request + body and reads the answer. The route, the header, the bound, the expiry + status and every refusal below are the same for both dialects. + THE ANSWER IS BOUNDED (PR #392 review note b). `response.read()` with no argument reads until the peer stops sending, which makes the memory of this process a function of what a declared endpoint chooses to send — and the @@ -645,10 +724,14 @@ def _post_to_provider(token: MintedToken, *, model_id: str, prompt: str, byte over `MAX_PROVIDER_ANSWER_BYTES` is read deliberately, so an answer that is exactly at the bound is still honoured while one past it is detected rather than truncated into a shorter document that would parse.""" - body = json.dumps({ - PROVIDER_REQUEST_MODEL_FIELD: model_id, - PROVIDER_REQUEST_PROMPT_FIELD: prompt, - }).encode("utf-8") + arm = _DIALECT_ARMS.get(token.dialect) + if arm is None: + raise AssertionError( + f"{token.dialect!r} is outside the declared dialect vocabulary " + f"{DIALECTS}; the binding refuses it at declaration, so no turn " + "can carry one") + build_request, read_answer = arm + body = json.dumps(build_request(model_id, prompt)).encode("utf-8") request = urllib.request.Request( # noqa: S310 - endpoint declared on the binding by its operator, carried on the minted token token.endpoint, data=body, method="POST") request.add_header("Content-Type", "application/json") @@ -680,10 +763,7 @@ def _post_to_provider(token: MintedToken, *, model_id: str, prompt: str, raise BrokerRefused(DIAG_PROVIDER_MALFORMED) from error if not isinstance(document, dict): raise BrokerRefused(DIAG_PROVIDER_MALFORMED) - prose = document.get(PROVIDER_RESPONSE_PROSE_FIELD) - if not isinstance(prose, str): - raise BrokerRefused(DIAG_PROVIDER_MALFORMED) - return prose + return read_answer(document) # --------------------------------------------------------------------------- diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 4a5fbaea..9fe6099e 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -21,6 +21,9 @@ redacted refusal `doxbench_model.dispatch_turn` already defines, and the UNCONFIGURED posture is byte-for-byte what it was before this change. +A SIXTH LAYER, (f), holds #1144 Group 16's binding and provider boxes (plan +034 phase 3, slice P3-B). 16.1 is the OpenAI-compatible dialect (T078). + THE FAKE BROKER SPEAKS THE DECLARED CONTRACT (task 2.6). It was this repository's own invented stdin/stdout protocol until the reconciliation, which meant every test here agreed with a broker that does not exist. It now takes the @@ -40,6 +43,7 @@ from __future__ import annotations +import contextlib import dataclasses import http.server import io @@ -145,8 +149,12 @@ def test_the_dialect_vocabulary_is_closed_and_refuses_at_declaration(): there; openProfiler's declaration emits no dialect at all, so the fact is the BINDING's and the refusal happens when an operator DECLARES one — before any broker is invoked and long before a paid call. Closed, still: an unknown - grammar refuses rather than being guessed at.""" - assert binding_mod.DIALECTS == ("xfactory-prompt-v1",) + grammar refuses rather than being guessed at. + + TWO MEMBERS since #1144 box 16.1 (plan 034 T078), and the order is pinned: + the prompt grammar stays first, and the OpenAI-compatible chat grammar + joins after it. The refusal below is the same refusal it always was.""" + assert binding_mod.DIALECTS == ("xfactory-prompt-v1", "openai-chat-v1") assert provider_mod.DIALECTS is binding_mod.DIALECTS, \ "one vocabulary, read from the record that declares it" with pytest.raises(binding_mod.BindingRefused) as caught: @@ -866,9 +874,9 @@ def _expired_error(): def _port(tmp_path, *outcomes, expires=None, notice=None, clock=time.time, - endpoint=ENDPOINT): + endpoint=ENDPOINT, dialect=binding_mod.DIALECT_XFACTORY_PROMPT_V1): script = _write_broker(tmp_path, expires=expires) - binding = _broker_binding(script, endpoint=endpoint) + binding = _broker_binding(script, endpoint=endpoint, dialect=dialect) opener = _Opener(*outcomes) port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), @@ -1336,3 +1344,236 @@ def test_the_subprocess_runner_never_uses_a_shell(tmp_path): assert "shell=True" not in source assert "os.system" not in source assert subprocess.Popen is subprocess.Popen # the module spawns, nothing else + + +# =========================================================================== +# (f) CHAT'S MODEL CONFIGURATION (#1144 Group 16; plan 034 phase 3, P3-B) +# =========================================================================== +# +# 16.1, the OpenAI-compatible dialect (T078). `openai-chat-v1` is the second +# `DIALECTS` member. Its request is the chat-completions grammar (`model`, +# `messages`), and its answer is read at `choices[0].message.content`. Both are +# spoken by one arm in `doxbench_provider`, beside the prompt grammar's arm. + +OPENAI_CHAT = binding_mod.DIALECT_OPENAI_CHAT_V1 + + +def _chat_completion(content="the chat answer"): + """A chat-completions answer in that grammar's own shape. The keys around + `choices` are what a real server sends, and nothing here reads them.""" + return {"id": "chatcmpl-stand-in", "object": "chat.completion", + "model": "stand-in-model", + "choices": [{"index": 0, "finish_reason": "stop", + "message": {"role": "assistant", + "content": content}}]} + + +def test_f16_1_the_openai_compatible_dialect_is_declared(): + """F16.1's dialect assertion, as #1144 writes it: + `assert "openai-chat-v1" in b.DIALECTS`. A binding may declare it.""" + assert "openai-chat-v1" in binding_mod.DIALECTS, ( + f"no OpenAI-compatible dialect: {binding_mod.DIALECTS}") + assert OPENAI_CHAT == "openai-chat-v1" + assert _binding(dialect=OPENAI_CHAT).dialect == OPENAI_CHAT + assert provider_mod.DIALECT_OPENAI_CHAT_V1 is OPENAI_CHAT, \ + "one spelling, read from the record that declares it" + + +def test_every_declared_dialect_has_exactly_one_arm_in_the_provider_module(): + """A member cannot join the vocabulary without an arm, or an arm exist + for a member the record would refuse.""" + assert set(provider_mod._DIALECT_ARMS) == set(binding_mod.DIALECTS) + + +def test_a_chat_turn_speaks_the_chat_completions_grammar(tmp_path): + port, opener = _port(tmp_path, _chat_completion("the answer"), + dialect=OPENAI_CHAT) + assert port.dispatch(_Envelope()) == {"assistant_prose": "the answer", + "proposals": []} + request = opener.requests[0] + assert request.get_method() == "POST" + assert request.get_full_url() == ENDPOINT + assert json.loads(request.data.decode("utf-8")) == { + "model": "openprofiler-demo", + "messages": [{"role": "user", "content": "assembled prompt"}]} + assert request.get_header("Content-type") == "application/json" + # the token travels in the header, exactly as it does for the prompt grammar + assert request.get_header("Authorization") == f"Bearer {SENTINEL_TOKEN}" + assert SENTINEL_TOKEN not in request.get_full_url() + assert SENTINEL_TOKEN not in request.data.decode("utf-8") + + +def test_the_prompt_dialect_is_unchanged_byte_for_byte(tmp_path): + """The first member's request is the bytes it always was: the arm table + moved the code, and nothing it sends.""" + port, opener = _port(tmp_path, {"assistant_prose": "a"}) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + assert opener.requests[0].data == json.dumps( + {"model": "openprofiler-demo", "prompt": "assembled prompt"} + ).encode("utf-8") + + +@pytest.mark.parametrize("answer", [ + {}, + {"choices": []}, + {"choices": "not a list"}, + {"choices": ["not an object"]}, + {"choices": [{}]}, + {"choices": [{"message": "not an object"}]}, + {"choices": [{"message": {"role": "assistant"}}]}, + {"choices": [{"message": {"role": "assistant", "content": None}}]}, + {"choices": [{"message": {"role": "assistant", "content": 7}}]}, + {"assistant_prose": "the prompt grammar's answer, not this one's"}, +], ids=["empty", "no-choice", "choices-not-a-list", "choice-not-an-object", + "no-message", "message-not-an-object", "no-content", "null-content", + "content-not-text", "the-other-grammar"]) +def test_a_chat_answer_off_the_declared_path_is_malformed(tmp_path, answer): + port, _opener = _port(tmp_path, answer, dialect=OPENAI_CHAT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED + + +def test_a_chat_shaped_answer_is_not_the_prompt_grammars_answer(tmp_path): + """Each arm reads its own grammar and no other.""" + port, _opener = _port(tmp_path, _chat_completion()) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED + + +def test_only_the_first_choice_is_read(tmp_path): + answer = _chat_completion("first") + answer["choices"].append({"index": 1, "finish_reason": "stop", + "message": {"role": "assistant", + "content": "second"}}) + port, _opener = _port(tmp_path, answer, dialect=OPENAI_CHAT) + assert port.dispatch(_Envelope())["assistant_prose"] == "first" + + +def test_the_expiry_ruling_holds_for_the_chat_grammar(tmp_path): + """The 2026-08-26 ruling is the port's, not a dialect's: a mid-turn expiry + re-mints and retries once, visibly, in either grammar.""" + printed: list[str] = [] + port, opener = _port(tmp_path, _expired_error(), + _chat_completion("the retried answer"), + notice=printed.append, dialect=OPENAI_CHAT) + assert port.dispatch(_Envelope())["assistant_prose"] == "the retried answer" + assert len(opener.requests) == 2, "exactly one paid retry" + assert [event.reason for event in port.ledger] == [ + provider_mod.REASON_FIRST_MINT, + provider_mod.REASON_EXPIRY_REMINT, + provider_mod.REASON_PAID_RETRY, + ] + assert printed and "re-minted once and retried" in printed[0] + + +def test_the_answer_bound_holds_for_the_chat_grammar(tmp_path): + bound = provider_mod.MAX_PROVIDER_ANSWER_BYTES + oversize = json.dumps(_chat_completion("x" * bound)).encode("utf-8") + port, _opener = _port(tmp_path, oversize, dialect=OPENAI_CHAT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED + + +def test_a_chat_provider_refusal_lands_on_the_fixed_sentence(tmp_path): + port, _opener = _port( + tmp_path, + urllib.error.HTTPError(ENDPOINT, 400, "Bad Request", {}, + io.BytesIO(b'{"error":{"message":"leaky"}}')), + dialect=OPENAI_CHAT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_REFUSED + assert "leaky" not in str(caught.value) + + +@contextlib.contextmanager +def _stand_in_provider(handler_class): + """A stand-in provider on loopback for the length of one test. It yields + the server's base URL, and it is shut down and joined however the test + ends.""" + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler_class) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + host, prt = server.server_address[:2] + yield f"http://{host}:{prt}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def _answer_json(handler, document) -> None: + """Answer one stand-in request with `document` as a JSON body.""" + payload = json.dumps(document).encode("utf-8") + handler.send_response(200) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(payload))) + handler.end_headers() + handler.wfile.write(payload) + + +class _ChatCompletionsHandler(http.server.BaseHTTPRequestHandler): + """A stand-in OpenAI-compatible server on loopback. It records each + request and answers in the chat-completions grammar.""" + + seen: dict = {} + + def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler's own spelling + length = int(self.headers.get("Content-Length", "0")) + _ChatCompletionsHandler.seen = { + "path": self.path, + "authorization": self.headers.get("Authorization"), + "content_type": self.headers.get("Content-Type"), + "body": json.loads(self.rfile.read(length).decode("utf-8")), + } + _answer_json(self, _chat_completion("answered in the chat grammar")) + + def log_message(self, *_args): + return + + +def test_a_chat_turn_reaches_a_stand_in_chat_completions_server(tmp_path): + """The real `urllib` path, in the chat grammar: a stand-in server on + loopback receives the request at the binding's declared endpoint, in that + grammar, with the token in the authorization header and nowhere else.""" + with _stand_in_provider(_ChatCompletionsHandler) as base: + binding = _broker_binding(_write_broker(tmp_path), + endpoint=f"{base}/v1/chat/completions", + dialect=OPENAI_CHAT) + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + notice=lambda _text: None) + assert port.dispatch(_Envelope()) == { + "assistant_prose": "answered in the chat grammar", + "proposals": []} + + seen = _ChatCompletionsHandler.seen + assert seen["path"] == "/v1/chat/completions" + assert seen["authorization"] == f"Bearer {SENTINEL_TOKEN}" + assert seen["content_type"] == "application/json" + assert seen["body"] == { + "model": "openprofiler-demo", + "messages": [{"role": "user", "content": "assembled prompt"}]} + assert SENTINEL_TOKEN not in json.dumps(seen["body"]) + + +def test_the_cli_declares_a_chat_binding(tmp_path, capsys): + """The operator door offers the dialect, because its choices are read from + the record's vocabulary rather than respelled.""" + checkout = tmp_path / "checkout" + checkout.mkdir() + args = cli_mod.build_parser().parse_args([ + "model-binding", "add", "--repo-root", str(checkout), + "--id", "local-chat", "--label", "Local chat", "--provider", "local", + "--credential-ref", FAKE_REFERENCE, "--auth-kind", "api_key", + "--credential-approver", "brett@opensoft.one", + "--endpoint", "http://127.0.0.1:9/v1/chat/completions", + "--dialect", OPENAI_CHAT, "--", "openprofiler-broker"]) + assert args.func(args) == 0 + capsys.readouterr() + store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) + assert store.get("local-chat").dialect == OPENAI_CHAT From 7c83c2cd0bbabc8a6005ee524a8ed018f9c9c807 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:29:46 +0000 Subject: [PATCH 02/45] T079: 16.2, a `model` field the provider receives (plan 034) The binding record gains `model`, the model name the provider receives as the request's model, in either dialect. It sits after `dialect`, with the route it belongs to, so BINDING_FIELDS grows from nine to ten, and still no field can hold a secret. `model-binding add|edit --model` sets it, and `model-binding list` shows it. A binding that declares no model keeps the meaning it had: the request names the catalog handle, the binding's `id`, byte for byte. So `model` is keyword-only and defaults to None, and a stored record may leave it out (OPTIONAL_BINDING_FIELDS). Every construction written before the field existed builds the binding it built, and every nine-field document reads. The console's intake route in serve_workbench.py builds a binding without a model, and it keeps working unedited. The catalog handle stays the binding's id, and `model` is not an argv placeholder. Falsifier: F16.1's field assertion, `"model" in b.BINDING_FIELDS`. Ruled: R1Q22 (a), openxFactory#656 comment 5817152735. Drafted ahead of T063 under Brett's phase-3 word ("Only the independent ones"). It does not land before T063. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/cli_model_binding.py | 21 ++- src/opendox/doxbench_binding.py | 47 +++++-- src/opendox/doxbench_provider.py | 26 ++-- tests/test_model_provider_broker.py | 208 +++++++++++++++++++++++++++- 4 files changed, 280 insertions(+), 22 deletions(-) diff --git a/src/opendox/cli_model_binding.py b/src/opendox/cli_model_binding.py index 2331034d..f5c15ad9 100644 --- a/src/opendox/cli_model_binding.py +++ b/src/opendox/cli_model_binding.py @@ -50,7 +50,14 @@ def _declared_binding(args: argparse.Namespace) -> "binding_mod.ModelProviderBin id=args.id, label=args.label, provider=args.provider, credential_ref=args.credential_ref, auth_kind=args.auth_kind, approved_by=args.approved_by, endpoint=args.endpoint, - dialect=args.dialect, broker_argv=tuple(args.broker_argv)) + dialect=args.dialect, model=args.model, + broker_argv=tuple(args.broker_argv)) + + +#: What `list` prints for a binding that declares no model (#1144 box 16.2). +#: The request then names the binding's id, as every request did before the +#: field existed, and the operator reading the list should see that. +NO_MODEL_DECLARED = "(none declared: the request names this binding's id)" def cmd_model_binding_list(args: argparse.Namespace) -> int: @@ -77,6 +84,9 @@ def cmd_model_binding_list(args: argparse.Namespace) -> int: print(f" credential ref {record['credential_ref']}") print(f" endpoint {record['endpoint']}") print(f" dialect {record['dialect']}") + model = record["model"] + print(f" model " + f"{model if model is not None else NO_MODEL_DECLARED}") print(f" broker argv {record['broker_argv']}") print(f" custody {record['credential_custody']}") return 0 @@ -203,6 +213,15 @@ def _add_binding_declaration_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--dialect", required=True, choices=list(binding_mod.DIALECTS), help="the request grammar that endpoint speaks") + # THE MODEL THE PROVIDER RECEIVES (#1144 box 16.2), the route's third fact. + # OPTIONAL, and that keeps a binding declared without it meaning what it + # always meant: the request names the binding's id. `edit` replaces the + # whole binding, as it always has, so an edit that omits `--model` declares + # none. + parser.add_argument("--model", default=None, + help="the model name the provider receives in each " + "request (default: none declared, and the " + "request names this binding's id)") # A POSITIONAL, taken after a bare `--`, and that is the fix for a real # trap rather than a style choice: a broker invocation is full of # option-shaped members (`--binding`, `--ref`), and as a flag's value they diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index 3b1e2131..c76ca89a 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -31,7 +31,9 @@ would then be accountable for. So provider routing is the CONSUMER's fact, and the consumer's declared record is where a fact the consumer owns belongs. Declaring a route is not holding a transport: nothing here opens a socket, and -the module still names no provider host of its own. +the module still names no provider host of its own. #1144 box 16.2 gave the +route a third fact, `model`: the model name the provider receives. It is the +consumer's fact for the same reason. THE BROKER INVOCATION IS DECLARED, NOT WRITTEN INTO CODE — the program and its fixed leading arguments. `broker_argv` is the BASE invocation and names no @@ -133,9 +135,14 @@ #: `provider` and `approved_by` are REQUIRED flags of the declared `intake` #: (`--provider`, `--approved-by`; the second because `credential-contracts` #: holds that a grant without an approver is invalid), and `endpoint`/`dialect` -#: are the provider route the mint answer deliberately does not carry. STILL NO -#: SECRET FIELD: nine fields, and the absence of a tenth is the same point the -#: absence of a sixth was. +#: are the provider route the mint answer deliberately does not carry. +#: +#: AND FROM NINE TO TEN BY #1144 box 16.2 (plan 034 T079): `model`, the model +#: name the provider receives as the request's model. It sits with the route +#: it belongs to, after `dialect`. Before it, the request named the catalog +#: handle, which is this binding's `id`, so no provider model could be named. +#: STILL NO SECRET FIELD: ten fields, and the absence of an eleventh is the +#: same point the absence of a sixth was. BINDING_FIELDS: tuple[str, ...] = ( "id", "label", @@ -145,16 +152,25 @@ "approved_by", "endpoint", "dialect", + "model", "broker_argv", ) +#: The one field a stored record may leave out. A record without `model` was +#: declared before the field existed, and it keeps the meaning it had: the +#: request names the catalog handle, this binding's `id`. Every other field is +#: required, as it always was. +OPTIONAL_BINDING_FIELDS: tuple[str, ...] = ("model",) + #: The CLOSED placeholder vocabulary an argv template may name. Every member is #: a field of the binding itself, which is the property that matters: a template #: can only ever be filled with facts the binding already discloses, so no #: substitution can smuggle a value the record does not carry. A template naming #: anything outside this set is refused at construction rather than at #: execution — an operator finds out when they declare the binding, not when a -#: turn fails. +#: turn fails. `model` is not a member: a broker's invocation is about custody, +#: never about which model a turn asks for, and an undeclared model has no +#: value to fill a placeholder with. ARGV_PLACEHOLDERS: tuple[str, ...] = ( "binding_id", "label", "provider", "credential_ref", "auth_kind", "approved_by", "endpoint", "dialect") @@ -195,12 +211,19 @@ def _require_non_blank_str(field: str, value: object) -> str: class ModelProviderBinding: """ONE model provider, as settings hold it. - Nine fields, and the absence of a tenth is the point (see the module + Ten fields, and the absence of an eleventh is the point (see the module docstring). `broker_argv` is the DECLARED BASE invocation as a tuple of argv members — argv, never a shell string, so no operator's label and no credential reference can ever be read as shell syntax. It names the program and its fixed leading arguments and NOT the operation: the operation is a declared subcommand `doxbench_provider` appends. + + `model` (#1144 box 16.2) is the model name the provider receives as the + request's model. It is KEYWORD-ONLY and defaults to None, so every + construction written before it existed still builds the binding it built. + That binding keeps its old meaning: with no model declared, the request + names the catalog handle, which is the binding's `id`, exactly as before. + A declared model is a non-blank string. """ id: str @@ -211,12 +234,15 @@ class ModelProviderBinding: approved_by: str endpoint: str dialect: str + model: str | None = dataclasses.field(default=None, kw_only=True) broker_argv: tuple[str, ...] def __post_init__(self) -> None: for field in ("id", "label", "provider", "credential_ref", "auth_kind", "approved_by", "endpoint", "dialect"): _require_non_blank_str(field, getattr(self, field)) + if self.model is not None: + _require_non_blank_str("model", self.model) if self.auth_kind not in AUTH_KINDS: raise BindingRefused( f"auth_kind {self.auth_kind!r} is outside the closed " @@ -259,7 +285,8 @@ def __post_init__(self) -> None: def as_record(self) -> dict: """The STORED record: the record kind, then exactly ``BINDING_FIELDS`` in order. `broker_argv` becomes a list because that is what YAML round - trips; nothing else changes shape.""" + trips. An undeclared `model` is written as null, so every stored + record carries all ten keys; nothing else changes shape.""" return { "kind": BINDING_KIND, "id": self.id, @@ -270,6 +297,7 @@ def as_record(self) -> dict: "approved_by": self.approved_by, "endpoint": self.endpoint, "dialect": self.dialect, + "model": self.model, "broker_argv": list(self.broker_argv), } @@ -334,7 +362,9 @@ def from_record(cls, record: object) -> "ModelProviderBinding": raise BindingRefused( f"a binding record declares kind {declared_kind!r}, not " f"{BINDING_KIND!r}") - missing = [field for field in BINDING_FIELDS if field not in record] + missing = [field for field in BINDING_FIELDS + if field not in record + and field not in OPTIONAL_BINDING_FIELDS] if missing: raise BindingRefused( f"a binding record is missing {missing}") @@ -347,6 +377,7 @@ def from_record(cls, record: object) -> "ModelProviderBinding": approved_by=record["approved_by"], endpoint=record["endpoint"], dialect=record["dialect"], + model=record.get("model"), broker_argv=record["broker_argv"], ) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 42e9ad9e..9317a995 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -704,10 +704,14 @@ def _chat_answer(document: dict) -> str: } -def _post_to_provider(token: MintedToken, *, model_id: str, prompt: str, +def _post_to_provider(token: MintedToken, *, model: str, prompt: str, timeout: float, opener) -> str: """The ONE place a provider is contacted. Returns the assistant prose. + `model` is the model name the request carries, which the port chose (the + binding's declared `model`, or the catalog handle for a binding that + declares none). + The token travels in the request's authorization header and nowhere else; it is not in the URL (which a proxy logs), not in the body (which an error handler might echo), and not in this function's return value. @@ -731,7 +735,7 @@ def _post_to_provider(token: MintedToken, *, model_id: str, prompt: str, f"{DIALECTS}; the binding refuses it at declaration, so no turn " "can carry one") build_request, read_answer = arm - body = json.dumps(build_request(model_id, prompt)).encode("utf-8") + body = json.dumps(build_request(model, prompt)).encode("utf-8") request = urllib.request.Request( # noqa: S310 - endpoint declared on the binding by its operator, carried on the minted token token.endpoint, data=body, method="POST") request.add_header("Content-Type", "application/json") @@ -909,15 +913,21 @@ def dispatch(self, prompt_envelope: object) -> object: unrelated issuances. The expired mint's reference is read off the token this turn is holding and lives no longer than the turn; * a SECOND expiry inside the same turn raises the standard refusal. - No third call is bought.""" - model_id = getattr(prompt_envelope, "model_id", None) - if not isinstance(model_id, str) or not model_id: + No third call is bought. + + THE REQUEST'S MODEL IS THE BINDING'S DECLARED `model` (#1144 box + 16.2). A binding that declares none sends the catalog handle, which is + what every request sent before the field existed, byte for byte.""" + handle = getattr(prompt_envelope, "model_id", None) + if not isinstance(handle, str) or not handle: entries = self._declared_catalog.entries - model_id = entries[0].model_id if entries else "" + handle = entries[0].model_id if entries else "" + declared_model = self._binding.model + model = declared_model if declared_model is not None else handle prompt = bridge_mod.render_prompt_message(prompt_envelope) token = self._current_token(REASON_FIRST_MINT) try: - prose = _post_to_provider(token, model_id=model_id, prompt=prompt, + prose = _post_to_provider(token, model=model, prompt=prompt, timeout=self._timeout_seconds, opener=self._opener) except _TokenExpired: @@ -932,7 +942,7 @@ def dispatch(self, prompt_envelope: object) -> object: self._record(REASON_PAID_RETRY) try: prose = _post_to_provider( - token, model_id=model_id, prompt=prompt, + token, model=model, prompt=prompt, timeout=self._timeout_seconds, opener=self._opener) except _TokenExpired: self._forget_token() diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 9fe6099e..82bf9a11 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -22,7 +22,8 @@ UNCONFIGURED posture is byte-for-byte what it was before this change. A SIXTH LAYER, (f), holds #1144 Group 16's binding and provider boxes (plan -034 phase 3, slice P3-B). 16.1 is the OpenAI-compatible dialect (T078). +034 phase 3, slice P3-B). 16.1 is the OpenAI-compatible dialect (T078), and +16.2 is the model name the provider receives (T079). THE FAKE BROKER SPEAKS THE DECLARED CONTRACT (task 2.6). It was this repository's own invented stdin/stdout protocol until the reconciliation, which @@ -120,8 +121,9 @@ def test_the_binding_declares_exactly_the_fields_the_seam_needs(): "secret", "api_key", "token", "credential", "value", "password"]) def test_no_secret_field_exists_in_the_shape_to_populate(secret_field): """NOT OPTIONAL — ABSENT. The dataclass is slotted and frozen, so a secret - cannot be passed in and cannot be attached afterwards. Nine fields now - rather than five, and the absence of a tenth is the same claim.""" + cannot be passed in and cannot be attached afterwards. Ten fields now + rather than five (#1144 box 16.2 added `model`), and the absence of an + eleventh is the same claim.""" with pytest.raises(TypeError): _binding(**{secret_field: SENTINEL_CREDENTIAL}) binding = _binding() @@ -874,9 +876,11 @@ def _expired_error(): def _port(tmp_path, *outcomes, expires=None, notice=None, clock=time.time, - endpoint=ENDPOINT, dialect=binding_mod.DIALECT_XFACTORY_PROMPT_V1): + endpoint=ENDPOINT, dialect=binding_mod.DIALECT_XFACTORY_PROMPT_V1, + model=None): script = _write_broker(tmp_path, expires=expires) - binding = _broker_binding(script, endpoint=endpoint, dialect=dialect) + binding = _broker_binding(script, endpoint=endpoint, dialect=dialect, + model=model) opener = _Opener(*outcomes) port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), @@ -1577,3 +1581,197 @@ def test_the_cli_declares_a_chat_binding(tmp_path, capsys): capsys.readouterr() store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) assert store.get("local-chat").dialect == OPENAI_CHAT + + +# 16.2, the model name the provider receives (T079). The record gains `model`, +# sent as the request's model and set by `model-binding add|edit --model`. The +# field list grows from nine to ten, and still no field can hold a secret. A +# binding that declares no model sends the catalog handle, its `id`, exactly as +# every request did before the field existed. + +DECLARED_MODEL = "stand-in-model-7b" + + +def test_f16_1_the_record_names_a_model(): + """F16.1's field assertion, as #1144 writes it: + `assert "model" in b.BINDING_FIELDS`. Ten fields, in their declared order, + with `model` beside the route it belongs to.""" + assert "model" in binding_mod.BINDING_FIELDS, ( + f"the record names no model: {binding_mod.BINDING_FIELDS}") + assert binding_mod.BINDING_FIELDS == ( + "id", "label", "provider", "credential_ref", "auth_kind", + "approved_by", "endpoint", "dialect", "model", "broker_argv") + assert binding_mod.OPTIONAL_BINDING_FIELDS == ("model",) + + +def test_the_model_is_keyword_only_and_undeclared_by_default(): + """Every construction written before the field existed builds the + binding it built, which declares no model.""" + import inspect + parameter = inspect.signature( + binding_mod.ModelProviderBinding).parameters["model"] + assert parameter.kind is inspect.Parameter.KEYWORD_ONLY + assert parameter.default is None + assert _binding().model is None + assert _binding(model=DECLARED_MODEL).model == DECLARED_MODEL + + +@pytest.mark.parametrize("bad", ["", " ", 7, ["a-model"]]) +def test_a_declared_model_is_non_blank_text(bad): + with pytest.raises(binding_mod.BindingRefused): + _binding(model=bad) + + +@pytest.mark.parametrize("dialect,answer,grammar_key", [ + (binding_mod.DIALECT_XFACTORY_PROMPT_V1, {"assistant_prose": "a"}, "prompt"), + (OPENAI_CHAT, _chat_completion("a"), "messages"), +]) +def test_the_declared_model_is_what_the_provider_receives(tmp_path, dialect, + answer, grammar_key): + port, opener = _port(tmp_path, answer, dialect=dialect, + model=DECLARED_MODEL) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + body = json.loads(opener.requests[0].data.decode("utf-8")) + assert body["model"] == DECLARED_MODEL + assert set(body) == {"model", grammar_key} + + +@pytest.mark.parametrize("dialect,answer", [ + (binding_mod.DIALECT_XFACTORY_PROMPT_V1, {"assistant_prose": "a"}), + (OPENAI_CHAT, _chat_completion("a")), +]) +def test_a_binding_with_no_model_still_sends_the_catalog_handle(tmp_path, + dialect, + answer): + """What every request sent before #1144 box 16.2, byte for byte.""" + port, opener = _port(tmp_path, answer, dialect=dialect) + port.dispatch(_Envelope()) + assert json.loads(opener.requests[0].data.decode("utf-8"))["model"] == \ + "openprofiler-demo" + + +def test_the_catalog_handle_stays_the_bindings_id(tmp_path): + """The model is what the PROVIDER receives. The menu's handle is still the + binding's id, so a chosen entry still resolves back to its binding.""" + binding = _binding(model=DECLARED_MODEL) + entries = install_mod.brokered_catalog(binding).entries + assert [entry.model_id for entry in entries] == [binding.id] + + +def test_a_declared_model_survives_the_expiry_retry(tmp_path): + port, opener = _port(tmp_path, _expired_error(), _chat_completion("b"), + dialect=OPENAI_CHAT, model=DECLARED_MODEL) + assert port.dispatch(_Envelope())["assistant_prose"] == "b" + assert [json.loads(request.data.decode("utf-8"))["model"] + for request in opener.requests] == [DECLARED_MODEL] * 2 + + +def test_the_model_is_not_an_argv_placeholder(): + """A broker's invocation is about custody, never about the model a turn + asks for, so the closed placeholder vocabulary does not grow.""" + assert "model" not in binding_mod.ARGV_PLACEHOLDERS + with pytest.raises(binding_mod.BindingRefused): + _binding(model=DECLARED_MODEL, + broker_argv=("openprofiler-broker", "--for", "{model}")) + + +def test_a_stored_record_carries_its_model_and_round_trips(tmp_path): + store = _store(tmp_path) + store.add(_binding(model=DECLARED_MODEL)) + store.add(_binding(id="undeclared", label="No model")) + import yaml + document = yaml.safe_load(store.path.read_text(encoding="utf-8")) + first, second = document["bindings"] + assert list(first) == ["kind", *binding_mod.BINDING_FIELDS] + assert first["model"] == DECLARED_MODEL + assert second["model"] is None, "an undeclared model is written as null" + assert store.get("openprofiler-demo").model == DECLARED_MODEL + assert store.get("undeclared").model is None + assert store.read_back()["bindings"][0]["model"] == DECLARED_MODEL + + +def test_a_record_declared_before_the_field_existed_still_reads(tmp_path): + """A nine-field record, as every stored document held until #1144 box + 16.2, reads as a binding that declares no model.""" + record = _binding().as_record() + del record["model"] + assert set(record) == {"kind", *binding_mod.BINDING_FIELDS} - {"model"} + path = tmp_path / "bindings.yaml" + path.write_text(json.dumps({"schema_version": 1, + "kind": binding_mod.BINDINGS_KIND, + "bindings": [record]}), encoding="utf-8") + (binding,) = binding_mod.BindingStore(path).list() + assert binding.model is None + assert binding == _binding() + + +def test_a_record_missing_a_required_field_still_refuses(): + """`model` is the one field a record may leave out, and only that one.""" + for field in binding_mod.BINDING_FIELDS: + if field in binding_mod.OPTIONAL_BINDING_FIELDS: + continue + record = _binding().as_record() + del record[field] + with pytest.raises(binding_mod.BindingRefused) as caught: + binding_mod.ModelProviderBinding.from_record(record) + assert field in str(caught.value) + + +def test_the_cli_sets_the_model_on_add_and_edit(tmp_path, capsys): + checkout = tmp_path / "checkout" + checkout.mkdir() + parser = cli_mod.build_parser() + + def run(*argv) -> int: + args = parser.parse_args(list(argv)) + return args.func(args) + + root = ["--repo-root", str(checkout)] + declaration = ["--id", "local-chat", "--label", "Local chat", + "--provider", "local", "--credential-ref", FAKE_REFERENCE, + "--auth-kind", "api_key", + "--credential-approver", "brett@opensoft.one", + "--endpoint", "http://127.0.0.1:9/v1/chat/completions", + "--dialect", OPENAI_CHAT] + store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) + + assert run("model-binding", "add", *root, *declaration, + "--model", DECLARED_MODEL, "--", "openprofiler-broker") == 0 + capsys.readouterr() + assert store.get("local-chat").model == DECLARED_MODEL + assert run("model-binding", "list", *root) == 0 + assert f"model {DECLARED_MODEL}" in capsys.readouterr().out + + assert run("model-binding", "edit", *root, *declaration, + "--model", "another-model", "--", "openprofiler-broker") == 0 + capsys.readouterr() + assert store.get("local-chat").model == "another-model" + + # `edit` replaces the whole binding, so an edit without `--model` declares + # none, and the list says what the request then names + assert run("model-binding", "edit", *root, *declaration, + "--", "openprofiler-broker") == 0 + capsys.readouterr() + assert store.get("local-chat").model is None + assert run("model-binding", "list", *root) == 0 + from opendox import cli_model_binding as cmb + assert cmb.NO_MODEL_DECLARED in capsys.readouterr().out + + # a blank model refuses THROUGH THE VERB, not only through the record + assert run("model-binding", "edit", *root, *declaration, + "--model", " ", "--", "openprofiler-broker") == 1 + capsys.readouterr() + assert store.get("local-chat").model is None + + +def test_a_stand_in_chat_server_receives_the_declared_model(tmp_path): + with _stand_in_provider(_ChatCompletionsHandler) as base: + binding = _broker_binding( + _write_broker(tmp_path), endpoint=f"{base}/v1/chat/completions", + dialect=OPENAI_CHAT, model=DECLARED_MODEL) + provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + notice=lambda _text: None).dispatch(_Envelope()) + assert _ChatCompletionsHandler.seen["body"] == { + "model": DECLARED_MODEL, + "messages": [{"role": "user", "content": "assembled prompt"}]} From e9ef9514a5a164812d5d11a067334b69a384f327 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:49:03 +0000 Subject: [PATCH 03/45] T080: 16.3, the credential stays a reference; a raw key is refused (plan 034) A key inside the endpoint URL is refused when a binding is declared, by the product's own detector, runtime/local_git_adapter's carries_a_credential. It runs before the scheme check, so no refusal repeats the URL. The refusal is one fixed sentence. A key in an extra field is refused as an unknown key, as it always was. Each record now has ONE resolver, and credential_source() says which: - a BROKER, for any other reference, as before; - the BUILT-IN RESOLVER, for `env:NAME` and `keyring:SERVICE/USERNAME` references (R1Q17 (b)). doxbench_provider reads the reference at call time, once per request, and keeps nothing: no mint, no ledger event, no retry on a 401. Such a record needs no broker, and a broker_argv given beside it is refused. That refusal is the plan's fail-closed reading (analyze round 2, V2-21), recorded as standing in openxFactory#656 comment 5851950767; no answer rules it. The OS keyring is read through the `keyring` package, imported at call time. It is not a dependency: without it, a keyring reference refuses with a fixed sentence. Two fixed diagnostics join (eight to ten); - NONE, for an endpoint that takes no credential: the auth kind `none` (R1Q18 (a)), under which credential_ref and broker_argv are forbidden. It joins AUTH_KINDS after api_key and oauth, so AUTH_KINDS[0] is unchanged, and its requests carry no authorization header. The record parses a reference's form, once, for both sides, and never reads what it names. A stored record may leave out the fields its resolver forbids or does not need. Each read-back and each removal states the custody sentence that is true of its resolver. The operator door takes `--auth-kind none`, an omitted `--credential-ref` and an empty broker invocation, and `set-credential` refuses a binding no broker answers without reading its standard input. Falsifier: F16.1's three refusals and its control, plus tests of the resolver and of `none`. Ruled: R1Q22 (a), openxFactory#656 comment 5817152735; R1Q17 (b) and R1Q18 (a), comment 5850003126. After: T079, and T007 (batch H), which has NOT landed on openxFactory main (e369cb25): #1144's 16.3 has no addendum yet. This is built to plan 034's text. Drafted ahead of T063 under Brett's phase-3 word ("Only the independent ones"). It does not land before T063. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/cli_model_binding.py | 75 +++- src/opendox/doxbench_binding.py | 306 ++++++++++++-- src/opendox/doxbench_provider.py | 264 +++++++++--- tests/test_model_provider_broker.py | 633 +++++++++++++++++++++++++++- 4 files changed, 1183 insertions(+), 95 deletions(-) diff --git a/src/opendox/cli_model_binding.py b/src/opendox/cli_model_binding.py index f5c15ad9..51a9709e 100644 --- a/src/opendox/cli_model_binding.py +++ b/src/opendox/cli_model_binding.py @@ -51,7 +51,7 @@ def _declared_binding(args: argparse.Namespace) -> "binding_mod.ModelProviderBin credential_ref=args.credential_ref, auth_kind=args.auth_kind, approved_by=args.approved_by, endpoint=args.endpoint, dialect=args.dialect, model=args.model, - broker_argv=tuple(args.broker_argv)) + broker_argv=tuple(args.broker_argv or ())) #: What `list` prints for a binding that declares no model (#1144 box 16.2). @@ -59,6 +59,19 @@ def _declared_binding(args: argparse.Namespace) -> "binding_mod.ModelProviderBin #: field existed, and the operator reading the list should see that. NO_MODEL_DECLARED = "(none declared: the request names this binding's id)" +#: What `list` prints for a field the record's resolver forbids or does not +#: need (#1144 box 16.3): the reference under the auth kind `none`, and the +#: broker invocation of a record no broker answers. The custody line beside it +#: says which resolver answers instead. +NOT_DECLARED = "(none)" + +#: What `set-credential` says of a binding no broker answers (#1144 box 16.3). +#: There is no broker to hand a credential to: the built-in resolver reads the +#: reference at call time, or the endpoint takes none. +NO_BROKER_TO_HAND_TO = ( + "binding {binding_id!r} names no broker, so there is nothing to hand a " + "credential to: {custody}") + def cmd_model_binding_list(args: argparse.Namespace) -> int: """DISCLOSE every declared binding (task 1.2's read-back). @@ -81,13 +94,16 @@ def cmd_model_binding_list(args: argparse.Namespace) -> int: print(f" provider {record['provider']}") print(f" auth kind {record['auth_kind']}") print(f" approved by {record['approved_by']}") - print(f" credential ref {record['credential_ref']}") + reference = record["credential_ref"] + print(f" credential ref " + f"{reference if reference is not None else NOT_DECLARED}") print(f" endpoint {record['endpoint']}") print(f" dialect {record['dialect']}") model = record["model"] print(f" model " f"{model if model is not None else NO_MODEL_DECLARED}") - print(f" broker argv {record['broker_argv']}") + argv = record["broker_argv"] + print(f" broker argv {argv if argv else NOT_DECLARED}") print(f" custody {record['credential_custody']}") return 0 @@ -100,7 +116,7 @@ def cmd_model_binding_add(args: argparse.Namespace) -> int: print(str(exc), file=sys.stderr) return 1 print(f" declared {binding.id} in {store.path}") - print(f" {binding_mod.CUSTODY_NOTICE}") + print(f" {binding.custody_notice()}") return 0 @@ -123,7 +139,7 @@ def cmd_model_binding_remove(args: argparse.Namespace) -> int: print(str(exc), file=sys.stderr) return 1 print(f" retired {binding.id} from {store.path}") - print(f" {binding_mod.REMOVAL_NOTICE}") + print(f" {binding.removal_notice()}") return 0 @@ -137,7 +153,12 @@ def cmd_model_binding_set_credential(args: argparse.Namespace, *, standard input. No variable in this function ever holds the credential, so none can outlive the call, be echoed in a message, or reach an exception. It is deliberately NOT a command-line argument: an argv is visible in the - process table and lands in a shell history.""" + process table and lands in a shell history. + + A BINDING NO BROKER ANSWERS IS REFUSED, and its standard input is left + unread (#1144 box 16.3). Its credential is where its `env:` or `keyring:` + reference names, or it takes none, so there is no custodian to hand a + value to, and reading one here would be holding it for nothing.""" from opendox import doxbench_provider as provider_mod store = _binding_store(args) @@ -146,6 +167,10 @@ def cmd_model_binding_set_credential(args: argparse.Namespace, *, if binding is None: raise binding_mod.BindingRefused( f"no binding with id {args.id!r} is declared") + if (binding.credential_source() + != binding_mod.CREDENTIAL_FROM_BROKER): + raise binding_mod.BindingRefused(NO_BROKER_TO_HAND_TO.format( + binding_id=binding.id, custody=binding.custody_notice())) reference = provider_mod.hand_off_credential( binding, source if source is not None else sys.stdin) store.edit(dataclasses.replace(binding, credential_ref=reference)) @@ -171,15 +196,25 @@ def _add_binding_declaration_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--label", required=True, help="the label the model menu shows") parser.add_argument("--provider", required=True, - help="the provider name the broker takes custody for " - "(the broker's declared `--provider`)") - parser.add_argument("--credential-ref", required=True, + help="the provider's name; where a broker holds the " + "credential, the one it takes custody for (the " + "broker's declared `--provider`)") + # A REFERENCE, NEVER THE CREDENTIAL (#1144 box 16.3). NOT REQUIRED by the + # parser, because the auth kind `none` forbids it. The binding itself + # refuses a missing reference for every kind that takes a credential, and + # says why. + parser.add_argument("--credential-ref", default=None, dest="credential_ref", - help="the reference the broker resolves; NEVER the " - "credential itself") + help="the credential's REFERENCE, NEVER the credential " + "itself: env:NAME or keyring:SERVICE/USERNAME, " + "which the built-in resolver reads at call time, " + "or a reference the broker resolves; omitted " + f"for --auth-kind {binding_mod.AUTH_KIND_NONE}") parser.add_argument("--auth-kind", required=True, dest="auth_kind", choices=list(binding_mod.AUTH_KINDS), - help="the authentication kind the broker holds") + help="the authentication kind of the credential; " + f"{binding_mod.AUTH_KIND_NONE} for an endpoint " + "that takes none") # REQUIRED because the broker requires it: `credential-contracts` holds # that a grant without an approver is invalid, and the broker's `intake` # refuses without an approver flag. A binding that could not name one could @@ -208,8 +243,10 @@ def _add_binding_declaration_args(parser: argparse.ArgumentParser) -> None: # neither an endpoint nor a dialect from a mint, deliberately, so both are # declared here — see doxbench_binding's module docstring. parser.add_argument("--endpoint", required=True, - help="the provider endpoint this binding's minted " - "token is presented at") + help="the provider endpoint this binding's requests " + "are sent to; a URL carrying a credential is " + "refused, so name the credential by its reference " + "instead") parser.add_argument("--dialect", required=True, choices=list(binding_mod.DIALECTS), help="the request grammar that endpoint speaks") @@ -230,11 +267,17 @@ def _add_binding_declaration_args(parser: argparse.ArgumentParser) -> None: # rewriting the operator's store path and truncating their template. The # subparsers below also set `allow_abbrev=False`, so the two defences are # independent. + # + # ZERO OR MORE since #1144 box 16.3: a binding the built-in resolver or the + # auth kind `none` answers names no broker, and one given beside either is + # refused by the binding itself. A broker's reference still needs its + # invocation, and the binding still refuses one without it. parser.add_argument( - "broker_argv", nargs="+", metavar="-- BROKER ARGV", + "broker_argv", nargs="*", metavar="-- BROKER ARGV", help="the broker invocation, as argv members, after a bare `--`. " f"Placeholders {binding_mod.ARGV_PLACEHOLDERS} are filled from " - "this binding's own fields") + "this binding's own fields. Omitted for an env: or keyring: " + f"reference and for --auth-kind {binding_mod.AUTH_KIND_NONE}") def _add_model_binding_parser(sub) -> None: diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index c76ca89a..4ac3abc6 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -18,10 +18,30 @@ not a validator that a later edit could soften. WHAT IS NOT IN THIS MODULE, deliberately: the broker itself, the credential -hand-off, the minted token, and every provider TRANSPORT. All four live in -`doxbench_provider`, the ONE module this repository permits to hold them, and -the structural boundary test names that module by name. This one holds records -and a file, reaches no network, spawns no process, and never sees a credential. +hand-off, the minted token, the built-in resolver, and every provider +TRANSPORT. All five live in `doxbench_provider`, the ONE module this repository +permits to hold them, and the structural boundary test names that module by +name. This one holds records and a file, reaches no network, spawns no process, +and never sees a credential. + +THE CREDENTIAL STAYS A REFERENCE, AND A KEY IS REFUSED WHEN IT IS DECLARED +(#1144 box 16.3; plan 034 T080). A key inside the endpoint URL is refused by the +product's own detector, `runtime/local_git_adapter.carries_a_credential`, and +the refusal never repeats the URL it refused. A key in an extra field is +refused as an unknown key, as it always was. EACH RECORD HAS ONE RESOLVER, and +the record says which: + + * the BROKER the record names, for any other reference (as before); + * the BUILT-IN RESOLVER, for an `env:NAME` or `keyring:SERVICE/USERNAME` + reference. It reads the reference at call time, inside `doxbench_provider` + only (RULED R1Q17 (b)). Such a record needs no broker, and one given + beside it is refused, so no record has two resolvers; + * NONE, for an endpoint that takes no credential. It declares the auth kind + `none` rather than leaving a field out, and `credential_ref` and + `broker_argv` are forbidden under it (RULED R1Q18 (a)). + +This module classifies a reference's FORM when a binding is declared. It never +reads what a reference names. IT DOES NOW DECLARE THE PROVIDER ROUTE, and that is a reconciliation rather than a widening (task 2.6, 0.2 FINDING 3). The binding carries `endpoint` and @@ -59,6 +79,7 @@ from __future__ import annotations import dataclasses +import re from collections.abc import Iterable, Mapping from pathlib import Path @@ -82,17 +103,62 @@ # the closed vocabularies # --------------------------------------------------------------------------- -#: A long-lived API key the broker takes custody of. +#: A long-lived API key. The broker the binding names takes custody of it, or, +#: for an `env:` or `keyring:` reference, the built-in resolver reads it at call +#: time (#1144 box 16.3, RULED R1Q17 (b)). AUTH_KIND_API_KEY = "api_key" #: An OAuth grant the broker holds and refreshes. AUTH_KIND_OAUTH = "oauth" +#: An endpoint that takes NO credential, which is the usual local server (#1144 +#: box 16.3; RULED R1Q18 (a), openxFactory#656 comment 5850003126). It says so +#: EXPLICITLY, as a kind, and never by a field left out. Under it +#: `credential_ref` and `broker_argv` are FORBIDDEN: there is no credential to +#: refer to and no broker to hold one. +AUTH_KIND_NONE = "none" + #: The CLOSED authentication-kind vocabulary. Closed because a free-text kind #: riding a record that neither declares nor forbids it is unenforceable and #: invisible to every consumer — the same argument `credential-contracts` makes #: for its own `issuance_preconditions` vocabulary. -AUTH_KINDS: tuple[str, ...] = (AUTH_KIND_API_KEY, AUTH_KIND_OAUTH) +#: +#: THREE MEMBERS, and the ORDER IS PINNED: `none` joins AFTER the two kinds that +#: take a credential (R1Q18 (a)), so `AUTH_KINDS[0]`, which #1144's F16.1 reads, +#: still names a kind that takes one. +AUTH_KINDS: tuple[str, ...] = (AUTH_KIND_API_KEY, AUTH_KIND_OAUTH, + AUTH_KIND_NONE) + +#: THE BUILT-IN RESOLVER'S REFERENCE FORMS (#1144 box 16.3; RULED R1Q17 (b)). A +#: standalone install has no broker to resolve a reference, so a reference in +#: one of these forms is resolved by a resolver built into `doxbench_provider`, +#: at call time, in that module only. This module holds the FORMS, as it holds +#: the dialects: it classifies a reference when a binding is declared, and +#: never reads what the reference names. +#: +#: * `env:NAME` — the environment variable NAME of the serving process. NAME +#: is a portable variable name: a letter or `_`, then letters, digits or +#: `_`; +#: * `keyring:SERVICE/USERNAME` — the OS keyring's entry for that service and +#: user name. The split is at the LAST `/`, so a service name may contain +#: one, and a user name may not. +#: +#: Any other reference is a BROKER's, as every reference was before 16.3. +CREDENTIAL_REF_ENV = "env:" +CREDENTIAL_REF_KEYRING = "keyring:" +BUILT_IN_REFERENCE_FORMS: tuple[str, ...] = (CREDENTIAL_REF_ENV, + CREDENTIAL_REF_KEYRING) + +_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + +#: The three answers to "what resolves this record's credential", one per +#: record (see the module docstring). `ModelProviderBinding.credential_source` +#: returns one of them. +CREDENTIAL_FROM_BROKER = "broker" +CREDENTIAL_FROM_BUILT_IN_RESOLVER = "built-in-resolver" +NO_CREDENTIAL = "no-credential" +CREDENTIAL_SOURCES: tuple[str, ...] = ( + CREDENTIAL_FROM_BROKER, CREDENTIAL_FROM_BUILT_IN_RESOLVER, NO_CREDENTIAL) #: The CLOSED dialect vocabulary a binding may declare — the request grammar the #: provider client speaks at the declared endpoint. CLOSED rather than open @@ -127,6 +193,35 @@ #: host is not something a provider client should discover at dispatch time. ENDPOINT_SCHEMES: tuple[str, ...] = ("https://", "http://") +#: The refusal a key inside the endpoint URL earns (#1144 box 16.3). Measured +#: before 16.3: this record checked the endpoint's scheme and nothing else, so +#: `https://user:@…` and `…?api_key=` were both ACCEPTED, into a file +#: this module calls safe to commit. A FIXED sentence, composed from nothing +#: the operator typed: the URL it refuses carries the key, and a refusal that +#: repeated the URL would print the key to a terminal, a log, or, through the +#: console's intake route, a browser. +ENDPOINT_CARRIES_A_CREDENTIAL = ( + "the endpoint carries a credential (a user name or password in the URL, or " + "a credential-shaped query or fragment parameter), and a binding is safe to " + "commit only because it holds none; declare the endpoint without it, and " + "name the credential by its reference in credential_ref") + + +def _carries_a_credential(text: str) -> bool: + """The product's ONE detector, `runtime/local_git_adapter. + carries_a_credential`, which #1144 box 16.3 names: it flags a URL with + userinfo and a URL with a credential-shaped parameter, and passes a clean + one. + + Asked here rather than re-derived, so the record and the repository act + cannot disagree about the same bytes. Imported where it is asked, as + `authoring.py` imports from the same module, so this module stays light at + import time. `local_git_adapter` is stdlib-only by the runtime package's + own import-weight contract, so the lean hosted image imports it too.""" + from opendox.runtime.local_git_adapter import carries_a_credential + + return carries_a_credential(text) + #: The exact, ordered field list a binding declares. Nothing else may appear in #: a stored record, and nothing else appears in a read-back. #: @@ -156,10 +251,12 @@ "broker_argv", ) -#: The one field a stored record may leave out. A record without `model` was +#: The one field EVERY stored record may leave out. A record without `model` was #: declared before the field existed, and it keeps the meaning it had: the -#: request names the catalog handle, this binding's `id`. Every other field is -#: required, as it always was. +#: request names the catalog handle, this binding's `id`. A record may also +#: leave out the fields its own resolver forbids or does not need (#1144 box +#: 16.3; `_fields_its_resolver_leaves_out`). Every other field is required, as +#: it always was. OPTIONAL_BINDING_FIELDS: tuple[str, ...] = ("model",) #: The CLOSED placeholder vocabulary an argv template may name. Every member is @@ -183,6 +280,20 @@ "the credential itself is held by the broker this binding names; this " "dashboard stores only the reference above and can disclose nothing more") +#: The same sentence for a record the BUILT-IN RESOLVER answers (#1144 box +#: 16.3). The broker sentence would be false there, and a read-back states +#: custody PLAINLY, so each resolver has its own sentence. +BUILT_IN_CUSTODY_NOTICE = ( + "the credential itself stays where the reference above names, in this " + "process's environment or the OS keyring; it is read at call time for each " + "request and never stored, and this dashboard stores only the reference") + +#: ...and for a record whose endpoint takes no credential (the auth kind +#: `none`). +NO_CREDENTIAL_NOTICE = ( + "this endpoint takes no credential (auth kind none), so there is nothing " + "to hold and nothing to disclose") + #: Where a checkout's bindings live when nothing said otherwise. Beside the gate #: records, under the served checkout, because a binding IS safe to commit and #: an operator reading their repository should be able to see what their install @@ -207,6 +318,59 @@ def _require_non_blank_str(field: str, value: object) -> str: return value +def names_a_built_in_form(credential_ref: object) -> bool: + """Whether a reference is in one of `BUILT_IN_REFERENCE_FORMS`, by its + prefix alone (#1144 box 16.3). A reference that is not is a broker's.""" + return (isinstance(credential_ref, str) + and credential_ref.startswith(BUILT_IN_REFERENCE_FORMS)) + + +def built_in_reference_parts(credential_ref: str) -> tuple[str, ...] | None: + """A reference the built-in resolver takes, split into what it looks up: + `("env:", NAME)` or `("keyring:", SERVICE, USERNAME)`. None for a broker's + reference. + + ONE PARSER, which the record calls when a binding is declared and + `doxbench_provider`'s resolver calls at call time, so the two cannot + disagree about a reference's form. A reference that names a built-in form + but is malformed is REFUSED, at declaration. The refusal does not repeat + the reference: a key pasted where its reference belongs would otherwise be + printed by the very check that refused it.""" + if credential_ref.startswith(CREDENTIAL_REF_ENV): + name = credential_ref[len(CREDENTIAL_REF_ENV):] + if not _ENV_NAME.fullmatch(name): + raise BindingRefused( + "credential_ref uses the env: form, and what follows env: is " + "not an environment variable name (a letter or _, then " + "letters, digits or _)") + return (CREDENTIAL_REF_ENV, name) + if credential_ref.startswith(CREDENTIAL_REF_KEYRING): + service, separator, username = ( + credential_ref[len(CREDENTIAL_REF_KEYRING):].rpartition("/")) + if not separator or not service.strip() or not username.strip(): + raise BindingRefused( + "credential_ref uses the keyring: form, and it does not read " + "keyring:SERVICE/USERNAME with both parts present") + return (CREDENTIAL_REF_KEYRING, service, username) + return None + + +def _fields_its_resolver_leaves_out(record: Mapping) -> set[str]: + """The fields a stored record may leave out because its own resolver + forbids or does not need them (#1144 box 16.3). Under the auth kind `none` + these are `credential_ref` and `broker_argv`. Beside a reference the + built-in resolver takes, it is `broker_argv`. + + Leaving a field out is not declaring it, which is why a record may. A + record that DECLARES one is refused by the binding itself, which is the + rule. This only says which absences are lawful.""" + if record.get("auth_kind") == AUTH_KIND_NONE: + return {"credential_ref", "broker_argv"} + if names_a_built_in_form(record.get("credential_ref")): + return {"broker_argv"} + return set() + + @dataclasses.dataclass(frozen=True, slots=True) class ModelProviderBinding: """ONE model provider, as settings hold it. @@ -224,12 +388,19 @@ class ModelProviderBinding: That binding keeps its old meaning: with no model declared, the request names the catalog handle, which is the binding's `id`, exactly as before. A declared model is a non-blank string. + + ONE RESOLVER PER RECORD (#1144 box 16.3; see the module docstring). Under + the auth kind `none`, `credential_ref` is None and `broker_argv` is empty, + and giving either is refused. A reference the built-in resolver takes + needs no broker, so `broker_argv` is empty there, and a broker given + beside it is refused. Any other reference is a broker's, and it needs its + `broker_argv`, as it always did. """ id: str label: str provider: str - credential_ref: str + credential_ref: str | None auth_kind: str approved_by: str endpoint: str @@ -238,8 +409,8 @@ class ModelProviderBinding: broker_argv: tuple[str, ...] def __post_init__(self) -> None: - for field in ("id", "label", "provider", "credential_ref", "auth_kind", - "approved_by", "endpoint", "dialect"): + for field in ("id", "label", "provider", "auth_kind", "approved_by", + "endpoint", "dialect"): _require_non_blank_str(field, getattr(self, field)) if self.model is not None: _require_non_blank_str("model", self.model) @@ -252,6 +423,10 @@ def __post_init__(self) -> None: f"dialect {self.dialect!r} is outside the closed vocabulary " f"{DIALECTS}; an unknown request grammar is refused at " "DECLARATION rather than guessed at on a paid call") + # A KEY INSIDE THE URL IS REFUSED FIRST (#1144 box 16.3), so no later + # refusal, the scheme's among them, can repeat a URL that carries one. + if _carries_a_credential(self.endpoint): + raise BindingRefused(ENDPOINT_CARRIES_A_CREDENTIAL) if not self.endpoint.startswith(ENDPOINT_SCHEMES): raise BindingRefused( f"endpoint {self.endpoint!r} does not name one of " @@ -268,10 +443,6 @@ def __post_init__(self) -> None: "broker_argv must be an iterable of argv members, got " f"{type(self.broker_argv).__name__}") from error object.__setattr__(self, "broker_argv", argv) - if not argv: - raise BindingRefused( - "broker_argv must name the broker command; an empty invocation " - "is a binding that can never mint") for member in argv: _require_non_blank_str("broker_argv member", member) for name in _placeholder_names(member): @@ -279,14 +450,84 @@ def __post_init__(self) -> None: raise BindingRefused( f"broker_argv names the placeholder {{{name}}}, which " f"is outside the closed vocabulary {ARGV_PLACEHOLDERS}") + self._require_one_resolver(argv) + + def _require_one_resolver(self, argv: tuple[str, ...]) -> None: + """#1144 box 16.3: exactly one thing answers this record's credential. + + THE `none` HALF IS RULED (R1Q18 (a)): an endpoint that takes no + credential declares `none`, and under it the reference and the broker + are both forbidden. THE BUILT-IN HALF is RULED in part: R1Q17 (b) says + a record whose reference the built-in resolver takes needs no broker. + Refusing a broker given BESIDE such a reference is plan 034's own + fail-closed reading, which no answer rules (its analyze round 2, + V2-21), and openxFactory#656 comment 5851950767 records it as standing, + not overruled. A record with two resolvers would leave which one + answers to whoever reads it next.""" + if self.auth_kind == AUTH_KIND_NONE: + if self.credential_ref is not None: + raise BindingRefused( + f"auth_kind {AUTH_KIND_NONE!r} declares an endpoint that " + "takes no credential, so credential_ref is forbidden " + "under it") + if argv: + raise BindingRefused( + f"auth_kind {AUTH_KIND_NONE!r} declares an endpoint that " + "takes no credential, so broker_argv is forbidden under " + "it: there is no credential for a broker to hold") + return + if self.credential_ref is None: + raise BindingRefused( + f"auth_kind {self.auth_kind!r} takes a credential, so " + "credential_ref must name its reference; an endpoint that " + f"takes no credential declares the auth kind " + f"{AUTH_KIND_NONE!r} instead") + _require_non_blank_str("credential_ref", self.credential_ref) + if built_in_reference_parts(self.credential_ref) is not None: + if argv: + raise BindingRefused( + "credential_ref is a reference the built-in resolver " + "takes, so this binding needs no broker; a broker_argv " + "beside it would give one record two resolvers") + return + if not argv: + raise BindingRefused( + "broker_argv must name the broker command; an empty invocation " + "is a binding that can never mint") + + def credential_source(self) -> str: + """Which of `CREDENTIAL_SOURCES` answers this record's credential: the + broker it names, the built-in resolver, or nothing (the auth kind + `none`). `doxbench_provider` routes a turn by it, and the read-back + states custody by it.""" + if self.auth_kind == AUTH_KIND_NONE: + return NO_CREDENTIAL + if names_a_built_in_form(self.credential_ref): + return CREDENTIAL_FROM_BUILT_IN_RESOLVER + return CREDENTIAL_FROM_BROKER + + def custody_notice(self) -> str: + """The fixed custody sentence that is TRUE of this record.""" + return {CREDENTIAL_FROM_BROKER: CUSTODY_NOTICE, + CREDENTIAL_FROM_BUILT_IN_RESOLVER: BUILT_IN_CUSTODY_NOTICE, + NO_CREDENTIAL: NO_CREDENTIAL_NOTICE}[self.credential_source()] + + def removal_notice(self) -> str: + """The fixed sentence a removal of this record states.""" + return {CREDENTIAL_FROM_BROKER: REMOVAL_NOTICE, + CREDENTIAL_FROM_BUILT_IN_RESOLVER: BUILT_IN_REMOVAL_NOTICE, + NO_CREDENTIAL: NO_CREDENTIAL_REMOVAL_NOTICE, + }[self.credential_source()] # -- projections -------------------------------------------------------- def as_record(self) -> dict: """The STORED record: the record kind, then exactly ``BINDING_FIELDS`` in order. `broker_argv` becomes a list because that is what YAML round - trips. An undeclared `model` is written as null, so every stored - record carries all ten keys; nothing else changes shape.""" + trips. An undeclared `model` is written as null, as is the absent + `credential_ref` of an auth-kind-`none` record, and a record that + names no broker writes an empty `broker_argv`. So every stored record + carries all ten keys; nothing else changes shape.""" return { "kind": BINDING_KIND, "id": self.id, @@ -308,9 +549,11 @@ def as_read_back(self) -> dict: There is no credential material to redact here, which is the whole claim: a read-back cannot leak a secret it was never able to hold. The sentence says so in words rather than leaving the absence to be - inferred from a missing key.""" + inferred from a missing key. It is the sentence that is true of THIS + record's resolver (#1144 box 16.3): the broker's, the built-in + resolver's, or none.""" disclosed = self.as_record() - disclosed["credential_custody"] = CUSTODY_NOTICE + disclosed["credential_custody"] = self.custody_notice() return disclosed def substituted_argv(self) -> tuple[str, ...]: @@ -362,23 +605,27 @@ def from_record(cls, record: object) -> "ModelProviderBinding": raise BindingRefused( f"a binding record declares kind {declared_kind!r}, not " f"{BINDING_KIND!r}") + may_leave_out = {*OPTIONAL_BINDING_FIELDS, + *_fields_its_resolver_leaves_out(record)} missing = [field for field in BINDING_FIELDS - if field not in record - and field not in OPTIONAL_BINDING_FIELDS] + if field not in record and field not in may_leave_out] if missing: raise BindingRefused( f"a binding record is missing {missing}") + broker_argv = record.get("broker_argv") return cls( id=record["id"], label=record["label"], provider=record["provider"], - credential_ref=record["credential_ref"], + credential_ref=record.get("credential_ref"), auth_kind=record["auth_kind"], approved_by=record["approved_by"], endpoint=record["endpoint"], dialect=record["dialect"], model=record.get("model"), - broker_argv=record["broker_argv"], + # an absent or null `broker_argv` names no broker. Whether that is + # lawful is the binding's own rule (`_require_one_resolver`) + broker_argv=() if broker_argv is None else broker_argv, ) @@ -591,6 +838,17 @@ def _save(self, bindings: Iterable[ModelProviderBinding]) -> None: "the binding is retired from this checkout; the credential it referenced " "remains in the broker's custody and is not revoked by this act") +#: The removal sentence for a record the built-in resolver answers, and for one +#: that takes no credential (#1144 box 16.3). As with custody, each resolver has +#: its own sentence, so no removal says something that is not so. +BUILT_IN_REMOVAL_NOTICE = ( + "the binding is retired from this checkout; the credential its reference " + "named stays where it is, in the environment or the OS keyring, and is not " + "removed by this act") +NO_CREDENTIAL_REMOVAL_NOTICE = ( + "the binding is retired from this checkout; it referred to no credential, " + "so there is nothing to revoke") + def bindings_path(checkout_root: Path | str) -> Path: """The default store path for a checkout. ONE rule, so two entrypoints diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 9317a995..201c1b61 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -38,6 +38,12 @@ grammar the binding may declare has one arm here (`_DIALECT_ARMS`): this repository's own prompt grammar, and the OpenAI-compatible chat-completions grammar (#1144 box 16.1); + * OR NO BROKER AT ALL (#1144 box 16.3). A record whose reference is + `env:NAME` or `keyring:SERVICE/USERNAME` is answered by the BUILT-IN + RESOLVER below, at call time and in this module only (RULED R1Q17 (b)). + The reference is read for the one request that presents it, and nothing + is minted, cached or stored. A record with the auth kind `none` presents + no credential at all (RULED R1Q18 (a)); * EXPIRY is handled by the 2026-08-26 ruling: re-mint and retry ONCE, with the re-mint and the paid retry visibly recorded, and a second expiry inside one turn surfaces the standard refusal rather than buying a third call. The @@ -47,10 +53,15 @@ WHAT NEVER HAPPENS HERE: - * a credential is never held. `hand_off_credential` streams the human's value - from an open source straight into the broker's standard input and never - materialises it as a value of its own — no variable that outlives the call, - no file, no echo in a return value, and nothing in any exception; + * a credential is never held beyond the one call that uses it. + `hand_off_credential` streams the human's value from an open source + straight into the broker's standard input and never materialises it as a + value of its own — no variable that outlives the call, no file, no echo in + a return value, and nothing in any exception. The built-in resolver's + value, the one exception 16.3 makes, is read at call time, presented in + that request's authorization header, and dropped with the call. It is + never cached on the port, written, logged, returned, or put in an + exception; * a minted token is never written to a file, never placed in a response, never logged, and never survives the process. `MintedToken` carries a redacting `__repr__`, so even a traceback frame or a debugger `print` of the @@ -230,18 +241,30 @@ "the minted token expired twice within one turn; a further paid call is " "not made on a turn that has already been retried once") +#: The built-in resolver's two refusals (#1144 box 16.3). Fixed, like every +#: other sentence here: they name no variable, no service and no user, so a +#: reference an operator mistyped is not repeated wherever the refusal goes. +DIAG_REFERENCE_UNRESOLVED = ( + "the credential reference resolved to no usable credential, so none was " + "presented") +DIAG_KEYRING_UNAVAILABLE = ( + "the OS keyring could not be read by this process, so the keyring " + "reference could not be resolved") + #: The closed set, so a test can assert no other sentence can be raised. -#: EIGHT, not the nine this set held before the reconciliation. -#: `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded moved: the dialect -#: is the BINDING's, validated against the closed vocabulary when the operator -#: declares it (`doxbench_binding.ModelProviderBinding.__post_init__`), so an -#: unknown grammar can no longer reach a mint. Keeping a sentence here that no -#: path can raise would be a refusal nobody can trigger, asserted by a test that -#: proves nothing. +#: TEN: the eight the reconciliation left, and the built-in resolver's two +#: (#1144 box 16.3). `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded +#: moved: the dialect is the BINDING's, validated against the closed vocabulary +#: when the operator declares it +#: (`doxbench_binding.ModelProviderBinding.__post_init__`), so an unknown +#: grammar can no longer reach a mint. Keeping a sentence here that no path can +#: raise would be a refusal nobody can trigger, asserted by a test that proves +#: nothing. FIXED_DIAGNOSTICS: frozenset[str] = frozenset({ DIAG_BROKER_UNREACHABLE, DIAG_BROKER_REFUSED, DIAG_BROKER_MALFORMED, DIAG_BROKER_TIMEOUT, DIAG_PROVIDER_UNREACHABLE, DIAG_PROVIDER_REFUSED, DIAG_PROVIDER_MALFORMED, DIAG_TOKEN_EXPIRED_TWICE, + DIAG_REFERENCE_UNRESOLVED, DIAG_KEYRING_UNAVAILABLE, }) @@ -455,11 +478,22 @@ def broker_operation_argv(binding, operation: str, *, careful: every value comes from a field of the binding, the binding has no secret field to read, and the declaration refuses a credential-shaped flag on every command with its own `secret_in_argv` code. Two independent - refusals, agreeing.""" + refusals, agreeing. + + A BINDING THAT NAMES NO BROKER HAS NO BROKER OPERATION (#1144 box 16.3). + The built-in resolver or the auth kind `none` answers it, and its empty + base invocation would otherwise run the SUBCOMMAND as a program. So that + is a programming error here, like an undeclared operation. The operator + door refuses it in words first (`cli_model_binding`).""" if operation not in OPERATIONS: raise AssertionError( f"{operation!r} is outside the broker's declared operation " f"vocabulary {OPERATIONS}") + if binding.credential_source() != binding_mod.CREDENTIAL_FROM_BROKER: + raise AssertionError( + f"binding {binding.id!r} names no broker, so it has no broker " + "operation: the built-in resolver or the auth kind " + f"{binding_mod.AUTH_KIND_NONE!r} answers it") argv = list(binding.substituted_argv()) if operation == OPERATION_INTAKE: argv += [OPERATION_INTAKE, @@ -616,6 +650,74 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: return references +# --------------------------------------------------------------------------- +# the built-in resolver (#1144 box 16.3; RULED R1Q17 (b), `5850003126`) +# --------------------------------------------------------------------------- + +#: What a resolved value may not carry. A line break or a NUL cannot travel in +#: a request header as it is, and trimming one out would present a credential +#: other than the one the reference names, so such a value is refused. +_UNPRESENTABLE_CHARACTERS = ("\r", "\n", "\x00") + + +def _os_keyring(): + """The OS keyring, through the `keyring` package, imported at call time. + + NOT A DEPENDENCY OF THIS PACKAGE, and that is deliberate. An install that + never names a keyring reference never needs it, and one that does installs + it beside openDox. Without it, a keyring reference refuses with the fixed + `DIAG_KEYRING_UNAVAILABLE` rather than raising an import error out of a + turn.""" + try: + import keyring + except ImportError: + raise BrokerRefused(DIAG_KEYRING_UNAVAILABLE) from None + return keyring + + +def resolve_credential_reference(binding, *, environ=None, + keyring_backend=None) -> str: + """THE BUILT-IN RESOLVER: the credential an `env:NAME` or + `keyring:SERVICE/USERNAME` reference names, read NOW. + + AT CALL TIME, IN THIS MODULE ONLY (R1Q17 (b)). The port calls it once per + request and hands the answer straight to `_post_to_provider`, and nothing + keeps it. So a key rotated in the keyring is the key the next request + presents, and no credential lives on the port between turns. The + reference's FORM is parsed by `doxbench_binding.built_in_reference_parts`, + which the record already ran when the binding was declared, so the two + cannot disagree about it. + + `environ` and `keyring_backend` are seams for tests. Production passes + neither, and so reads this process's own environment and the OS keyring. + + Every failure is a FIXED refusal, raised before any provider is contacted. + An unset or blank variable, an absent keyring entry, or a value that could + not travel in a header is `DIAG_REFERENCE_UNRESOLVED`. A keyring that + cannot be read is `DIAG_KEYRING_UNAVAILABLE`. A keyring backend's own error + is dropped unread, like a broker's or a provider's.""" + parts = binding_mod.built_in_reference_parts(binding.credential_ref) + if parts is None: + raise AssertionError( + f"binding {binding.id!r} names a broker's reference, which the " + "broker resolves; the built-in resolver takes only the " + f"{binding_mod.BUILT_IN_REFERENCE_FORMS} forms") + if parts[0] == binding_mod.CREDENTIAL_REF_ENV: + value = (os.environ if environ is None else environ).get(parts[1]) + else: + backend = (keyring_backend if keyring_backend is not None + else _os_keyring()) + try: + value = backend.get_password(parts[1], parts[2]) + except Exception: # noqa: BLE001 - a keyring backend's own error, of any class, never reaches a caller + raise BrokerRefused(DIAG_KEYRING_UNAVAILABLE) from None + if (not isinstance(value, str) or not value.strip() + or any(character in value + for character in _UNPRESENTABLE_CHARACTERS)): + raise BrokerRefused(DIAG_REFERENCE_UNRESOLVED) + return value + + # --------------------------------------------------------------------------- # the provider transport # --------------------------------------------------------------------------- @@ -704,22 +806,26 @@ def _chat_answer(document: dict) -> str: } -def _post_to_provider(token: MintedToken, *, model: str, prompt: str, - timeout: float, opener) -> str: +def _post_to_provider(*, endpoint: str, dialect: str, credential: str | None, + model: str, prompt: str, timeout: float, opener) -> str: """The ONE place a provider is contacted. Returns the assistant prose. - `model` is the model name the request carries, which the port chose (the - binding's declared `model`, or the catalog handle for a binding that - declares none). + `endpoint` and `dialect` are the binding's route. `model` is the model + name the request carries, which the port chose (the binding's declared + `model`, or the catalog handle for a binding that declares none). + `credential` is what this request presents (#1144 box 16.3): the token a + broker minted (a `MintedToken`'s), the value the built-in resolver read for + this call, or None under the auth kind `none`. With None the request + carries no authorization header at all. - The token travels in the request's authorization header and nowhere else; - it is not in the URL (which a proxy logs), not in the body (which an error - handler might echo), and not in this function's return value. + The credential travels in the request's authorization header and nowhere + else; it is not in the URL (which a proxy logs), not in the body (which an + error handler might echo), and not in this function's return value. - THE GRAMMAR IS THE BINDING'S DIALECT (#1144 box 16.1), which the token - carries from the binding. Its arm in `_DIALECT_ARMS` builds the request - body and reads the answer. The route, the header, the bound, the expiry - status and every refusal below are the same for both dialects. + THE GRAMMAR IS THE BINDING'S DIALECT (#1144 box 16.1). Its arm in + `_DIALECT_ARMS` builds the request body and reads the answer. The route, + the header, the bound, the expiry status and every refusal below are the + same for both dialects. THE ANSWER IS BOUNDED (PR #392 review note b). `response.read()` with no argument reads until the peer stops sending, which makes the memory of this @@ -728,18 +834,19 @@ def _post_to_provider(token: MintedToken, *, model: str, prompt: str, byte over `MAX_PROVIDER_ANSWER_BYTES` is read deliberately, so an answer that is exactly at the bound is still honoured while one past it is detected rather than truncated into a shorter document that would parse.""" - arm = _DIALECT_ARMS.get(token.dialect) + arm = _DIALECT_ARMS.get(dialect) if arm is None: raise AssertionError( - f"{token.dialect!r} is outside the declared dialect vocabulary " + f"{dialect!r} is outside the declared dialect vocabulary " f"{DIALECTS}; the binding refuses it at declaration, so no turn " "can carry one") build_request, read_answer = arm body = json.dumps(build_request(model, prompt)).encode("utf-8") - request = urllib.request.Request( # noqa: S310 - endpoint declared on the binding by its operator, carried on the minted token - token.endpoint, data=body, method="POST") + request = urllib.request.Request( # noqa: S310 - endpoint declared on the binding by its operator + endpoint, data=body, method="POST") request.add_header("Content-Type", "application/json") - request.add_header("Authorization", f"Bearer {token.token}") + if credential is not None: + request.add_header("Authorization", f"Bearer {credential}") try: with opener(request, timeout=timeout) as response: payload = response.read(MAX_PROVIDER_ANSWER_BYTES + 1) @@ -827,29 +934,37 @@ def as_dict(self) -> dict: class BrokeredProviderPort: - """A `doxbench_model.WorkbenchModelPort` backed by a broker-minted token. + """A `doxbench_model.WorkbenchModelPort` backed by the binding's + credential: a broker-minted token, or, since #1144 box 16.3, a reference + the built-in resolver reads at call time, or no credential at all under + the auth kind `none`. The record's `credential_source()` says which, and + the name is kept because the entry points construct this class by it. THREE MEMBERS AND NO FOURTH, exactly like every other adapter this seam accepts: `timeout_seconds`, `catalog()`, `dispatch(envelope)`. Everything - below them — minting, expiry, the retry ruling, the provider call — is this - class's business and reaches the seam as one opaque dispatch. + below them — minting, expiry, the retry ruling, the built-in resolver, the + provider call — is this class's business and reaches the seam as one + opaque dispatch. ONE INSTANCE PER PROCESS, for the same reason the harness bridge is: `_workbench_model_port` resolves per REQUEST, and a port constructed per call would mint a fresh token for every turn and throw away a perfectly live one. The token is guarded by a lock because the server is a - `ThreadingHTTPServer`. + `ThreadingHTTPServer`. A record no broker answers holds NO credential on + the port: the resolver reads it for each request. - `catalog()` NEVER MINTS. A menu is not a paid call, and a console that - minted a token to render one would spend a mint on every capabilities - probe.""" + `catalog()` NEVER MINTS AND NEVER RESOLVES. A menu is not a paid call, and + a console that minted a token or read a key to render one would do so on + every capabilities probe.""" def __init__(self, binding, catalog, *, timeout_seconds: float = 60.0, runner=subprocess_broker_runner, opener=urllib.request.urlopen, clock=time.time, - notice=None) -> None: + notice=None, + environ=None, + keyring_backend=None) -> None: if not isinstance(binding, binding_mod.ModelProviderBinding): raise TypeError( "binding must be a ModelProviderBinding, got " @@ -865,9 +980,13 @@ def __init__(self, binding, catalog, *, self._opener = opener self._clock = clock self._notice = notice if notice is not None else sys.stderr.write + # The built-in resolver's seams (#1144 box 16.3). None reads this + # process's own environment and the OS keyring, AT CALL TIME. + self._environ = environ + self._keyring_backend = keyring_backend self._lock = threading.Lock() self._token: MintedToken | None = None - self._mintable = True + self._available = True self.ledger: list[MintEvent] = [] # -- the three port members -------------------------------------------- @@ -878,12 +997,14 @@ def timeout_seconds(self) -> float: def catalog(self) -> model_mod.ModelCatalog: """The install's declared catalog, marked unavailable once this port - knows it cannot mint. + knows it cannot present a credential: a broker that refused to mint, + or a reference the built-in resolver could not resolve. The same honesty the harness bridge keeps: a declaration is available until something is measured, and a broker that has refused is measured. - Nothing here contacts the broker to find out.""" - if self._mintable: + Nothing here contacts the broker, or reads a reference, to find out. + A later turn that succeeds makes the entry available again.""" + if self._available: return self._declared_catalog return model_mod.ModelCatalog.from_entries([ dataclasses.replace(entry, available=False) @@ -917,7 +1038,10 @@ def dispatch(self, prompt_envelope: object) -> object: THE REQUEST'S MODEL IS THE BINDING'S DECLARED `model` (#1144 box 16.2). A binding that declares none sends the catalog handle, which is - what every request sent before the field existed, byte for byte.""" + what every request sent before the field existed, byte for byte. + + A RECORD NO BROKER ANSWERS takes `_dispatch_without_a_broker` instead + (#1144 box 16.3): no mint, no ledger event, and no retry.""" handle = getattr(prompt_envelope, "model_id", None) if not isinstance(handle, str) or not handle: entries = self._declared_catalog.entries @@ -925,11 +1049,17 @@ def dispatch(self, prompt_envelope: object) -> object: declared_model = self._binding.model model = declared_model if declared_model is not None else handle prompt = bridge_mod.render_prompt_message(prompt_envelope) + if (self._binding.credential_source() + != binding_mod.CREDENTIAL_FROM_BROKER): + return {"assistant_prose": self._dispatch_without_a_broker( + model=model, prompt=prompt), + "proposals": []} token = self._current_token(REASON_FIRST_MINT) try: - prose = _post_to_provider(token, model=model, prompt=prompt, - timeout=self._timeout_seconds, - opener=self._opener) + prose = _post_to_provider( + endpoint=token.endpoint, dialect=token.dialect, + credential=token.token, model=model, prompt=prompt, + timeout=self._timeout_seconds, opener=self._opener) except _TokenExpired: # PER-TURN STATE, and no longer than the turn: the expired mint's # own audit reference, read before the token is dropped, so the @@ -942,13 +1072,49 @@ def dispatch(self, prompt_envelope: object) -> object: self._record(REASON_PAID_RETRY) try: prose = _post_to_provider( - token, model=model, prompt=prompt, + endpoint=token.endpoint, dialect=token.dialect, + credential=token.token, model=model, prompt=prompt, timeout=self._timeout_seconds, opener=self._opener) except _TokenExpired: self._forget_token() raise BrokerRefused(DIAG_TOKEN_EXPIRED_TWICE) from None return {"assistant_prose": prose, "proposals": []} + def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: + """One turn for a record NO BROKER answers (#1144 box 16.3). + + NOTHING IS MINTED AND NOTHING IS KEPT. Under the built-in resolver the + credential is read now, for this one request (RULED R1Q17 (b)), and it + is dropped when this call returns. Under the auth kind `none` there is + no credential, and the request carries no authorization header (RULED + R1Q18 (a)). A resolution that fails refuses before any provider is + contacted, and marks the catalog unavailable as a refused mint does. + + A 401 HERE IS A REFUSAL, NOT AN EXPIRY. The 2026-08-26 retry ruling is + about a MINTED token outliving its turn, and here there is no mint to + repeat: the reference names the same value on a second read, so a + retry would buy a second paid call for the same refusal.""" + credential = None + if (self._binding.credential_source() + == binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER): + try: + credential = resolve_credential_reference( + self._binding, environ=self._environ, + keyring_backend=self._keyring_backend) + except BrokerRefused: + with self._lock: + self._available = False + raise + with self._lock: + self._available = True + try: + return _post_to_provider( + endpoint=self._binding.endpoint, dialect=self._binding.dialect, + credential=credential, model=model, prompt=prompt, + timeout=self._timeout_seconds, opener=self._opener) + except _TokenExpired: + raise BrokerRefused(DIAG_PROVIDER_REFUSED) from None + # -- token custody ------------------------------------------------------ def _current_token(self, reason: str, *, @@ -971,9 +1137,9 @@ def _current_token(self, reason: str, *, minted = mint(self._binding, retry_of=retry_of, runner=self._runner) except BrokerRefused: - self._mintable = False + self._available = False raise - self._mintable = True + self._available = True self._token = minted self._record(reason, audit_ref=minted.audit_ref) return minted @@ -992,5 +1158,5 @@ def _record(self, reason: str, *, audit_ref: str | None = None) -> None: def __repr__(self) -> str: return (f"BrokeredProviderPort(binding={self._binding.id!r}, " - f"mintable={self._mintable}, " + f"available={self._available}, " f"token={'held' if self._token is not None else 'none'})") diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 82bf9a11..a2ff186e 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -22,8 +22,10 @@ UNCONFIGURED posture is byte-for-byte what it was before this change. A SIXTH LAYER, (f), holds #1144 Group 16's binding and provider boxes (plan -034 phase 3, slice P3-B). 16.1 is the OpenAI-compatible dialect (T078), and -16.2 is the model name the provider receives (T079). +034 phase 3, slice P3-B). 16.1 is the OpenAI-compatible dialect (T078), 16.2 +is the model name the provider receives (T079), and 16.3 is the credential +staying a reference: a key in the URL or an extra field refused, the built-in +`env:` and keyring resolver, and the auth kind `none` (T080). THE FAKE BROKER SPEAKS THE DECLARED CONTRACT (task 2.6). It was this repository's own invented stdin/stdout protocol until the reconciliation, which @@ -137,9 +139,14 @@ def test_no_secret_field_exists_in_the_shape_to_populate(secret_field): def test_the_auth_kind_vocabulary_is_closed(): - assert binding_mod.AUTH_KINDS == ("api_key", "oauth") - for kind in binding_mod.AUTH_KINDS: + """THREE KINDS since #1144 box 16.3 (RULED R1Q18 (a)), in a pinned order: + `none` joins after the two that take a credential. A `none` binding names + no reference and no broker, which is why it is built apart here.""" + assert binding_mod.AUTH_KINDS == ("api_key", "oauth", "none") + for kind in (binding_mod.AUTH_KIND_API_KEY, binding_mod.AUTH_KIND_OAUTH): assert _binding(auth_kind=kind).auth_kind == kind + assert _binding(auth_kind=binding_mod.AUTH_KIND_NONE, credential_ref=None, + broker_argv=()).auth_kind == "none" with pytest.raises(binding_mod.BindingRefused): _binding(auth_kind="whatever_the_broker_likes") @@ -1123,8 +1130,12 @@ def test_a_refusal_cannot_be_composed_from_what_a_broker_said(): def test_the_fixed_diagnostics_are_all_reachable_and_no_more(): """The closed set shed the dialect sentence when the dialect became a declaration-time refusal; keeping an unraisable sentence would be a refusal - nobody can trigger.""" - assert len(provider_mod.FIXED_DIAGNOSTICS) == 8 + nobody can trigger. TEN since #1144 box 16.3: the built-in resolver's two + joined, and section (f) below reaches each of them.""" + assert len(provider_mod.FIXED_DIAGNOSTICS) == 10 + assert {provider_mod.DIAG_REFERENCE_UNRESOLVED, + provider_mod.DIAG_KEYRING_UNAVAILABLE} <= \ + provider_mod.FIXED_DIAGNOSTICS assert not hasattr(provider_mod, "DIAG_DIALECT_UNKNOWN") @@ -1775,3 +1786,613 @@ def test_a_stand_in_chat_server_receives_the_declared_model(tmp_path): assert _ChatCompletionsHandler.seen["body"] == { "model": DECLARED_MODEL, "messages": [{"role": "user", "content": "assembled prompt"}]} + + +# 16.3, the credential stays a reference (T080). +# +# * A key in the endpoint URL or in an extra field is refused when the +# binding is declared. The URL check is the product's own detector, +# `runtime/local_git_adapter.carries_a_credential`. +# * The built-in resolver takes `env:NAME` and OS-keyring references, at call +# time and inside `doxbench_provider` only (RULED R1Q17 (b)). Such a record +# needs no broker, and one given beside it is refused. That refusal is the +# plan's fail-closed reading (analyze round 2, V2-21), which no answer +# rules. The tests below hold both halves. +# * An endpoint that takes no credential declares the auth kind `none`, +# under which `broker_argv` and `credential_ref` are forbidden (RULED +# R1Q18 (a)). It joins after the two kinds that exist. +# +# Every key here is an obvious fake. + +KEY_SENTINEL = "sk-stand-in-7c1e5a90d3b24f68-NOT-A-KEY" +ENV_NAME = "STAND_IN_PROVIDER_KEY" +KEYRING_SERVICE = "https://api.example.invalid" +KEYRING_USER = "brett" + + +def _f16_1_record(): + """F16.1's clean control record, built as #1144's falsifier builds it.""" + rec = {f: "stand-in" for f in binding_mod.BINDING_FIELDS} + rec.update(auth_kind=binding_mod.AUTH_KINDS[0], dialect="openai-chat-v1", + endpoint="http://127.0.0.1:9/v1/chat/completions") + if "broker_argv" in rec: + rec["broker_argv"] = ["stand-in-broker"] + return rec + + +def test_f16_1_a_raw_key_is_refused_in_a_field_and_in_the_url(): + """F16.1's refusal block, as #1144 writes it: the clean control record is + accepted, and each of its three raw keys is refused.""" + rec = _f16_1_record() + binding_mod.ModelProviderBinding.from_record(rec) # the control + for bad in (dict(rec, endpoint="https://user:sk-stand-in@api.example.invalid/v1"), + dict(rec, endpoint="https://api.example.invalid/v1?api_key=sk-stand-in"), + dict(rec, api_key="sk-stand-in")): + with pytest.raises(binding_mod.BindingRefused): + binding_mod.ModelProviderBinding.from_record(bad) + + +def test_f16_1_the_first_auth_kind_still_takes_a_credential(): + """`none` joined AFTER the two kinds that exist (R1Q18 (a)), so the + `AUTH_KINDS[0]` F16.1 builds its control from is unchanged.""" + assert binding_mod.AUTH_KINDS[0] == binding_mod.AUTH_KIND_API_KEY + assert binding_mod.AUTH_KINDS[-1] == binding_mod.AUTH_KIND_NONE == "none" + + +@pytest.mark.parametrize("endpoint", [ + f"https://user:{KEY_SENTINEL}@api.example.invalid/v1", + f"https://{KEY_SENTINEL}@api.example.invalid/v1", + f"https://api.example.invalid/v1?api_key={KEY_SENTINEL}", + f"https://api.example.invalid/v1?key={KEY_SENTINEL}", + f"https://api.example.invalid/v1?token={KEY_SENTINEL}", + f"https://api.example.invalid/v1?%61pi_key={KEY_SENTINEL}", + f"https://api.example.invalid/v1#token={KEY_SENTINEL}", + f"ftp://user:{KEY_SENTINEL}@api.example.invalid/v1", +], ids=["userinfo", "bare-userinfo", "query-api-key", "query-key", + "query-token", "percent-encoded-name", "fragment", "keyed-bad-scheme"]) +def test_a_key_in_the_endpoint_is_refused_and_never_repeated(endpoint): + """The refusal is FIXED, so the URL it refused is repeated nowhere. The + detector runs before the scheme check, which would have echoed it.""" + with pytest.raises(binding_mod.BindingRefused) as caught: + _binding(endpoint=endpoint) + assert str(caught.value) == binding_mod.ENDPOINT_CARRIES_A_CREDENTIAL + assert KEY_SENTINEL not in str(caught.value) + + +@pytest.mark.parametrize("endpoint", [ + "http://127.0.0.1:9/v1/chat/completions", + "http://localhost:11434/v1/chat/completions", + "https://api.example.invalid/v1/chat/completions", + "https://api.example.invalid/v1?model=stand-in&stream=false", +]) +def test_a_clean_endpoint_is_accepted(endpoint): + assert _binding(endpoint=endpoint, dialect=OPENAI_CHAT).endpoint == endpoint + + +def test_a_key_in_an_extra_field_is_refused_as_it_always_was(): + for field in ("api_key", "secret", "token", "password", "key"): + record = dict(_binding().as_record(), **{field: KEY_SENTINEL}) + with pytest.raises(binding_mod.BindingRefused) as caught: + binding_mod.ModelProviderBinding.from_record(record) + assert KEY_SENTINEL not in str(caught.value) + + +def test_a_stored_document_whose_endpoint_carries_a_key_does_not_read( + tmp_path): + """A record written before 16.3 with a key in its URL no longer reads, + and the entry point's fallback still resolves the harness declaration. + The key is not in the refusal it prints.""" + record = _binding().as_record() + record["endpoint"] = f"https://user:{KEY_SENTINEL}@api.example.invalid/v1" + path = tmp_path / "bindings.yaml" + path.write_text(json.dumps({"schema_version": 1, + "kind": binding_mod.BINDINGS_KIND, + "bindings": [record]}), encoding="utf-8") + with pytest.raises(binding_mod.BindingRefused) as caught: + binding_mod.BindingStore(path).list() + assert KEY_SENTINEL not in str(caught.value) + + +# --- one resolver per record --------------------------------------------- + + +def _none_binding(**overrides): + fields = dict(auth_kind=binding_mod.AUTH_KIND_NONE, credential_ref=None, + broker_argv=(), endpoint="http://127.0.0.1:9/v1/chat/completions", + dialect=OPENAI_CHAT, model=DECLARED_MODEL) + fields.update(overrides) + return _binding(**fields) + + +def _built_in_binding(credential_ref=f"env:{ENV_NAME}", **overrides): + fields = dict(credential_ref=credential_ref, broker_argv=(), + dialect=OPENAI_CHAT, model=DECLARED_MODEL) + fields.update(overrides) + return _binding(**fields) + + +def test_the_none_kind_forbids_the_reference_and_the_broker(): + """R1Q18 (a), both halves: declared explicitly, and both fields + forbidden under it. A blank reference is still a reference given.""" + binding = _none_binding() + assert binding.credential_ref is None and binding.broker_argv == () + assert binding.credential_source() == binding_mod.NO_CREDENTIAL + for given in (FAKE_REFERENCE, f"env:{ENV_NAME}", ""): + with pytest.raises(binding_mod.BindingRefused) as caught: + _none_binding(credential_ref=given) + assert "credential_ref is forbidden" in str(caught.value) + with pytest.raises(binding_mod.BindingRefused) as caught: + _none_binding(broker_argv=("openprofiler-broker",)) + assert "broker_argv is forbidden" in str(caught.value) + + +@pytest.mark.parametrize("kind", [binding_mod.AUTH_KIND_API_KEY, + binding_mod.AUTH_KIND_OAUTH]) +def test_a_kind_that_takes_a_credential_must_name_its_reference(kind): + """Never "no credential" by a field left out: the kind says it.""" + with pytest.raises(binding_mod.BindingRefused) as caught: + _binding(auth_kind=kind, credential_ref=None) + assert "'none'" in str(caught.value) + + +@pytest.mark.parametrize("reference", [ + f"env:{ENV_NAME}", f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"]) +def test_a_built_in_reference_needs_no_broker_and_refuses_one_beside_it( + reference): + """BOTH HALVES, in one test, as T080's text asks. R1Q17 (b): a record + whose reference the built-in resolver takes needs no broker. The plan's + fail-closed reading: a broker given beside it is refused, so the record + has one resolver.""" + binding = _built_in_binding(reference) + assert binding.broker_argv == () + assert binding.credential_source() == \ + binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER + with pytest.raises(binding_mod.BindingRefused) as caught: + _built_in_binding(reference, broker_argv=("openprofiler-broker",)) + assert "two resolvers" in str(caught.value) + + +def test_a_brokers_reference_still_needs_its_broker(): + binding = _binding() + assert binding.credential_source() == binding_mod.CREDENTIAL_FROM_BROKER + with pytest.raises(binding_mod.BindingRefused) as caught: + _binding(broker_argv=()) + assert "must name the broker command" in str(caught.value) + + +def test_the_reference_forms_are_parsed_once(): + parts = binding_mod.built_in_reference_parts + assert parts(f"env:{ENV_NAME}") == ("env:", ENV_NAME) + # the split is at the LAST `/`, so a service may carry one + assert parts(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}") == ( + "keyring:", KEYRING_SERVICE, KEYRING_USER) + assert parts(FAKE_REFERENCE) is None + assert binding_mod.BUILT_IN_REFERENCE_FORMS == ("env:", "keyring:") + + +@pytest.mark.parametrize("reference", [ + "env:", "env:1BAD", "env:A-B", "env: SPACED", "env:NAME\n", + f"env:{KEY_SENTINEL}", + "keyring:", "keyring:service-only", "keyring:/user", "keyring:service/", + "keyring: /user", f"keyring:{KEY_SENTINEL}", +]) +def test_a_malformed_built_in_reference_is_refused_and_never_repeated( + reference): + """Refused at declaration, and the refusal names the FORM, not the value: + a key pasted where its reference belongs is not printed back.""" + with pytest.raises(binding_mod.BindingRefused) as caught: + _built_in_binding(reference) + message = str(caught.value) + after_the_form = reference.split(":", 1)[1] + if after_the_form.strip(): + assert after_the_form not in message + assert KEY_SENTINEL not in message + + +def test_a_none_record_round_trips_and_may_leave_its_forbidden_fields_out(): + binding = _none_binding() + record = binding.as_record() + assert list(record) == ["kind", *binding_mod.BINDING_FIELDS] + assert record["credential_ref"] is None and record["broker_argv"] == [] + assert binding_mod.ModelProviderBinding.from_record(record) == binding + del record["credential_ref"], record["broker_argv"] + assert binding_mod.ModelProviderBinding.from_record(record) == binding + for key, given in (("credential_ref", FAKE_REFERENCE), + ("broker_argv", ["openprofiler-broker"])): + with pytest.raises(binding_mod.BindingRefused): + binding_mod.ModelProviderBinding.from_record( + dict(record, **{key: given})) + + +def test_a_built_in_record_may_leave_out_its_broker_argv(): + binding = _built_in_binding() + record = binding.as_record() + assert record["broker_argv"] == [] + for absent in ("deleted", None): + candidate = dict(record) + if absent == "deleted": + del candidate["broker_argv"] + else: + candidate["broker_argv"] = None + assert binding_mod.ModelProviderBinding.from_record(candidate) == \ + binding + with pytest.raises(binding_mod.BindingRefused): + binding_mod.ModelProviderBinding.from_record( + dict(record, broker_argv=["openprofiler-broker"])) + + +def test_each_read_back_states_the_custody_that_is_true_of_it(tmp_path): + store = _store(tmp_path) + store.add(_binding()) + store.add(_built_in_binding(id="env-bound", label="Env")) + store.add(_none_binding(id="local", label="Local")) + custody = {record["id"]: record["credential_custody"] + for record in store.read_back()["bindings"]} + assert custody == { + "openprofiler-demo": binding_mod.CUSTODY_NOTICE, + "env-bound": binding_mod.BUILT_IN_CUSTODY_NOTICE, + "local": binding_mod.NO_CREDENTIAL_NOTICE, + } + assert [store.get(i).removal_notice() + for i in ("openprofiler-demo", "env-bound", "local")] == [ + binding_mod.REMOVAL_NOTICE, binding_mod.BUILT_IN_REMOVAL_NOTICE, + binding_mod.NO_CREDENTIAL_REMOVAL_NOTICE] + + +def test_the_consoles_intake_shaped_binding_still_builds(): + """`serve_workbench`'s intake route builds its binding by keyword, with a + placeholder reference and the declared broker. That construction is + unchanged and still valid. A `none` binding cannot be enrolled that way, + because the placeholder is a reference and `none` forbids one.""" + shaped = dict(id="enrolled", label="Enrolled", provider="p", + credential_ref="pending-broker-intake", + approved_by="brett", endpoint=ENDPOINT, + dialect=binding_mod.DIALECT_XFACTORY_PROMPT_V1, + broker_argv=("openprofiler-broker",)) + for kind in (binding_mod.AUTH_KIND_API_KEY, binding_mod.AUTH_KIND_OAUTH): + assert binding_mod.ModelProviderBinding(auth_kind=kind, **shaped) + with pytest.raises(binding_mod.BindingRefused): + binding_mod.ModelProviderBinding(auth_kind=binding_mod.AUTH_KIND_NONE, + **shaped) + + +def test_a_binding_no_broker_answers_has_no_broker_operation(): + for binding in (_none_binding(), _built_in_binding()): + for operation in provider_mod.OPERATIONS: + with pytest.raises(AssertionError): + provider_mod.broker_operation_argv(binding, operation) + with pytest.raises(AssertionError): + provider_mod.hand_off_credential(binding, io.StringIO("x")) + + +# --- the built-in resolver, at call time --------------------------------- + + +class _FakeKeyring: + """A stand-in for the `keyring` package: the one call the resolver makes, + recorded.""" + + def __init__(self, entries=None, error=None): + self.entries = dict(entries or {}) + self.error = error + self.asked: list[tuple[str, str]] = [] + + def get_password(self, service, username): + self.asked.append((service, username)) + if self.error is not None: + raise self.error + return self.entries.get((service, username)) + + +def _refusing_runner(argv, **_kwargs): + raise AssertionError(f"a binding no broker answers spawned {argv!r}") + + +def _unbrokered_port(binding, *outcomes, environ=None, keyring_backend=None, + notice=None): + opener = _Opener(*outcomes) + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + runner=_refusing_runner, opener=opener, + notice=notice if notice is not None else (lambda _text: None), + environ=environ, keyring_backend=keyring_backend) + return port, opener + + +def test_an_env_reference_is_read_at_call_time_and_presented_as_the_bearer(): + """R1Q17 (b): no broker, no mint, and the value read for each request, so + a rotated value is the one the next request presents.""" + environ = {ENV_NAME: KEY_SENTINEL} + port, opener = _unbrokered_port(_built_in_binding(), + _chat_completion("a"), + _chat_completion("b"), environ=environ) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + environ[ENV_NAME] = "sk-rotated-stand-in-NOT-A-KEY" + assert port.dispatch(_Envelope())["assistant_prose"] == "b" + assert [request.get_header("Authorization") + for request in opener.requests] == [ + f"Bearer {KEY_SENTINEL}", "Bearer sk-rotated-stand-in-NOT-A-KEY"] + for request in opener.requests: + assert KEY_SENTINEL not in request.get_full_url() + assert KEY_SENTINEL not in request.data.decode("utf-8") + assert port.ledger == [], "nothing was minted" + + +def test_production_reads_this_processs_own_environment(monkeypatch): + monkeypatch.setenv(ENV_NAME, KEY_SENTINEL) + port, opener = _unbrokered_port(_built_in_binding(), + _chat_completion("a")) + port.dispatch(_Envelope()) + assert opener.requests[0].get_header("Authorization") == \ + f"Bearer {KEY_SENTINEL}" + + +@pytest.mark.parametrize("environ", [ + {}, {ENV_NAME: ""}, {ENV_NAME: " "}, {ENV_NAME: "sk-stand-in\nX-Other: 1"}, + {ENV_NAME: "sk-stand-in\x00"}], + ids=["unset", "empty", "blank", "line-break", "nul"]) +def test_an_unusable_env_value_refuses_before_any_request(environ): + port, opener = _unbrokered_port(_built_in_binding(), _chat_completion(), + environ=environ) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_REFERENCE_UNRESOLVED + assert opener.requests == [], "no provider was contacted" + assert port.catalog().entries[0].available is False + + +def test_a_reference_that_resolves_again_makes_the_entry_available_again(): + environ: dict[str, str] = {} + port, _opener = _unbrokered_port(_built_in_binding(), + _chat_completion("a"), environ=environ) + with pytest.raises(provider_mod.BrokerRefused): + port.dispatch(_Envelope()) + assert port.catalog().entries[0].available is False + environ[ENV_NAME] = KEY_SENTINEL + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + assert port.catalog().entries[0].available is True + + +def test_a_keyring_reference_reads_the_os_keyring_at_call_time(): + backend = _FakeKeyring({(KEYRING_SERVICE, KEYRING_USER): KEY_SENTINEL}) + port, opener = _unbrokered_port( + _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), + _chat_completion("a"), _chat_completion("b"), keyring_backend=backend) + port.dispatch(_Envelope()) + port.dispatch(_Envelope()) + assert backend.asked == [(KEYRING_SERVICE, KEYRING_USER)] * 2, \ + "read for each request, and nothing cached" + assert opener.requests[0].get_header("Authorization") == \ + f"Bearer {KEY_SENTINEL}" + + +def test_an_absent_keyring_entry_refuses_unresolved(): + port, opener = _unbrokered_port( + _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), + _chat_completion(), keyring_backend=_FakeKeyring()) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_REFERENCE_UNRESOLVED + assert opener.requests == [] + + +def test_a_keyring_that_cannot_be_read_refuses_and_says_nothing_of_its_own(): + backend = _FakeKeyring(error=RuntimeError("backend detail that leaks")) + port, opener = _unbrokered_port( + _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), + _chat_completion(), keyring_backend=backend) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_KEYRING_UNAVAILABLE + assert "leaks" not in str(caught.value) + assert caught.value.__cause__ is None + assert opener.requests == [] + + +def test_without_the_keyring_package_a_keyring_reference_refuses(monkeypatch): + """`keyring` is not a dependency of this package. Without it, the + production path refuses with the fixed sentence, not an import error.""" + monkeypatch.setitem(sys.modules, "keyring", None) + port, opener = _unbrokered_port( + _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), + _chat_completion()) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_KEYRING_UNAVAILABLE + assert opener.requests == [] + + +def test_the_production_keyring_path_imports_the_package_at_call_time( + monkeypatch): + backend = _FakeKeyring({(KEYRING_SERVICE, KEYRING_USER): KEY_SENTINEL}) + monkeypatch.setitem(sys.modules, "keyring", backend) + port, opener = _unbrokered_port( + _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), + _chat_completion("a")) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + assert backend.asked == [(KEYRING_SERVICE, KEYRING_USER)] + + +def test_a_none_binding_presents_no_credential_and_spawns_no_broker(): + port, opener = _unbrokered_port(_none_binding(), _chat_completion("a")) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + request = opener.requests[0] + assert not request.has_header("Authorization") + assert json.loads(request.data.decode("utf-8"))["model"] == DECLARED_MODEL + assert port.ledger == [] + + +@pytest.mark.parametrize("which", ["built-in", "none"]) +def test_a_401_without_a_broker_is_a_refusal_not_a_retry(which): + """The 2026-08-26 retry ruling is about a MINTED token. Here there is + nothing to re-mint, so a retry would buy a second paid call for the same + refusal.""" + binding = _built_in_binding() if which == "built-in" else _none_binding() + port, opener = _unbrokered_port(binding, _expired_error(), + _chat_completion("never reached"), + environ={ENV_NAME: KEY_SENTINEL}) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_REFUSED + assert len(opener.requests) == 1, "no second paid call" + assert port.ledger == [] + + +def test_the_resolved_credential_reaches_no_response_log_repr_or_disk( + tmp_path): + printed: list[str] = [] + port, _opener = _unbrokered_port(_built_in_binding(), + _chat_completion("the answer"), + environ={ENV_NAME: KEY_SENTINEL}, + notice=printed.append) + answer = port.dispatch(_Envelope()) + assert KEY_SENTINEL not in json.dumps(answer) + assert KEY_SENTINEL not in repr(port) + assert KEY_SENTINEL not in "".join(printed) + assert KEY_SENTINEL not in repr(port.ledger) + port_state = {name: getattr(port, name) for name in dir(port) + if name.startswith("_") and not name.startswith("__") + and name != "_environ"} + assert KEY_SENTINEL not in repr(port_state), \ + "the port keeps no credential between turns" + assert not [path for path in tmp_path.rglob("*") if path.is_file() + and KEY_SENTINEL in path.read_text(encoding="utf-8", + errors="replace")] + + +def test_an_unresolved_reference_maps_onto_the_seams_fixed_model_failed(): + port, _opener = _unbrokered_port(_built_in_binding(), environ={}) + entry = port.catalog().entries[0] + ticks = iter([0.0, 0.1]) + outcome = model_mod.dispatch_turn(port, _Envelope(), entry=entry, + clock=lambda: next(ticks)) + assert isinstance(outcome, model_mod.TurnDispatchFailure) + assert outcome.error == model_mod.DISPATCH_ERR_MODEL_FAILED + assert KEY_SENTINEL not in json.dumps(str(outcome)) + + +@pytest.mark.parametrize("which,expected", [ + ("built-in", f"Bearer {KEY_SENTINEL}"), ("none", None)]) +def test_a_stand_in_server_sees_the_resolved_bearer_or_no_header(which, + expected): + with _stand_in_provider(_ChatCompletionsHandler) as base: + endpoint = f"{base}/v1/chat/completions" + binding = (_built_in_binding(endpoint=endpoint) if which == "built-in" + else _none_binding(endpoint=endpoint)) + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + runner=_refusing_runner, notice=lambda _text: None, + environ={ENV_NAME: KEY_SENTINEL}) + assert port.dispatch(_Envelope())["assistant_prose"] == \ + "answered in the chat grammar" + assert _ChatCompletionsHandler.seen["authorization"] == expected + assert _ChatCompletionsHandler.seen["body"]["model"] == DECLARED_MODEL + + +def test_the_resolver_lives_in_the_provider_module_alone(): + """R1Q17 (b): "inside `doxbench_provider.py` only". The record parses a + reference's FORM and reads no environment and no keyring. No other module + of the package reads the OS keyring.""" + package = REPO_ROOT / "src" / "opendox" + binding_source = (package / "doxbench_binding.py").read_text( + encoding="utf-8") + for reach in ("os.environ", "getenv", "get_password", "import keyring", + "import os"): + assert reach not in binding_source, reach + holders = sorted(path.relative_to(package).as_posix() + for path in package.rglob("*.py") + if "get_password" in path.read_text(encoding="utf-8") + or "import keyring" in path.read_text(encoding="utf-8")) + assert holders == [provider_mod.PROVIDER_CLIENT_MODULE], holders + + +# --- the operator door ---------------------------------------------------- + + +def test_the_cli_declares_a_binding_for_each_resolver(tmp_path, capsys): + checkout = tmp_path / "checkout" + checkout.mkdir() + parser = cli_mod.build_parser() + + def run(*argv) -> int: + args = parser.parse_args(list(argv)) + return args.func(args) + + root = ["--repo-root", str(checkout)] + route = ["--label", "L", "--provider", "local", + "--credential-approver", "brett@opensoft.one", + "--endpoint", "http://127.0.0.1:9/v1/chat/completions", + "--dialect", OPENAI_CHAT, "--model", DECLARED_MODEL] + store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) + + # an endpoint that takes no credential: no reference, and no broker + assert run("model-binding", "add", *root, "--id", "local", *route, + "--auth-kind", "none") == 0 + assert binding_mod.NO_CREDENTIAL_NOTICE in capsys.readouterr().out + assert store.get("local").credential_source() == binding_mod.NO_CREDENTIAL + + # a reference the built-in resolver takes: no broker + assert run("model-binding", "add", *root, "--id", "env-bound", *route, + "--auth-kind", "api_key", + "--credential-ref", f"env:{ENV_NAME}") == 0 + assert binding_mod.BUILT_IN_CUSTODY_NOTICE in capsys.readouterr().out + + # ...and one given a broker beside it is refused, through the verb + assert run("model-binding", "add", *root, "--id", "two-resolvers", *route, + "--auth-kind", "api_key", "--credential-ref", f"env:{ENV_NAME}", + "--", "openprofiler-broker") == 1 + assert "two resolvers" in capsys.readouterr().err + + # a kind that takes a credential, with no reference, is refused + assert run("model-binding", "add", *root, "--id", "no-ref", *route, + "--auth-kind", "api_key") == 1 + assert "'none'" in capsys.readouterr().err + + # a key inside the URL is refused, and the refusal does not repeat it + keyed = list(route) + keyed[keyed.index("--endpoint") + 1] = ( + f"https://user:{KEY_SENTINEL}@api.example.invalid/v1") + assert run("model-binding", "add", *root, "--id", "keyed", *keyed, + "--auth-kind", "none") == 1 + captured = capsys.readouterr() + assert binding_mod.ENDPOINT_CARRIES_A_CREDENTIAL in captured.err + assert KEY_SENTINEL not in captured.err + captured.out + assert store.get("keyed") is None, "nothing is stored" + + assert run("model-binding", "list", *root) == 0 + listed = capsys.readouterr().out + from opendox import cli_model_binding as cmb + assert f"credential ref {cmb.NOT_DECLARED}" in listed + assert f"broker argv {cmb.NOT_DECLARED}" in listed + assert f"credential ref env:{ENV_NAME}" in listed + + assert run("model-binding", "remove", *root, "--id", "env-bound") == 0 + assert binding_mod.BUILT_IN_REMOVAL_NOTICE in capsys.readouterr().out + + +class _UnreadableSource: + """A standard input that must never be read.""" + + def read(self, *_args): + raise AssertionError("set-credential read a credential it had no " + "custodian for") + + readline = read + + +@pytest.mark.parametrize("binding_factory", [_none_binding, _built_in_binding], + ids=["none", "built-in"]) +def test_set_credential_refuses_a_binding_no_broker_answers(tmp_path, capsys, + binding_factory): + checkout = tmp_path / "checkout" + (checkout / "ideation" / "dashboard").mkdir(parents=True) + store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) + binding = binding_factory() + store.add(binding) + args = cli_mod.build_parser().parse_args([ + "model-binding", "set-credential", "--repo-root", str(checkout), + "--id", binding.id]) + assert cli_mod.cmd_model_binding_set_credential( + args, source=_UnreadableSource()) == 1 + assert "names no broker" in capsys.readouterr().err + assert store.get(binding.id) == binding, "nothing changed" From 6e1b8942913f650a182a3bd5999fcd04586f689f Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:10:09 +0000 Subject: [PATCH 04/45] T078: one throwing call per pytest.raises block in the new tests (SonarCloud) SonarCloud's analysis of openDox-code#61 flagged five of T078's new test lines: four `pytest.raises` blocks whose envelope was built inside the block (S5778), and one composite assertion (S9073). The envelope is now built before each block and the assertion is split in two. No assertion changes meaning, and the case count is unchanged. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_provider_broker.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 9fe6099e..76cbd2ec 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -1429,16 +1429,18 @@ def test_the_prompt_dialect_is_unchanged_byte_for_byte(tmp_path): "content-not-text", "the-other-grammar"]) def test_a_chat_answer_off_the_declared_path_is_malformed(tmp_path, answer): port, _opener = _port(tmp_path, answer, dialect=OPENAI_CHAT) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED def test_a_chat_shaped_answer_is_not_the_prompt_grammars_answer(tmp_path): """Each arm reads its own grammar and no other.""" port, _opener = _port(tmp_path, _chat_completion()) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED @@ -1465,15 +1467,17 @@ def test_the_expiry_ruling_holds_for_the_chat_grammar(tmp_path): provider_mod.REASON_EXPIRY_REMINT, provider_mod.REASON_PAID_RETRY, ] - assert printed and "re-minted once and retried" in printed[0] + assert printed + assert "re-minted once and retried" in printed[0] def test_the_answer_bound_holds_for_the_chat_grammar(tmp_path): bound = provider_mod.MAX_PROVIDER_ANSWER_BYTES oversize = json.dumps(_chat_completion("x" * bound)).encode("utf-8") port, _opener = _port(tmp_path, oversize, dialect=OPENAI_CHAT) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED @@ -1483,8 +1487,9 @@ def test_a_chat_provider_refusal_lands_on_the_fixed_sentence(tmp_path): urllib.error.HTTPError(ENDPOINT, 400, "Bad Request", {}, io.BytesIO(b'{"error":{"message":"leaky"}}')), dialect=OPENAI_CHAT) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_REFUSED assert "leaky" not in str(caught.value) From 053e207a7b6b60c121e5803bead0c91599ff9733 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:15:17 +0000 Subject: [PATCH 05/45] T079: the intake module's docstring stops counting the binding's fields Copilot's overview of openDox-code#62 noted that doxbench_intake.py's module docstring still called the binding a "closed nine-field record" and the intake declaration "not a tenth field on it". T079 made the record ten fields, so both phrases were false. The paragraph now names each count by its tuple, as it already did for the catalog entry, and records that the binding grew once, by `model` (#1144 box 16.2). The declaration is "not a field on it". This is a docstring only: no code, no test and no behaviour changes. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_intake.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/opendox/doxbench_intake.py b/src/opendox/doxbench_intake.py index caaeb4a6..a51d9cfa 100644 --- a/src/opendox/doxbench_intake.py +++ b/src/opendox/doxbench_intake.py @@ -20,21 +20,22 @@ never sees a credential — which is why it is safe for it to be the surface a browser talks to. -WHAT IT DOES NOT WIDEN, also deliberately: the BINDING's closed nine-field -record (`doxbench_binding.BINDING_FIELDS`) and the CATALOG entry's closed public -shape (`doxbench_model.DECLARABLE_ENTRY_FIELDS`). The count is named by that -tuple rather than restated here, because the shape has grown twice by governing -release since this module was written — the routing declaration at -contract-v1.38 and the input-modality declaration at contract-v2.2 — and this -paragraph's claim is that THIS module widens nothing, which is unchanged by -either. Proposed-versus- +WHAT IT DOES NOT WIDEN, also deliberately: the BINDING's closed record +(`doxbench_binding.BINDING_FIELDS`) and the CATALOG entry's closed public +shape (`doxbench_model.DECLARABLE_ENTRY_FIELDS`). Each count is named by its +tuple rather than restated here, because both shapes have grown since this +module was written. The catalog's grew twice by governing release (the routing +declaration at contract-v1.38 and the input-modality declaration at +contract-v2.2), and the binding's grew once, by `model` (#1144 box 16.2, plan +034 T079). This paragraph's claim is that THIS module widens nothing, which is +unchanged by any of them. Proposed-versus- approved is a SERVER-SIDE distinction and a pending declaration is simply not in the catalog, so NEITHER SHAPE GAINS A FIELD FROM THIS MODULE and this module needs no release act. (Both statements are scoped to this module deliberately. The catalog shape HAS gained fields — by the governing releases named above — and each of those was a release act; what has never happened, and is what this paragraph promises, is this module widening either shape.) The declaration is a -SECOND record beside the binding, not a tenth field on it. +SECOND record beside the binding, not a field on it. WHY PENDING-NESS IS A DECLARED FACT AND NOT A DEFAULT. A binding this document says nothing about is UNAFFECTED: it resolves exactly as it resolved before this From f92fca47e934d1979f08bad7c836111b0d87f73a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:30:16 +0000 Subject: [PATCH 06/45] T080: bound the endpoint before the detector; present only what a bearer can carry Two remarks in Copilot's overview of openDox-code#63, both measured here. The endpoint reached the credential detector unbounded. The detector is quadratic in a parameter name's length, and an endpoint arrives from the command line or from the console's intake route. The binding now refuses an endpoint longer than runtime/config.MAX_REMOTE_URL_CHARS before the detector is asked. That is the product's own URL bound, which the repository act applies to a remote for the same reason. It is read when the binding is declared, and the refusal is a fixed sentence that repeats nothing of the endpoint. A resolved credential could be one no header carries. The resolver checked only for CR, LF and NUL. Measured against urllib: - a character outside latin-1 failed while the header was encoded, as DIAG_PROVIDER_UNREACHABLE, which names the wrong party, and the UnicodeEncodeError it chained held the whole header, credential included; - any other non-ASCII character, or an embedded space, was sent. A resolved value must now be non-empty printable ASCII with no whitespace, or it refuses with DIAG_REFERENCE_UNRESOLVED before any provider is contacted. SonarCloud's findings on the same PR: - built_in_reference_parts returns one shape for both forms, a BuiltInReference named tuple (S8495); - the variable-name pattern reads [A-Za-z_]\w* under re.ASCII (S6353); - the broad except carries a bare noqa code, with its reason on the line above (S7632); - each pytest.raises block in T080's tests holds one throwing call (S5778), and two composite assertions are split (S9073). Sixteen new cases: the bound (past it, at it, read from config), six unpresentable values and a printable-ASCII control, a real-socket case for a value outside latin-1, three unusable keyring answers, and two non-ASCII variable names. Five mutants of the fix were each killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 65 ++++++++++-- src/opendox/doxbench_provider.py | 49 +++++---- tests/test_model_provider_broker.py | 152 +++++++++++++++++++++++++--- 3 files changed, 225 insertions(+), 41 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index 4ac3abc6..ed4db9b0 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -27,9 +27,11 @@ THE CREDENTIAL STAYS A REFERENCE, AND A KEY IS REFUSED WHEN IT IS DECLARED (#1144 box 16.3; plan 034 T080). A key inside the endpoint URL is refused by the product's own detector, `runtime/local_git_adapter.carries_a_credential`, and -the refusal never repeats the URL it refused. A key in an extra field is -refused as an unknown key, as it always was. EACH RECORD HAS ONE RESOLVER, and -the record says which: +the refusal never repeats the URL it refused. An endpoint longer than the +product's URL bound is refused before the detector is asked. A key in an extra +field is refused as an unknown key, as it always was. + +EACH RECORD HAS ONE RESOLVER, and the record says which: * the BROKER the record names, for any other reference (as before); * the BUILT-IN RESOLVER, for an `env:NAME` or `keyring:SERVICE/USERNAME` @@ -82,6 +84,7 @@ import re from collections.abc import Iterable, Mapping from pathlib import Path +from typing import NamedTuple # --------------------------------------------------------------------------- # the record's identity (the workspace rule: every YAML carries both) @@ -149,7 +152,23 @@ BUILT_IN_REFERENCE_FORMS: tuple[str, ...] = (CREDENTIAL_REF_ENV, CREDENTIAL_REF_KEYRING) -_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +#: A portable variable name. `re.ASCII` keeps `\w` to letters, digits and `_` +#: of ASCII alone, so a name no other shell could export is refused. +_ENV_NAME = re.compile(r"[A-Za-z_]\w*", re.ASCII) + + +class BuiltInReference(NamedTuple): + """A reference the built-in resolver takes, split into what it looks up. + + One shape for both forms. `form` is one of `BUILT_IN_REFERENCE_FORMS`. + For `env:NAME`, `name` is the variable and `user` is None. For + `keyring:SERVICE/USERNAME`, `name` is the service and `user` is the user + name.""" + + form: str + name: str + user: str | None + #: The three answers to "what resolves this record's credential", one per #: record (see the module docstring). `ModelProviderBinding.credential_source` @@ -206,6 +225,30 @@ "commit only because it holds none; declare the endpoint without it, and " "name the credential by its reference in credential_ref") +#: The refusal an endpoint longer than the product's URL bound earns (#1144 box +#: 16.3; Copilot's overview of openDox-code#63). The detector below is +#: quadratic in a parameter name's length, and an endpoint reaches it from an +#: operator's command line or from the console's intake route, so the +#: endpoint's length is checked before the detector is asked. The bound is the +#: product's own, `runtime/config.MAX_REMOTE_URL_CHARS`, which the repository +#: act applies to a remote for the same reason. Like the refusal above, this +#: one never repeats the endpoint. The only number in it is the product's +#: bound. +ENDPOINT_TOO_LONG = ( + "the endpoint is longer than {bound} characters and is refused unread: " + "the credential check is quadratic in what it is given, and no provider " + "endpoint is this long") + + +def _endpoint_bound() -> int: + """`runtime/config.MAX_REMOTE_URL_CHARS`, read where it is asked. It is + imported there for the same reason as the detector below, so this module + stays light at import time. `runtime/config` is stdlib-only by the runtime + package's import-weight contract.""" + from opendox.runtime import config + + return config.MAX_REMOTE_URL_CHARS + def _carries_a_credential(text: str) -> bool: """The product's ONE detector, `runtime/local_git_adapter. @@ -325,9 +368,10 @@ def names_a_built_in_form(credential_ref: object) -> bool: and credential_ref.startswith(BUILT_IN_REFERENCE_FORMS)) -def built_in_reference_parts(credential_ref: str) -> tuple[str, ...] | None: +def built_in_reference_parts(credential_ref: str) -> BuiltInReference | None: """A reference the built-in resolver takes, split into what it looks up: - `("env:", NAME)` or `("keyring:", SERVICE, USERNAME)`. None for a broker's + `BuiltInReference("env:", NAME, None)` or + `BuiltInReference("keyring:", SERVICE, USERNAME)`. None for a broker's reference. ONE PARSER, which the record calls when a binding is declared and @@ -343,7 +387,7 @@ def built_in_reference_parts(credential_ref: str) -> tuple[str, ...] | None: "credential_ref uses the env: form, and what follows env: is " "not an environment variable name (a letter or _, then " "letters, digits or _)") - return (CREDENTIAL_REF_ENV, name) + return BuiltInReference(CREDENTIAL_REF_ENV, name, None) if credential_ref.startswith(CREDENTIAL_REF_KEYRING): service, separator, username = ( credential_ref[len(CREDENTIAL_REF_KEYRING):].rpartition("/")) @@ -351,7 +395,7 @@ def built_in_reference_parts(credential_ref: str) -> tuple[str, ...] | None: raise BindingRefused( "credential_ref uses the keyring: form, and it does not read " "keyring:SERVICE/USERNAME with both parts present") - return (CREDENTIAL_REF_KEYRING, service, username) + return BuiltInReference(CREDENTIAL_REF_KEYRING, service, username) return None @@ -425,6 +469,11 @@ def __post_init__(self) -> None: "DECLARATION rather than guessed at on a paid call") # A KEY INSIDE THE URL IS REFUSED FIRST (#1144 box 16.3), so no later # refusal, the scheme's among them, can repeat a URL that carries one. + # The length is checked before that, because the detector's work grows + # with the square of what it is given. + if len(self.endpoint) > _endpoint_bound(): + raise BindingRefused(ENDPOINT_TOO_LONG.format( + bound=_endpoint_bound())) if _carries_a_credential(self.endpoint): raise BindingRefused(ENDPOINT_CARRIES_A_CREDENTIAL) if not self.endpoint.startswith(ENDPOINT_SCHEMES): diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 201c1b61..5cc56be7 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -654,10 +654,25 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: # the built-in resolver (#1144 box 16.3; RULED R1Q17 (b), `5850003126`) # --------------------------------------------------------------------------- -#: What a resolved value may not carry. A line break or a NUL cannot travel in -#: a request header as it is, and trimming one out would present a credential -#: other than the one the reference names, so such a value is refused. -_UNPRESENTABLE_CHARACTERS = ("\r", "\n", "\x00") +def _presentable(value: object) -> bool: + """Whether a resolved value can be presented AS IT IS, as the bearer + credential of the request's Authorization header. + + It must be a non-empty string of printable ASCII with no whitespace, which + a bearer credential is by its grammar (RFC 6750's `b64token` is narrower + still). Any other value is refused, before any provider is contacted + (Copilot's overview of openDox-code#63). Measured against `urllib`: + * a line break or a NUL cannot travel in a header at all; + * a character outside latin-1 fails while the header is encoded. The + refusal from there would read `DIAG_PROVIDER_UNREACHABLE`, which names + the wrong party, and the `UnicodeEncodeError` it chains holds the + whole header, credential included; + * any other non-ASCII character, and an embedded space, is SENT, as a + credential the grammar does not allow. + Trimming or re-encoding the value would present a credential other than + the one the reference names, so the value is refused instead.""" + return (isinstance(value, str) and value != "" + and all("!" <= character <= "~" for character in value)) def _os_keyring(): @@ -692,28 +707,28 @@ def resolve_credential_reference(binding, *, environ=None, neither, and so reads this process's own environment and the OS keyring. Every failure is a FIXED refusal, raised before any provider is contacted. - An unset or blank variable, an absent keyring entry, or a value that could - not travel in a header is `DIAG_REFERENCE_UNRESOLVED`. A keyring that - cannot be read is `DIAG_KEYRING_UNAVAILABLE`. A keyring backend's own error - is dropped unread, like a broker's or a provider's.""" - parts = binding_mod.built_in_reference_parts(binding.credential_ref) - if parts is None: + An unset variable, an absent keyring entry, or a value that cannot be + presented as it is (`_presentable`) is `DIAG_REFERENCE_UNRESOLVED`. A + keyring that cannot be read is `DIAG_KEYRING_UNAVAILABLE`. A keyring + backend's own error is dropped unread, like a broker's or a provider's.""" + reference = binding_mod.built_in_reference_parts(binding.credential_ref) + if reference is None: raise AssertionError( f"binding {binding.id!r} names a broker's reference, which the " "broker resolves; the built-in resolver takes only the " f"{binding_mod.BUILT_IN_REFERENCE_FORMS} forms") - if parts[0] == binding_mod.CREDENTIAL_REF_ENV: - value = (os.environ if environ is None else environ).get(parts[1]) + if reference.form == binding_mod.CREDENTIAL_REF_ENV: + value = (os.environ if environ is None else environ).get( + reference.name) else: backend = (keyring_backend if keyring_backend is not None else _os_keyring()) try: - value = backend.get_password(parts[1], parts[2]) - except Exception: # noqa: BLE001 - a keyring backend's own error, of any class, never reaches a caller + value = backend.get_password(reference.name, reference.user) + # A keyring backend's own error, of any class, never reaches a caller. + except Exception: # noqa: BLE001 raise BrokerRefused(DIAG_KEYRING_UNAVAILABLE) from None - if (not isinstance(value, str) or not value.strip() - or any(character in value - for character in _UNPRESENTABLE_CHARACTERS)): + if not _presentable(value): raise BrokerRefused(DIAG_REFERENCE_UNRESOLVED) return value diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 151c6121..be8391f9 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -68,6 +68,8 @@ from opendox import doxbench_install as install_mod from opendox import doxbench_model as model_mod from opendox import doxbench_provider as provider_mod +from opendox.runtime import config as runtime_config +from opendox.runtime import local_git_adapter as git_adapter_mod # The credential a human types. A SENTINEL: long, unique, and impossible to # produce by accident, so a sweep that finds it has found the real thing. @@ -1874,6 +1876,58 @@ def test_a_clean_endpoint_is_accepted(endpoint): assert _binding(endpoint=endpoint, dialect=OPENAI_CHAT).endpoint == endpoint +def _endpoint_of_length(length: int, head: str) -> str: + endpoint = head + "a" * (length - len(head)) + assert len(endpoint) == length + return endpoint + + +def test_an_endpoint_past_the_url_bound_is_refused_before_the_detector( + monkeypatch): + """Copilot's overview of openDox-code#63. The detector is quadratic in a + parameter name's length, so the endpoint's length is checked first, + against the product's own bound, and the detector is not asked. The + refusal repeats nothing of the endpoint, and so nothing of a key in it.""" + bound = runtime_config.MAX_REMOTE_URL_CHARS + endpoint = _endpoint_of_length( + bound + 1, + f"https://api.example.invalid/v1?api_key={KEY_SENTINEL}&pad=") + + def _not_asked(_text): + raise AssertionError("the detector was asked about an endpoint past " + "the bound") + + monkeypatch.setattr(git_adapter_mod, "carries_a_credential", _not_asked) + with pytest.raises(binding_mod.BindingRefused) as caught: + _binding(endpoint=endpoint) + message = str(caught.value) + assert message == binding_mod.ENDPOINT_TOO_LONG.format(bound=bound) + assert KEY_SENTINEL not in message + assert "api.example.invalid" not in message + + +def test_an_endpoint_at_the_url_bound_is_declared(): + bound = runtime_config.MAX_REMOTE_URL_CHARS + endpoint = _endpoint_of_length( + bound, "https://api.example.invalid/v1/chat/completions?pad=") + binding = _binding(endpoint=endpoint, dialect=OPENAI_CHAT) + assert binding.endpoint == endpoint + + +def test_the_url_bound_is_the_products_own_read_when_it_is_asked( + monkeypatch): + """The number is `runtime/config`'s, read at declaration, so the record + and the repository act cannot come to hold different bounds.""" + monkeypatch.setattr(runtime_config, "MAX_REMOTE_URL_CHARS", 40) + head = "https://api.example.invalid/" + at_the_bound = _endpoint_of_length(40, head) + past_the_bound = _endpoint_of_length(41, head) + assert _binding(endpoint=at_the_bound).endpoint == at_the_bound + with pytest.raises(binding_mod.BindingRefused) as caught: + _binding(endpoint=past_the_bound) + assert str(caught.value) == binding_mod.ENDPOINT_TOO_LONG.format(bound=40) + + def test_a_key_in_an_extra_field_is_refused_as_it_always_was(): for field in ("api_key", "secret", "token", "password", "key"): record = dict(_binding().as_record(), **{field: KEY_SENTINEL}) @@ -1893,8 +1947,9 @@ def test_a_stored_document_whose_endpoint_carries_a_key_does_not_read( path.write_text(json.dumps({"schema_version": 1, "kind": binding_mod.BINDINGS_KIND, "bindings": [record]}), encoding="utf-8") + store = binding_mod.BindingStore(path) with pytest.raises(binding_mod.BindingRefused) as caught: - binding_mod.BindingStore(path).list() + store.list() assert KEY_SENTINEL not in str(caught.value) @@ -1920,7 +1975,8 @@ def test_the_none_kind_forbids_the_reference_and_the_broker(): """R1Q18 (a), both halves: declared explicitly, and both fields forbidden under it. A blank reference is still a reference given.""" binding = _none_binding() - assert binding.credential_ref is None and binding.broker_argv == () + assert binding.credential_ref is None + assert binding.broker_argv == () assert binding.credential_source() == binding_mod.NO_CREDENTIAL for given in (FAKE_REFERENCE, f"env:{ENV_NAME}", ""): with pytest.raises(binding_mod.BindingRefused) as caught: @@ -1966,17 +2022,27 @@ def test_a_brokers_reference_still_needs_its_broker(): def test_the_reference_forms_are_parsed_once(): + """One parser and ONE SHAPE for both forms, so no caller has to count + what it was given before it reads it.""" parts = binding_mod.built_in_reference_parts - assert parts(f"env:{ENV_NAME}") == ("env:", ENV_NAME) + env = parts(f"env:{ENV_NAME}") + assert env == binding_mod.BuiltInReference("env:", ENV_NAME, None) + keyring = parts(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}") # the split is at the LAST `/`, so a service may carry one - assert parts(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}") == ( + assert keyring == binding_mod.BuiltInReference( + "keyring:", KEYRING_SERVICE, KEYRING_USER) + assert (keyring.form, keyring.name, keyring.user) == ( "keyring:", KEYRING_SERVICE, KEYRING_USER) + assert len(env) == len(keyring) assert parts(FAKE_REFERENCE) is None assert binding_mod.BUILT_IN_REFERENCE_FORMS == ("env:", "keyring:") @pytest.mark.parametrize("reference", [ "env:", "env:1BAD", "env:A-B", "env: SPACED", "env:NAME\n", + # `\w` is held to ASCII: a letter or a digit of another script is not a + # portable variable name + "env:NAMÉ", "env:KEY١", f"env:{KEY_SENTINEL}", "keyring:", "keyring:service-only", "keyring:/user", "keyring:service/", "keyring: /user", f"keyring:{KEY_SENTINEL}", @@ -1998,7 +2064,8 @@ def test_a_none_record_round_trips_and_may_leave_its_forbidden_fields_out(): binding = _none_binding() record = binding.as_record() assert list(record) == ["kind", *binding_mod.BINDING_FIELDS] - assert record["credential_ref"] is None and record["broker_argv"] == [] + assert record["credential_ref"] is None + assert record["broker_argv"] == [] assert binding_mod.ModelProviderBinding.from_record(record) == binding del record["credential_ref"], record["broker_argv"] assert binding_mod.ModelProviderBinding.from_record(record) == binding @@ -2066,8 +2133,9 @@ def test_a_binding_no_broker_answers_has_no_broker_operation(): for operation in provider_mod.OPERATIONS: with pytest.raises(AssertionError): provider_mod.broker_operation_argv(binding, operation) + stdin = io.StringIO("x") with pytest.raises(AssertionError): - provider_mod.hand_off_credential(binding, io.StringIO("x")) + provider_mod.hand_off_credential(binding, stdin) # --- the built-in resolver, at call time --------------------------------- @@ -2134,24 +2202,67 @@ def test_production_reads_this_processs_own_environment(monkeypatch): @pytest.mark.parametrize("environ", [ {}, {ENV_NAME: ""}, {ENV_NAME: " "}, {ENV_NAME: "sk-stand-in\nX-Other: 1"}, - {ENV_NAME: "sk-stand-in\x00"}], - ids=["unset", "empty", "blank", "line-break", "nul"]) + {ENV_NAME: "sk-stand-in\x00"}, + # a bearer credential is printable ASCII with no whitespace (Copilot's + # overview of openDox-code#63), so nothing else is presented + {ENV_NAME: f"{KEY_SENTINEL}€"}, {ENV_NAME: f"{KEY_SENTINEL}é"}, + {ENV_NAME: "sk-stand-in NOT-A-KEY"}, {ENV_NAME: "sk-stand-in\tNOT-A-KEY"}, + {ENV_NAME: f"{KEY_SENTINEL} "}, {ENV_NAME: f"{KEY_SENTINEL}\x7f"}], + ids=["unset", "empty", "blank", "line-break", "nul", "outside-latin-1", + "latin-1-not-ascii", "embedded-space", "tab", "trailing-space", + "delete"]) def test_an_unusable_env_value_refuses_before_any_request(environ): port, opener = _unbrokered_port(_built_in_binding(), _chat_completion(), environ=environ) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_REFERENCE_UNRESOLVED assert opener.requests == [], "no provider was contacted" assert port.catalog().entries[0].available is False +def test_a_value_outside_latin_1_is_refused_before_any_header_is_built(): + """Over a real socket, because the failure was `urllib`'s. Before the + check, such a value failed while the header was encoded: the refusal read + `DIAG_PROVIDER_UNREACHABLE`, and it chained a `UnicodeEncodeError` whose + `object` held the whole header, credential included (measured). Now it is + the resolver's own fixed refusal, nothing is chained, and no request is + sent.""" + _ChatCompletionsHandler.seen = {} + with _stand_in_provider(_ChatCompletionsHandler) as base: + binding = _built_in_binding(endpoint=f"{base}/v1/chat/completions") + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + runner=_refusing_runner, notice=lambda _text: None, + environ={ENV_NAME: f"{KEY_SENTINEL}€"}) + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + assert caught.value.diagnostic == provider_mod.DIAG_REFERENCE_UNRESOLVED + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert _ChatCompletionsHandler.seen == {}, "no request reached the server" + + +def test_a_value_of_printable_ascii_is_presented_as_it_is(): + """The check refuses what a bearer credential cannot be and nothing more: + every printable ASCII character but the space is presented unchanged.""" + value = "sk-" + "".join(chr(code) for code in range(0x21, 0x7F)) + port, opener = _unbrokered_port(_built_in_binding(), + _chat_completion("a"), + environ={ENV_NAME: value}) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + assert opener.requests[0].get_header("Authorization") == f"Bearer {value}" + + def test_a_reference_that_resolves_again_makes_the_entry_available_again(): environ: dict[str, str] = {} port, _opener = _unbrokered_port(_built_in_binding(), _chat_completion("a"), environ=environ) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused): - port.dispatch(_Envelope()) + port.dispatch(envelope) assert port.catalog().entries[0].available is False environ[ENV_NAME] = KEY_SENTINEL assert port.dispatch(_Envelope())["assistant_prose"] == "a" @@ -2171,12 +2282,18 @@ def test_a_keyring_reference_reads_the_os_keyring_at_call_time(): f"Bearer {KEY_SENTINEL}" -def test_an_absent_keyring_entry_refuses_unresolved(): +@pytest.mark.parametrize("stored", [ + None, b"sk-stand-in-NOT-A-KEY", "", f"{KEY_SENTINEL}€"], + ids=["absent", "not-text", "empty", "outside-latin-1"]) +def test_an_absent_or_unusable_keyring_entry_refuses_unresolved(stored): + entries = ({} if stored is None + else {(KEYRING_SERVICE, KEYRING_USER): stored}) port, opener = _unbrokered_port( _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), - _chat_completion(), keyring_backend=_FakeKeyring()) + _chat_completion(), keyring_backend=_FakeKeyring(entries)) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_REFERENCE_UNRESOLVED assert opener.requests == [] @@ -2186,8 +2303,9 @@ def test_a_keyring_that_cannot_be_read_refuses_and_says_nothing_of_its_own(): port, opener = _unbrokered_port( _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), _chat_completion(), keyring_backend=backend) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_KEYRING_UNAVAILABLE assert "leaks" not in str(caught.value) assert caught.value.__cause__ is None @@ -2201,8 +2319,9 @@ def test_without_the_keyring_package_a_keyring_reference_refuses(monkeypatch): port, opener = _unbrokered_port( _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), _chat_completion()) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_KEYRING_UNAVAILABLE assert opener.requests == [] @@ -2236,8 +2355,9 @@ def test_a_401_without_a_broker_is_a_refusal_not_a_retry(which): port, opener = _unbrokered_port(binding, _expired_error(), _chat_completion("never reached"), environ={ENV_NAME: KEY_SENTINEL}) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_REFUSED assert len(opener.requests) == 1, "no second paid call" assert port.ledger == [] From 68b6e412446c4611a6fa3cd1590aac0f2b6f4893 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:42:37 +0000 Subject: [PATCH 07/45] T080: the stand-in server's record is reset through monkeypatch (SonarCloud) SonarCloud's analysis of openDox-code#63 at f92fca47 flagged one line of the new real-socket test: it reset the stand-in handler's class-level record by assignment (S8997). The test now takes monkeypatch, so the record is restored when the test ends. The test file's four new non-ASCII literals are written as \u escapes, so the source shows which code point each case uses. The string values are unchanged, and the case count is unchanged. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_provider_broker.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index be8391f9..225441ae 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -2042,7 +2042,7 @@ def test_the_reference_forms_are_parsed_once(): "env:", "env:1BAD", "env:A-B", "env: SPACED", "env:NAME\n", # `\w` is held to ASCII: a letter or a digit of another script is not a # portable variable name - "env:NAMÉ", "env:KEY١", + "env:NAM\u00c9", "env:KEY\u0661", f"env:{KEY_SENTINEL}", "keyring:", "keyring:service-only", "keyring:/user", "keyring:service/", "keyring: /user", f"keyring:{KEY_SENTINEL}", @@ -2205,7 +2205,7 @@ def test_production_reads_this_processs_own_environment(monkeypatch): {ENV_NAME: "sk-stand-in\x00"}, # a bearer credential is printable ASCII with no whitespace (Copilot's # overview of openDox-code#63), so nothing else is presented - {ENV_NAME: f"{KEY_SENTINEL}€"}, {ENV_NAME: f"{KEY_SENTINEL}é"}, + {ENV_NAME: f"{KEY_SENTINEL}\u20ac"}, {ENV_NAME: f"{KEY_SENTINEL}\u00e9"}, {ENV_NAME: "sk-stand-in NOT-A-KEY"}, {ENV_NAME: "sk-stand-in\tNOT-A-KEY"}, {ENV_NAME: f"{KEY_SENTINEL} "}, {ENV_NAME: f"{KEY_SENTINEL}\x7f"}], ids=["unset", "empty", "blank", "line-break", "nul", "outside-latin-1", @@ -2222,20 +2222,21 @@ def test_an_unusable_env_value_refuses_before_any_request(environ): assert port.catalog().entries[0].available is False -def test_a_value_outside_latin_1_is_refused_before_any_header_is_built(): +def test_a_value_outside_latin_1_is_refused_before_any_header_is_built( + monkeypatch): """Over a real socket, because the failure was `urllib`'s. Before the check, such a value failed while the header was encoded: the refusal read `DIAG_PROVIDER_UNREACHABLE`, and it chained a `UnicodeEncodeError` whose `object` held the whole header, credential included (measured). Now it is the resolver's own fixed refusal, nothing is chained, and no request is sent.""" - _ChatCompletionsHandler.seen = {} + monkeypatch.setattr(_ChatCompletionsHandler, "seen", {}) with _stand_in_provider(_ChatCompletionsHandler) as base: binding = _built_in_binding(endpoint=f"{base}/v1/chat/completions") port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), runner=_refusing_runner, notice=lambda _text: None, - environ={ENV_NAME: f"{KEY_SENTINEL}€"}) + environ={ENV_NAME: f"{KEY_SENTINEL}\u20ac"}) envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: port.dispatch(envelope) @@ -2283,7 +2284,7 @@ def test_a_keyring_reference_reads_the_os_keyring_at_call_time(): @pytest.mark.parametrize("stored", [ - None, b"sk-stand-in-NOT-A-KEY", "", f"{KEY_SENTINEL}€"], + None, b"sk-stand-in-NOT-A-KEY", "", f"{KEY_SENTINEL}\u20ac"], ids=["absent", "not-text", "empty", "outside-latin-1"]) def test_an_absent_or_unusable_keyring_entry_refuses_unresolved(stored): entries = ({} if stored is None From 146b5a22530acfe9daec7219b3712df87e2d1027 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:06:09 +0000 Subject: [PATCH 08/45] T080: the console flow's kinds are the ones a broker enrols, said and pinned With `none` in AUTH_KINDS, the console's intake flow offers a subset of the vocabulary: api_key and oauth, the kinds a broker enrols. A `none` binding holds no credential, so the flow, which exists to hand one to a broker, has nothing to collect for it. The operator declares one with `model-binding add --auth-kind none` instead. doxbench_intake.auth_kind_disclosure's docstring now says so, and a new test pins the relationship, in the vocabulary's order. The disclosure's code is unchanged. The docstring sits outside P3-B's row; the PR body flags it. The test named "this_processs_own_environment" is renamed to "the_serving_process_environment". Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_intake.py | 6 +++++- tests/test_model_provider_broker.py | 12 +++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/opendox/doxbench_intake.py b/src/opendox/doxbench_intake.py index a51d9cfa..de6f4faa 100644 --- a/src/opendox/doxbench_intake.py +++ b/src/opendox/doxbench_intake.py @@ -233,7 +233,11 @@ def auth_kind_disclosure() -> list[dict]: """The authentication kinds this flow offers, as a surface may disclose them. READ FROM `doxbench_binding.AUTH_KINDS`, never respelled, so the flow and the - record it writes cannot drift into two vocabularies. `accepts_secret` is the + record it writes cannot drift into two vocabularies. The flow offers the + kinds a broker enrols, which is every member but `none` (#1144 box 16.3). A + `none` binding holds no credential, so this flow, which exists to hand one + to a broker, has nothing to collect for it; the operator declares it with + `model-binding add --auth-kind none` instead. `accepts_secret` is the fact a renderer actually needs: it is what decides whether a field that would take a credential is presented at all, and it is stated here — on the server, beside the vocabulary — rather than inferred in a browser from the kind's diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 225441ae..f7a648ed 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -66,6 +66,7 @@ from opendox import cli as cli_mod from opendox import doxbench_binding as binding_mod from opendox import doxbench_install as install_mod +from opendox import doxbench_intake as intake_mod from opendox import doxbench_model as model_mod from opendox import doxbench_provider as provider_mod from opendox.runtime import config as runtime_config @@ -1846,6 +1847,15 @@ def test_f16_1_the_first_auth_kind_still_takes_a_credential(): assert binding_mod.AUTH_KINDS[-1] == binding_mod.AUTH_KIND_NONE == "none" +def test_the_console_flow_offers_every_kind_a_broker_enrols(): + """The console's intake flow hands a credential to a broker, so it offers + every member of `AUTH_KINDS` but `none`, in the vocabulary's order. A + `none` binding holds no credential and is declared at the operator door.""" + offered = [entry["kind"] for entry in intake_mod.auth_kind_disclosure()] + assert offered == [kind for kind in binding_mod.AUTH_KINDS + if kind != binding_mod.AUTH_KIND_NONE] + + @pytest.mark.parametrize("endpoint", [ f"https://user:{KEY_SENTINEL}@api.example.invalid/v1", f"https://{KEY_SENTINEL}@api.example.invalid/v1", @@ -2191,7 +2201,7 @@ def test_an_env_reference_is_read_at_call_time_and_presented_as_the_bearer(): assert port.ledger == [], "nothing was minted" -def test_production_reads_this_processs_own_environment(monkeypatch): +def test_production_reads_the_serving_process_environment(monkeypatch): monkeypatch.setenv(ENV_NAME, KEY_SENTINEL) port, opener = _unbrokered_port(_built_in_binding(), _chat_completion("a")) From 4abc6d4de4480f4d31376b876b30062d153e641f Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:37:20 +0000 Subject: [PATCH 09/45] T080: a built-in credential travels only over https:// or to this host Brett Heap ruled on the question openDox-code#63 raised, on 2026-09-28, choosing "Refuse unless loopback (Recommended)"; the lane's holder relayed the ruling. A credential the built-in resolver reads (an env: or keyring: reference) is a long-lived key, so it is sent only over https://, or over http:// to 127.0.0.1, ::1 or localhost. Any other http:// endpoint is refused before resolution. - doxbench_binding.is_a_private_route is the one predicate. It matches the endpoint as written, case-blind, against https:// or http:// to one of LOOPBACK_HOSTS with an optional port. A URL parser's reading is not used, because it and the HTTP client read "http://evil.example\@localhost/" as two different hosts. - The record refuses a built-in reference on any other route when it is declared, from the command line or a stored record, with one fixed sentence, ENDPOINT_NOT_PRIVATE, that repeats nothing of the endpoint. - The built-in resolver asks the same predicate before it reads anything. No declared binding reaches that check, so what does is a programming error, raised as an AssertionError with nothing read, as the resolver already does for a broker's reference. - The broker path is left as it is, as the ruling says: a minted token may still be declared over plain http:// to any host. The auth kind none presents no credential and keeps its route too. 32 new cases, including IPv6 loopback, the lookalike host "localhost.evil.com" and mixed-case schemes. Seven mutants of the rule were each killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 62 ++++++++++- src/opendox/doxbench_provider.py | 17 ++- tests/test_model_provider_broker.py | 156 ++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 2 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index ed4db9b0..cf3ef971 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -37,7 +37,10 @@ * the BUILT-IN RESOLVER, for an `env:NAME` or `keyring:SERVICE/USERNAME` reference. It reads the reference at call time, inside `doxbench_provider` only (RULED R1Q17 (b)). Such a record needs no broker, and one given - beside it is refused, so no record has two resolvers; + beside it is refused, so no record has two resolvers. What it reads is a + LONG-LIVED key, so its endpoint must be a PRIVATE ROUTE: `https://`, or + `http://` to 127.0.0.1, ::1 or localhost (Brett Heap's ruling of + 2026-09-28, "Refuse unless loopback"; `is_a_private_route`); * NONE, for an endpoint that takes no credential. It declares the auth kind `none` rather than leaving a field out, and `credential_ref` and `broker_argv` are forbidden under it (RULED R1Q18 (a)). @@ -210,8 +213,59 @@ class BuiltInReference(NamedTuple): #: on-this-host proxy posture an operator may legitimately run; a scheme this #: tuple does not name is refused at declaration, because `file://` or a bare #: host is not something a provider client should discover at dispatch time. +#: For a credential the built-in resolver reads, "on this host" is ENFORCED +#: (`is_a_private_route`). A broker's minted token and the auth kind `none` +#: keep the posture this tuple gives them, as the 2026-09-28 ruling leaves it. ENDPOINT_SCHEMES: tuple[str, ...] = ("https://", "http://") +#: The hosts a credential the built-in resolver reads may reach over plain +#: `http://`: this host, spelled exactly as Brett Heap's ruling of 2026-09-28 +#: names it ("Refuse unless loopback"). No other spelling of these addresses, +#: and no other address of the loopback range, is one of them. +LOOPBACK_HOSTS: tuple[str, ...] = ("127.0.0.1", "::1", "localhost") + + +def _authority(host: str) -> str: + """`host` as a URL's authority spells it: an IPv6 literal is bracketed.""" + return f"[{host}]" if ":" in host else host + + +#: A PRIVATE ROUTE: `https://` to any host, or `http://` to one of +#: `LOOPBACK_HOSTS`, with an optional port, then the path, the query, the +#: fragment or nothing at all. It is matched against the endpoint AS WRITTEN, +#: not against a parser's reading of it. A URL parser and the HTTP client read +#: `http://evil.example\@localhost/` as two different hosts, and only the +#: client's reading decides where the key would go. It is case-blind, because +#: a scheme and a host name are: `HTTP://` is `http://`. +_PRIVATE_ROUTE = re.compile( + r"https://|http://(?:" + + "|".join(re.escape(_authority(host)) for host in LOOPBACK_HOSTS) + + r")(?::[0-9]{1,5})?(?:[/?#]|\Z)", + re.IGNORECASE | re.ASCII) + + +def is_a_private_route(endpoint: object) -> bool: + """Whether `endpoint` keeps a credential from crossing a network in + cleartext: `https://`, or `http://` to this host (`LOOPBACK_HOSTS`). + + ONE PREDICATE. The record asks it when a binding is declared, and + `doxbench_provider`'s built-in resolver asks it again before it reads + anything.""" + return (isinstance(endpoint, str) + and _PRIVATE_ROUTE.match(endpoint) is not None) + + +#: The refusal a credential the built-in resolver reads earns on a route that +#: is not private (Brett Heap's ruling of 2026-09-28, "Refuse unless +#: loopback"). That resolver reads a LONG-LIVED key, where a broker mints a +#: short-lived token, so plain `http://` carries one only to this host. A +#: fixed sentence, and it repeats nothing of the endpoint. +ENDPOINT_NOT_PRIVATE = ( + "a credential the built-in resolver reads (an env: or keyring: reference) " + "is sent only over https://, or over http:// to this host (127.0.0.1, ::1 " + "or localhost), and this endpoint is neither; declare an https:// " + "endpoint, or a loopback one") + #: The refusal a key inside the endpoint URL earns (#1144 box 16.3). Measured #: before 16.3: this record checked the endpoint's scheme and nothing else, so #: `https://user:@…` and `…?api_key=` were both ACCEPTED, into a file @@ -500,6 +554,12 @@ def __post_init__(self) -> None: f"broker_argv names the placeholder {{{name}}}, which " f"is outside the closed vocabulary {ARGV_PLACEHOLDERS}") self._require_one_resolver(argv) + # A CREDENTIAL THE BUILT-IN RESOLVER READS TRAVELS ONLY BY A PRIVATE + # ROUTE (the 2026-09-28 ruling). A broker's minted token and the auth + # kind `none` keep the route they had. + if (self.credential_source() == CREDENTIAL_FROM_BUILT_IN_RESOLVER + and not is_a_private_route(self.endpoint)): + raise BindingRefused(ENDPOINT_NOT_PRIVATE) def _require_one_resolver(self, argv: tuple[str, ...]) -> None: """#1144 box 16.3: exactly one thing answers this record's credential. diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 5cc56be7..c5555669 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -710,13 +710,28 @@ def resolve_credential_reference(binding, *, environ=None, An unset variable, an absent keyring entry, or a value that cannot be presented as it is (`_presentable`) is `DIAG_REFERENCE_UNRESOLVED`. A keyring that cannot be read is `DIAG_KEYRING_UNAVAILABLE`. A keyring - backend's own error is dropped unread, like a broker's or a provider's.""" + backend's own error is dropped unread, like a broker's or a provider's. + + NOTHING IS READ FOR A ROUTE THAT IS NOT PRIVATE (Brett Heap's ruling of + 2026-09-28, "Refuse unless loopback"). What this function reads is a + long-lived key, sent only over `https://` or over `http://` to this host. + The record refuses any other endpoint when the binding is declared + (`doxbench_binding.ENDPOINT_NOT_PRIVATE`), so no declared binding reaches + that check here. The check is repeated before the first read all the + same, because this is the function that holds the key. What reaches it + is a programming error, like a broker's reference, and nothing has been + read when it is raised.""" reference = binding_mod.built_in_reference_parts(binding.credential_ref) if reference is None: raise AssertionError( f"binding {binding.id!r} names a broker's reference, which the " "broker resolves; the built-in resolver takes only the " f"{binding_mod.BUILT_IN_REFERENCE_FORMS} forms") + if not binding_mod.is_a_private_route(binding.endpoint): + raise AssertionError( + f"binding {binding.id!r} routes a credential the built-in " + "resolver reads over a route that is not private, which the " + "record refuses when it is declared; nothing was read") if reference.form == binding_mod.CREDENTIAL_REF_ENV: value = (os.environ if environ is None else environ).get( reference.name) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index f7a648ed..c7ce29a7 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -55,6 +55,7 @@ import sys import threading import time +import types import urllib.error from datetime import datetime, timezone from pathlib import Path @@ -2148,6 +2149,147 @@ def test_a_binding_no_broker_answers_has_no_broker_operation(): provider_mod.hand_off_credential(binding, stdin) +# --- a built-in credential travels by a private route -------------------- +# Brett Heap's ruling of 2026-09-28 on this PR's question, "Refuse unless +# loopback": a credential the built-in resolver reads is sent only over +# https://, or over http:// to 127.0.0.1, ::1 or localhost. + +BUILT_IN_REFERENCES = (f"env:{ENV_NAME}", + f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}") +BACKSLASH = chr(92) + + +@pytest.mark.parametrize("endpoint", [ + "https://api.example.invalid/v1/chat/completions", + "http://127.0.0.1:8080/v1/chat/completions", + "http://[::1]:8080/v1/chat/completions", + "http://localhost:11434/v1/chat/completions", + "http://LOCALHOST:11434/v1/chat/completions", + "http://localhost", +], ids=["https", "ipv4-loopback", "ipv6-loopback", "localhost", + "localhost-in-capitals", "no-path"]) +def test_a_built_in_credential_is_declared_on_a_private_route(endpoint): + for reference in BUILT_IN_REFERENCES: + binding = _built_in_binding(reference, endpoint=endpoint) + assert binding.endpoint == endpoint + assert binding.credential_source() == ( + binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER) + + +@pytest.mark.parametrize("endpoint", [ + "http://api.example.invalid/v1/chat/completions", + "http://localhost.evil.com/v1/chat/completions", + "http://127.0.0.1.evil.com/v1/chat/completions", + "http://evil.com/localhost", + "http://127.0.0.2:8080/v1", + "http://[0:0:0:0:0:0:0:1]:8080/v1", + "http://localhost./v1", + "http://localhost%2eevil.com/v1", + "http://0.0.0.0:8080/v1", +], ids=["another-host", "resembles-localhost", "resembles-127", + "localhost-only-in-the-path", "a-loopback-address-not-named", + "another-spelling-of-ipv6-loopback", "trailing-dot", + "percent-encoded-dot", "unspecified-address"]) +def test_a_built_in_credential_over_http_to_another_host_is_refused(endpoint): + """Refused when it is declared, by the constructor and from a stored + record alike, with the one fixed sentence.""" + for reference in BUILT_IN_REFERENCES: + with pytest.raises(binding_mod.BindingRefused) as caught: + _built_in_binding(reference, endpoint=endpoint) + assert str(caught.value) == binding_mod.ENDPOINT_NOT_PRIVATE + record = dict(_built_in_binding(reference).as_record(), + endpoint=endpoint) + with pytest.raises(binding_mod.BindingRefused) as caught: + binding_mod.ModelProviderBinding.from_record(record) + assert str(caught.value) == binding_mod.ENDPOINT_NOT_PRIVATE + + +@pytest.mark.parametrize("endpoint,private", [ + ("HTTP://api.example.invalid/v1", False), + ("Http://localhost.evil.com/v1", False), + ("hTTp://127.0.0.1:8080/v1", True), + ("HTTP://[::1]:8080/v1", True), + ("http://LocalHost:11434/v1", True), + ("HTTPS://api.example.invalid/v1", True), + ("hTtPs://api.example.invalid/v1", True), + (f"http://evil.example{BACKSLASH}@localhost/v1", False), + (" http://localhost/v1", False), + ("ftp://localhost/v1", False), +], ids=["capital-http-to-another-host", "mixed-case-http-to-a-lookalike", + "mixed-case-http-to-127", "capital-http-to-ipv6-loopback", + "mixed-case-localhost", "capital-https", "mixed-case-https", + "backslash-before-localhost", "leading-space", "another-scheme"]) +def test_a_private_route_is_read_case_blind_and_as_written(endpoint, + private): + """A scheme and a host name are case-blind, so `HTTP://` to another host + is not private and `HTTP://` to this one is. The route is read AS + WRITTEN: a URL parser reads the backslash case's host as `localhost`, and + the HTTP client reads it as the whole authority.""" + assert binding_mod.is_a_private_route(endpoint) is private + + +class _RecordingEnviron(dict): + """An environment that records every name read from it.""" + + def __init__(self, *args): + super().__init__(*args) + self.read: list[str] = [] + + def get(self, name, default=None): + self.read.append(name) + return super().get(name, default) + + +@pytest.mark.parametrize("endpoint", [ + "http://api.example.invalid/v1", "HTTP://api.example.invalid/v1", + "http://localhost.evil.com/v1", + f"http://evil.example{BACKSLASH}@localhost/v1", +], ids=["another-host", "mixed-case-scheme", "resembles-localhost", + "backslash"]) +def test_the_resolver_reads_nothing_for_a_route_that_is_not_private( + endpoint): + """BEFORE RESOLUTION, in the resolver itself. The record refuses such a + binding when it is declared, so this is a binding-shaped object that was + never declared, and it still cannot make the resolver read a key.""" + for reference in BUILT_IN_REFERENCES: + shaped = types.SimpleNamespace(id="undeclared", + credential_ref=reference, + endpoint=endpoint) + environ = _RecordingEnviron({ENV_NAME: KEY_SENTINEL}) + backend = _FakeKeyring({(KEYRING_SERVICE, KEYRING_USER): KEY_SENTINEL}) + with pytest.raises(AssertionError) as caught: + provider_mod.resolve_credential_reference( + shaped, environ=environ, keyring_backend=backend) + assert "nothing was read" in str(caught.value) + assert environ.read == [] + assert backend.asked == [] + + +def test_the_resolver_reads_a_key_for_a_private_route(): + """The control for the case above: the same shape on IPv6 loopback is + read.""" + shaped = types.SimpleNamespace(id="undeclared", + credential_ref=f"env:{ENV_NAME}", + endpoint="http://[::1]:8080/v1") + environ = _RecordingEnviron({ENV_NAME: KEY_SENTINEL}) + assert provider_mod.resolve_credential_reference( + shaped, environ=environ) == KEY_SENTINEL + assert environ.read == [ENV_NAME] + + +@pytest.mark.parametrize("endpoint", [ + "http://api.example.invalid/turn", "http://localhost.evil.com/turn"]) +def test_the_loopback_rule_is_the_built_in_resolvers_alone(endpoint): + """The ruling leaves the broker path as it is today: a broker's minted + token may still be declared over plain http:// to any host, which is the + pre-existing gap the PR notes. The auth kind `none` presents no + credential, so it keeps its route too.""" + assert _binding(endpoint=endpoint).credential_source() == ( + binding_mod.CREDENTIAL_FROM_BROKER) + assert _none_binding(endpoint=endpoint).credential_source() == ( + binding_mod.NO_CREDENTIAL) + + # --- the built-in resolver, at call time --------------------------------- @@ -2495,6 +2637,20 @@ def run(*argv) -> int: assert KEY_SENTINEL not in captured.err + captured.out assert store.get("keyed") is None, "nothing is stored" + # a built-in credential over http:// to another host is refused (the + # 2026-09-28 loopback ruling), and a `none` binding to it is declared + cleartext = list(route) + cleartext[cleartext.index("--endpoint") + 1] = ( + "http://api.example.invalid/v1/chat/completions") + assert run("model-binding", "add", *root, "--id", "cleartext", + *cleartext, "--auth-kind", "api_key", + "--credential-ref", f"env:{ENV_NAME}") == 1 + assert binding_mod.ENDPOINT_NOT_PRIVATE in capsys.readouterr().err + assert store.get("cleartext") is None, "nothing is stored" + assert run("model-binding", "add", *root, "--id", "cleartext-none", + *cleartext, "--auth-kind", "none") == 0 + capsys.readouterr() + assert run("model-binding", "list", *root) == 0 listed = capsys.readouterr().out from opendox import cli_model_binding as cmb From d240fd50b521b78411e89ecc8a6f12c4b95b1bd7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:46:49 +0000 Subject: [PATCH 10/45] T080: the endpoint's checks and the private-route rule move into two helpers (SonarCloud) SonarCloud's analysis of openDox-code#63 at 4abc6d4d measured the binding's __post_init__ at a cognitive complexity of 17, over the 15 allowed (S3776). The endpoint's own checks (the length bound, the key detector and the scheme, in that order) move into _require_a_declarable_endpoint, and the loopback rule into _require_a_private_route. The order of every check is unchanged, and so is every refusal. The whole suite and the seven mutants of the rule give the same results as at 4abc6d4d. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 42 ++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index cf3ef971..57359d2e 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -521,19 +521,7 @@ def __post_init__(self) -> None: f"dialect {self.dialect!r} is outside the closed vocabulary " f"{DIALECTS}; an unknown request grammar is refused at " "DECLARATION rather than guessed at on a paid call") - # A KEY INSIDE THE URL IS REFUSED FIRST (#1144 box 16.3), so no later - # refusal, the scheme's among them, can repeat a URL that carries one. - # The length is checked before that, because the detector's work grows - # with the square of what it is given. - if len(self.endpoint) > _endpoint_bound(): - raise BindingRefused(ENDPOINT_TOO_LONG.format( - bound=_endpoint_bound())) - if _carries_a_credential(self.endpoint): - raise BindingRefused(ENDPOINT_CARRIES_A_CREDENTIAL) - if not self.endpoint.startswith(ENDPOINT_SCHEMES): - raise BindingRefused( - f"endpoint {self.endpoint!r} does not name one of " - f"{ENDPOINT_SCHEMES}") + self._require_a_declarable_endpoint() if isinstance(self.broker_argv, (str, bytes)): raise BindingRefused( "broker_argv must be a sequence of argv members, not a single " @@ -554,9 +542,31 @@ def __post_init__(self) -> None: f"broker_argv names the placeholder {{{name}}}, which " f"is outside the closed vocabulary {ARGV_PLACEHOLDERS}") self._require_one_resolver(argv) - # A CREDENTIAL THE BUILT-IN RESOLVER READS TRAVELS ONLY BY A PRIVATE - # ROUTE (the 2026-09-28 ruling). A broker's minted token and the auth - # kind `none` keep the route they had. + self._require_a_private_route() + + def _require_a_declarable_endpoint(self) -> None: + """The endpoint's own checks, in the order that keeps a key out of + every refusal (#1144 box 16.3). + + The length is checked first, because the detector's work grows with + the square of what it is given. A KEY INSIDE THE URL IS REFUSED NEXT, + so no later refusal, the scheme's among them, can repeat a URL that + carries one.""" + if len(self.endpoint) > _endpoint_bound(): + raise BindingRefused(ENDPOINT_TOO_LONG.format( + bound=_endpoint_bound())) + if _carries_a_credential(self.endpoint): + raise BindingRefused(ENDPOINT_CARRIES_A_CREDENTIAL) + if not self.endpoint.startswith(ENDPOINT_SCHEMES): + raise BindingRefused( + f"endpoint {self.endpoint!r} does not name one of " + f"{ENDPOINT_SCHEMES}") + + def _require_a_private_route(self) -> None: + """A CREDENTIAL THE BUILT-IN RESOLVER READS TRAVELS ONLY BY A PRIVATE + ROUTE (Brett Heap's ruling of 2026-09-28, "Refuse unless loopback"; + `is_a_private_route`). A broker's minted token and the auth kind + `none` keep the route they had, as the ruling leaves them.""" if (self.credential_source() == CREDENTIAL_FROM_BUILT_IN_RESOLVER and not is_a_private_route(self.endpoint)): raise BindingRefused(ENDPOINT_NOT_PRIVATE) From 5167084cf4f147e071ad4bed17a35104a03cb59e Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:59:31 +0000 Subject: [PATCH 11/45] T080: a request carrying a built-in credential follows no redirect Copilot's review of openDox-code#63 at 4abc6d4d (high severity): the default urllib opener follows redirects, and it re-sends every header but the content ones to the Location. Measured with two loopback servers: a POST answered 301, 302 or 303 reached the redirect's target as a GET that still carried "Authorization: Bearer ...". So a private endpoint could hand a long-lived key to any host and any scheme, which the loopback ruling of 2026-09-28 forbids. A request that carries a credential the built-in resolver read now uses _open_without_redirects. Its _DeclineRedirects handler closes the redirect's answer unread and declines, and the port answers with a new fixed diagnostic, DIAG_PROVIDER_REDIRECTED. FIXED_DIAGNOSTICS goes from ten to eleven. An injected opener is still used as given. The auth kind none sends no credential, and the broker path keeps the default opener, as the ruling leaves that path. Five new cases over real sockets (301, 302, 303, 307 and 308). The second server hears nothing. Two mutants were each killed: the opener swap removed, and the redirect declined silently. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 66 ++++++++++++++++++++++++-- tests/test_model_provider_broker.py | 73 +++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 8 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index c5555669..ae61664e 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -251,9 +251,19 @@ "the OS keyring could not be read by this process, so the keyring " "reference could not be resolved") +#: The answer to a redirect of a request that carried a credential the +#: built-in resolver read. That request follows no redirect (see +#: `_DeclineRedirects`), so the credential went to the declared endpoint and +#: nowhere else, and the sentence says what to declare instead. +DIAG_PROVIDER_REDIRECTED = ( + "the provider answered with a redirect, which a credential the built-in " + "resolver reads does not follow, so it was sent nowhere else; declare " + "the endpoint the provider redirects to") + #: The closed set, so a test can assert no other sentence can be raised. -#: TEN: the eight the reconciliation left, and the built-in resolver's two -#: (#1144 box 16.3). `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded +#: ELEVEN: the eight the reconciliation left, the built-in resolver's two +#: (#1144 box 16.3), and the redirect a request carrying a built-in +#: credential declines. `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded #: moved: the dialect is the BINDING's, validated against the closed vocabulary #: when the operator declares it #: (`doxbench_binding.ModelProviderBinding.__post_init__`), so an unknown @@ -265,6 +275,7 @@ DIAG_BROKER_TIMEOUT, DIAG_PROVIDER_UNREACHABLE, DIAG_PROVIDER_REFUSED, DIAG_PROVIDER_MALFORMED, DIAG_TOKEN_EXPIRED_TWICE, DIAG_REFERENCE_UNRESOLVED, DIAG_KEYRING_UNAVAILABLE, + DIAG_PROVIDER_REDIRECTED, }) @@ -836,6 +847,40 @@ def _chat_answer(document: dict) -> str: } +class _Redirected(Exception): + """A provider answered a request carrying a built-in credential with a + redirect, and the redirect was declined. + + PRIVATE and never raised out of this module: the port answers it with + `DIAG_PROVIDER_REDIRECTED` before any caller sees anything.""" + + +class _DeclineRedirects(urllib.request.HTTPRedirectHandler): + """A redirect handler that follows NO redirect (Copilot's review of + openDox-code#63 at `4abc6d4d`). + + `urllib`'s own handler re-sends a request's headers, all but the content + ones, to whatever `Location` the provider names, whatever its host and + scheme. Measured: a POST answered 301, 302 or 303 reaches the redirect's + target as a GET that still carries `Authorization: Bearer ...`. The + loopback ruling of 2026-09-28 sends a credential the built-in resolver + reads only by a private route, and a followed redirect would send it by + any route. So a request that carries one declines every redirect, with + the redirect's answer closed unread.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + fp.close() + raise _Redirected + + +def _open_without_redirects(request, *, timeout): + """`urllib.request.urlopen`, but every redirect is declined. The opener + is built per call, as `urlopen` builds its own on first use, so the + proxy environment is read when a request is made.""" + return urllib.request.build_opener(_DeclineRedirects).open( + request, timeout=timeout) + + def _post_to_provider(*, endpoint: str, dialect: str, credential: str | None, model: str, prompt: str, timeout: float, opener) -> str: """The ONE place a provider is contacted. Returns the assistant prose. @@ -1123,7 +1168,15 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: A 401 HERE IS A REFUSAL, NOT AN EXPIRY. The 2026-08-26 retry ruling is about a MINTED token outliving its turn, and here there is no mint to repeat: the reference names the same value on a second read, so a - retry would buy a second paid call for the same refusal.""" + retry would buy a second paid call for the same refusal. + + A REQUEST CARRYING A BUILT-IN CREDENTIAL FOLLOWS NO REDIRECT. The + default opener follows redirects and re-sends the credential header + (see `_DeclineRedirects`), so such a request swaps it for + `_open_without_redirects`. An opener a caller injected is that + caller's own seam and is used as given. The auth kind `none` sends no + credential, and a broker's minted token keeps the default opener, as + the 2026-09-28 ruling leaves that path.""" credential = None if (self._binding.credential_source() == binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER): @@ -1137,13 +1190,18 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: raise with self._lock: self._available = True + opener = self._opener + if credential is not None and opener is urllib.request.urlopen: + opener = _open_without_redirects try: return _post_to_provider( endpoint=self._binding.endpoint, dialect=self._binding.dialect, credential=credential, model=model, prompt=prompt, - timeout=self._timeout_seconds, opener=self._opener) + timeout=self._timeout_seconds, opener=opener) except _TokenExpired: raise BrokerRefused(DIAG_PROVIDER_REFUSED) from None + except _Redirected: + raise BrokerRefused(DIAG_PROVIDER_REDIRECTED) from None # -- token custody ------------------------------------------------------ diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index c7ce29a7..8d44f5e8 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -1134,11 +1134,13 @@ def test_a_refusal_cannot_be_composed_from_what_a_broker_said(): def test_the_fixed_diagnostics_are_all_reachable_and_no_more(): """The closed set shed the dialect sentence when the dialect became a declaration-time refusal; keeping an unraisable sentence would be a refusal - nobody can trigger. TEN since #1144 box 16.3: the built-in resolver's two - joined, and section (f) below reaches each of them.""" - assert len(provider_mod.FIXED_DIAGNOSTICS) == 10 + nobody can trigger. ELEVEN since #1144 box 16.3: the built-in resolver's + two joined, and so did the redirect a built-in credential declines. + Section (f) below reaches each of the three.""" + assert len(provider_mod.FIXED_DIAGNOSTICS) == 11 assert {provider_mod.DIAG_REFERENCE_UNRESOLVED, - provider_mod.DIAG_KEYRING_UNAVAILABLE} <= \ + provider_mod.DIAG_KEYRING_UNAVAILABLE, + provider_mod.DIAG_PROVIDER_REDIRECTED} <= \ provider_mod.FIXED_DIAGNOSTICS assert not hasattr(provider_mod, "DIAG_DIALECT_UNKNOWN") @@ -2567,6 +2569,69 @@ def test_a_stand_in_server_sees_the_resolved_bearer_or_no_header(which, assert _ChatCompletionsHandler.seen["body"]["model"] == DECLARED_MODEL +class _ElsewhereHandler(http.server.BaseHTTPRequestHandler): + """A second stand-in, where a redirect would lead. It records every + request it is sent, of any method.""" + + seen: list = [] + + def _record(self): + _ElsewhereHandler.seen.append( + (self.command, self.headers.get("Authorization"))) + _answer_json(self, _chat_completion("followed a redirect")) + + def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler's own spelling + self._record() + + def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler's own spelling + self._record() + + def log_message(self, *_args): + return + + +class _RedirectingHandler(http.server.BaseHTTPRequestHandler): + """A stand-in provider that answers every request with a redirect.""" + + code = 302 + location = "" + + def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler's own spelling + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + self.send_response(_RedirectingHandler.code) + self.send_header("Location", _RedirectingHandler.location) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, *_args): + return + + +@pytest.mark.parametrize("code", [301, 302, 303, 307, 308]) +def test_a_built_in_credential_follows_no_redirect(monkeypatch, code): + """Copilot's review of openDox-code#63 at `4abc6d4d`, over real sockets. + `urllib`'s default opener answers a POST's 301, 302 or 303 by sending a + GET to the `Location`, with the credential header still on it (measured). + A request that carries a built-in credential declines the redirect, and + the second server hears nothing at all.""" + monkeypatch.setattr(_ElsewhereHandler, "seen", []) + monkeypatch.setattr(_RedirectingHandler, "code", code) + with _stand_in_provider(_ElsewhereHandler) as elsewhere, \ + _stand_in_provider(_RedirectingHandler) as base: + monkeypatch.setattr(_RedirectingHandler, "location", + f"{elsewhere}/v1/chat/completions") + binding = _built_in_binding(endpoint=f"{base}/v1/chat/completions") + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + runner=_refusing_runner, notice=lambda _text: None, + environ={ENV_NAME: KEY_SENTINEL}) + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_REDIRECTED + assert _ElsewhereHandler.seen == [], "the credential went nowhere else" + + def test_the_resolver_lives_in_the_provider_module_alone(): """R1Q17 (b): "inside `doxbench_provider.py` only". The record parses a reference's FORM and reads no environment and no keyring. No other module From 1b0fb3f4d9e543f36e99f0eccf1232d98e6682f4 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:09:49 +0000 Subject: [PATCH 12/45] T080: no frame a refusal keeps holds a raw credential Copilot's review of openDox-code#63 at d240fd50 (high severity): _post_to_provider took the credential as a raw string. At main the provider-call frame held a MintedToken, whose repr redacts. A traceback keeps its frames, and an error reporter that records a frame's locals records them by their repr, so the raw string was one repr away from a log. - The credential now travels as _PresentedCredential, whose repr and str say nothing, on both paths. The minted token is wrapped at its two call sites and the built-in value as it is read. So every frame of this module that carries a credential to the provider holds only the wrapper, as it held a MintedToken at main. - A refusal of a request that carried a built-in credential is raised afresh, outside every handler, with no cause and no context. Measured with a refused connection: urllib's own frames (do_open.headers, _send_request.headers, send.data and others) hold the bearer in their locals, and a chained cause keeps those frames. - A value refused as unpresentable is deleted from the reading frame before the refusal is raised. It can still be most of a key. The broker path keeps its chained cause, and so keeps urllib's frames, as the 2026-09-28 ruling leaves that path. The PR body records that. Three new cases: over a real refused socket, for an unpresentable value, and for the broker path's frame. Each walks every frame the refusal keeps, through its causes and contexts. Three mutants were each killed: the wrapper's repr disclosing, the chained cause kept, and the unpresentable value kept. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 75 +++++++++++++++++++---- tests/test_model_provider_broker.py | 95 +++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 12 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index ae61664e..9e692db8 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -755,6 +755,10 @@ def resolve_credential_reference(binding, *, environ=None, except Exception: # noqa: BLE001 raise BrokerRefused(DIAG_KEYRING_UNAVAILABLE) from None if not _presentable(value): + # The refusal's traceback keeps this frame, so what was read leaves + # it first: a value that cannot be presented can still be most of a + # key. + del value raise BrokerRefused(DIAG_REFERENCE_UNRESOLVED) return value @@ -847,6 +851,32 @@ def _chat_answer(document: dict) -> str: } +class _PresentedCredential: + """A credential on its way into ONE request's authorization header. + + Its repr and its str say nothing, as `MintedToken`'s do (Copilot's review + of openDox-code#63 at `d240fd50`). A traceback keeps the frames it passes + through, and an error reporter that records a frame's locals records them + by their repr. So in this module a raw credential is a local of no frame + except the one that reads it: `resolve_credential_reference`, until it + returns. Every frame that carries a credential to the provider carries + this wrapper instead.""" + + __slots__ = ("_value",) + + def __init__(self, value: str) -> None: + self._value = value + + def __repr__(self) -> str: + return "_PresentedCredential()" + + __str__ = __repr__ + + def authorization(self) -> str: + """The authorization header's value, built as the header is set.""" + return f"Bearer {self._value}" + + class _Redirected(Exception): """A provider answered a request carrying a built-in credential with a redirect, and the redirect was declined. @@ -881,7 +911,8 @@ def _open_without_redirects(request, *, timeout): request, timeout=timeout) -def _post_to_provider(*, endpoint: str, dialect: str, credential: str | None, +def _post_to_provider(*, endpoint: str, dialect: str, + credential: _PresentedCredential | None, model: str, prompt: str, timeout: float, opener) -> str: """The ONE place a provider is contacted. Returns the assistant prose. @@ -891,7 +922,9 @@ def _post_to_provider(*, endpoint: str, dialect: str, credential: str | None, `credential` is what this request presents (#1144 box 16.3): the token a broker minted (a `MintedToken`'s), the value the built-in resolver read for this call, or None under the auth kind `none`. With None the request - carries no authorization header at all. + carries no authorization header at all. A credential arrives wrapped in a + `_PresentedCredential`, so this frame holds no raw value for a traceback + to keep. The credential travels in the request's authorization header and nowhere else; it is not in the URL (which a proxy logs), not in the body (which an @@ -921,7 +954,7 @@ def _post_to_provider(*, endpoint: str, dialect: str, credential: str | None, endpoint, data=body, method="POST") request.add_header("Content-Type", "application/json") if credential is not None: - request.add_header("Authorization", f"Bearer {credential}") + request.add_header("Authorization", credential.authorization()) try: with opener(request, timeout=timeout) as response: payload = response.read(MAX_PROVIDER_ANSWER_BYTES + 1) @@ -1133,8 +1166,9 @@ def dispatch(self, prompt_envelope: object) -> object: try: prose = _post_to_provider( endpoint=token.endpoint, dialect=token.dialect, - credential=token.token, model=model, prompt=prompt, - timeout=self._timeout_seconds, opener=self._opener) + credential=_PresentedCredential(token.token), model=model, + prompt=prompt, timeout=self._timeout_seconds, + opener=self._opener) except _TokenExpired: # PER-TURN STATE, and no longer than the turn: the expired mint's # own audit reference, read before the token is dropped, so the @@ -1148,8 +1182,9 @@ def dispatch(self, prompt_envelope: object) -> object: try: prose = _post_to_provider( endpoint=token.endpoint, dialect=token.dialect, - credential=token.token, model=model, prompt=prompt, - timeout=self._timeout_seconds, opener=self._opener) + credential=_PresentedCredential(token.token), model=model, + prompt=prompt, timeout=self._timeout_seconds, + opener=self._opener) except _TokenExpired: self._forget_token() raise BrokerRefused(DIAG_TOKEN_EXPIRED_TWICE) from None @@ -1176,14 +1211,20 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: `_open_without_redirects`. An opener a caller injected is that caller's own seam and is used as given. The auth kind `none` sends no credential, and a broker's minted token keeps the default opener, as - the 2026-09-28 ruling leaves that path.""" + the 2026-09-28 ruling leaves that path. + + A REFUSAL OF A REQUEST THAT CARRIED A BUILT-IN CREDENTIAL CHAINS + NOTHING. The credential stays wrapped in a `_PresentedCredential` in + every frame here, and the refusal is raised afresh, with no cause and + no context, so no traceback it carries reaches a frame inside + `urllib` whose locals hold the request's headers.""" credential = None if (self._binding.credential_source() == binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER): try: - credential = resolve_credential_reference( + credential = _PresentedCredential(resolve_credential_reference( self._binding, environ=self._environ, - keyring_backend=self._keyring_backend) + keyring_backend=self._keyring_backend)) except BrokerRefused: with self._lock: self._available = False @@ -1193,15 +1234,25 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: opener = self._opener if credential is not None and opener is urllib.request.urlopen: opener = _open_without_redirects + failure = None try: return _post_to_provider( endpoint=self._binding.endpoint, dialect=self._binding.dialect, credential=credential, model=model, prompt=prompt, timeout=self._timeout_seconds, opener=opener) except _TokenExpired: - raise BrokerRefused(DIAG_PROVIDER_REFUSED) from None + failure = DIAG_PROVIDER_REFUSED except _Redirected: - raise BrokerRefused(DIAG_PROVIDER_REDIRECTED) from None + failure = DIAG_PROVIDER_REDIRECTED + except BrokerRefused as refusal: + if credential is None: + raise + failure = refusal.diagnostic + # RAISED HERE, OUTSIDE EVERY HANDLER, so the refusal chains nothing + # (Copilot's review of openDox-code#63 at `d240fd50`). A cause chained + # from inside `urllib` keeps frames whose locals hold the request's + # headers, and so the credential. + raise BrokerRefused(failure) # -- token custody ------------------------------------------------------ diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 8d44f5e8..73a9d010 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -51,6 +51,7 @@ import http.server import io import json +import socket import subprocess import sys import threading @@ -2632,6 +2633,100 @@ def test_a_built_in_credential_follows_no_redirect(monkeypatch, code): assert _ElsewhereHandler.seen == [], "the credential went nowhere else" +def _safe_repr(value) -> str: + try: + return repr(value) + # A repr that fails discloses nothing, whatever it raised. + except Exception: # noqa: BLE001 + return "" + + +def _frames_kept_by(exception): + """Every frame a refusal's tracebacks keep, through its causes and its + contexts, suppressed or not, except this test file's own frames.""" + seen: set[int] = set() + pending = [exception] + while pending: + current = pending.pop() + if current is None or id(current) in seen: + continue + seen.add(id(current)) + traceback = current.__traceback__ + while traceback is not None: + if traceback.tb_frame.f_code.co_filename != __file__: + yield traceback.tb_frame + traceback = traceback.tb_next + pending.extend((current.__cause__, current.__context__)) + + +def _locals_holding(exception, secret: str) -> list[str]: + """The frame locals, by their repr, that disclose `secret`, which is what + an error reporter that records locals would send on.""" + return sorted({f"{frame.f_code.co_name}.{name}" + for frame in _frames_kept_by(exception) + for name, value in list(frame.f_locals.items()) + if secret in _safe_repr(value)}) + + +@contextlib.contextmanager +def _a_closed_loopback_port(): + """A loopback port that nothing listens on. It is held for the test, so + no other process can take it.""" + holder = socket.socket() + try: + holder.bind(("127.0.0.1", 0)) + yield holder.getsockname()[1] + finally: + holder.close() + + +def test_a_refused_connection_keeps_no_frame_that_holds_the_key(monkeypatch): + """Copilot's review of openDox-code#63 at `d240fd50`, over the real + transport. A cause chained from inside `urllib` keeps frames whose locals + hold the request's headers, and so the key. The refusal chains nothing, + and no frame it keeps holds the key.""" + monkeypatch.setenv(ENV_NAME, KEY_SENTINEL) + with _a_closed_loopback_port() as closed: + binding = _built_in_binding( + endpoint=f"http://127.0.0.1:{closed}/v1/chat/completions") + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + runner=_refusing_runner, notice=lambda _text: None) + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_UNREACHABLE + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert _locals_holding(caught.value, KEY_SENTINEL) == [] + + +def test_an_unpresentable_value_leaves_no_frame_that_holds_it(monkeypatch): + """A value refused as unpresentable can still be most of a key, such as + a key with a line break after it. The frame that read it lets it go + before the refusal is raised.""" + monkeypatch.setenv(ENV_NAME, KEY_SENTINEL + "\n") + port, opener = _unbrokered_port(_built_in_binding(), _chat_completion()) + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + assert caught.value.diagnostic == provider_mod.DIAG_REFERENCE_UNRESOLVED + assert opener.requests == [] + assert _locals_holding(caught.value, KEY_SENTINEL) == [] + + +def test_a_broker_refusal_keeps_no_frame_that_holds_the_token(tmp_path): + """The minted token travels as the same wrapper, so the provider-call + frame holds no raw token, as it held none when that frame took a + `MintedToken`.""" + port, _opener = _port(tmp_path, OSError("unreachable")) + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_UNREACHABLE + assert _locals_holding(caught.value, SENTINEL_TOKEN) == [] + + def test_the_resolver_lives_in_the_provider_module_alone(): """R1Q17 (b): "inside `doxbench_provider.py` only". The record parses a reference's FORM and reads no environment and no keyring. No other module From 286655f3082a8e7048c713794531a57de68b5d7e Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:21:55 +0000 Subject: [PATCH 13/45] T080: a built-in credential over plain http:// uses no proxy Copilot's review of openDox-code#63 at 1b0fb3f4: the opener for a built-in credential still installed urllib's ProxyHandler, which reads the environment's proxies. Measured: with http_proxy set, a request addressed to 127.0.0.1 went to the proxy, credential header and all. A plain-http route is private only because it stays on this host, so the loopback ruling of 2026-09-28 is broken by any proxy. _open_without_redirects becomes _open_for_a_built_in_credential. It still declines every redirect, and a plain-http request now goes direct through ProxyHandler({}), whatever the environment names. An https:// request may still use the environment's proxy, because a proxy reaches it only by CONNECT and the credential stays inside TLS. The broker path keeps the default opener, as the ruling leaves that path. One new case over real sockets, with a stand-in proxy that hears nothing. With the bypass removed, the stand-in proxy answered the turn, so the mutant was killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 41 ++++++++++++++++++++--------- tests/test_model_provider_broker.py | 33 ++++++++++++++++++++--- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 9e692db8..39a440cb 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -903,11 +903,26 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): raise _Redirected -def _open_without_redirects(request, *, timeout): - """`urllib.request.urlopen`, but every redirect is declined. The opener - is built per call, as `urlopen` builds its own on first use, so the - proxy environment is read when a request is made.""" - return urllib.request.build_opener(_DeclineRedirects).open( +def _open_for_a_built_in_credential(request, *, timeout): + """`urllib.request.urlopen` for a request that carries a credential the + built-in resolver read. It changes two things, and nothing else. + + * EVERY REDIRECT IS DECLINED (`_DeclineRedirects`). + * A PLAIN-`http://` REQUEST GOES DIRECT, whatever proxy the environment + names (Copilot's review of openDox-code#63 at `1b0fb3f4`). Such a + route is private only because it stays on this host, and a proxy + would carry it, in cleartext, to wherever the proxy is. Measured: with + `http_proxy` set, urllib's default opener sends a request addressed + to `127.0.0.1` to the proxy, credential header and all. An `https://` + request may still use the environment's proxy, because a proxy + reaches it only by CONNECT, and the credential stays inside TLS. + + The opener is built per call, as `urlopen` builds its own on first use, + so the proxy environment is read when a request is made.""" + handlers: list = [_DeclineRedirects] + if request.type == "http": + handlers.insert(0, urllib.request.ProxyHandler({})) + return urllib.request.build_opener(*handlers).open( request, timeout=timeout) @@ -1205,13 +1220,13 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: repeat: the reference names the same value on a second read, so a retry would buy a second paid call for the same refusal. - A REQUEST CARRYING A BUILT-IN CREDENTIAL FOLLOWS NO REDIRECT. The - default opener follows redirects and re-sends the credential header - (see `_DeclineRedirects`), so such a request swaps it for - `_open_without_redirects`. An opener a caller injected is that - caller's own seam and is used as given. The auth kind `none` sends no - credential, and a broker's minted token keeps the default opener, as - the 2026-09-28 ruling leaves that path. + A REQUEST CARRYING A BUILT-IN CREDENTIAL FOLLOWS NO REDIRECT AND, OVER + PLAIN `http://`, USES NO PROXY. The default opener does both, and + sends the credential header along each time, so such a request uses + `_open_for_a_built_in_credential` in its place. An opener a caller + injected is that caller's own seam and is used as given. The auth + kind `none` sends no credential, and a broker's minted token keeps + the default opener, as the 2026-09-28 ruling leaves that path. A REFUSAL OF A REQUEST THAT CARRIED A BUILT-IN CREDENTIAL CHAINS NOTHING. The credential stays wrapped in a `_PresentedCredential` in @@ -1233,7 +1248,7 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: self._available = True opener = self._opener if credential is not None and opener is urllib.request.urlopen: - opener = _open_without_redirects + opener = _open_for_a_built_in_credential failure = None try: return _post_to_provider( diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 73a9d010..5943b157 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -2571,15 +2571,15 @@ def test_a_stand_in_server_sees_the_resolved_bearer_or_no_header(which, class _ElsewhereHandler(http.server.BaseHTTPRequestHandler): - """A second stand-in, where a redirect would lead. It records every - request it is sent, of any method.""" + """A second stand-in, where a redirect or a proxy would lead. It records + every request it is sent, of any method.""" seen: list = [] def _record(self): _ElsewhereHandler.seen.append( (self.command, self.headers.get("Authorization"))) - _answer_json(self, _chat_completion("followed a redirect")) + _answer_json(self, _chat_completion("answered from elsewhere")) def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler's own spelling self._record() @@ -2633,6 +2633,33 @@ def test_a_built_in_credential_follows_no_redirect(monkeypatch, code): assert _ElsewhereHandler.seen == [], "the credential went nowhere else" +def test_a_built_in_credential_over_http_to_this_host_uses_no_proxy( + monkeypatch): + """Copilot's review of openDox-code#63 at `1b0fb3f4`, over real sockets. + A plain-http route is private only because it stays on this host. With + `http_proxy` set, urllib's default opener sends a request addressed to + `127.0.0.1` to the proxy, credential header and all (measured). This + request goes direct, and the stand-in proxy hears nothing.""" + monkeypatch.setattr(_ElsewhereHandler, "seen", []) + monkeypatch.setattr(_ChatCompletionsHandler, "seen", {}) + for name in ("no_proxy", "NO_PROXY"): + monkeypatch.delenv(name, raising=False) + with _stand_in_provider(_ElsewhereHandler) as proxy, \ + _stand_in_provider(_ChatCompletionsHandler) as base: + for name in ("http_proxy", "HTTP_PROXY"): + monkeypatch.setenv(name, proxy) + binding = _built_in_binding(endpoint=f"{base}/v1/chat/completions") + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + runner=_refusing_runner, notice=lambda _text: None, + environ={ENV_NAME: KEY_SENTINEL}) + answer = port.dispatch(_Envelope()) + assert answer["assistant_prose"] == "answered in the chat grammar" + assert _ChatCompletionsHandler.seen["authorization"] == ( + f"Bearer {KEY_SENTINEL}") + assert _ElsewhereHandler.seen == [], "the proxy heard nothing" + + def _safe_repr(value) -> str: try: return repr(value) From 3f14bb961982ebdde13284d52115238a732c3ee5 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:36:31 +0000 Subject: [PATCH 14/45] T080: a broker's reference may not take a built-in form; keyring failures keep no context Two remarks in Copilot's overview of openDox-code#63 at 286655f3. Its threads were none. - "Broker-returned reserved references can raise uncaught errors." T080 reserves the env: and keyring: forms for the built-in resolver. A broker whose intake answer carried a reference in one of them would make the next declaration of the record see two resolvers. The console's intake route replaces the reference outside the handler that catches a refused binding, so there it would be an uncaught error. hand_off_credential now refuses such an answer as malformed (DIAG_BROKER_MALFORMED), which both entry points already catch. serve_workbench.py is left alone. - "Keyring failures retain exception context." The resolver raised its keyring refusal inside the handler, "from None", which hides the backend's error but keeps it as __context__, together with the backend's frames. The refusal is now raised after the handler, with no context. One new case with two real broker scripts, and one assertion added to the failing-backend case. Two mutants were each killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 29 ++++++++++++++++++++++++++--- tests/test_model_provider_broker.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 39a440cb..ab0956dd 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -590,11 +590,22 @@ def hand_off_credential(binding, source, *, Returns the `reference` the broker gives back — the declaration's own field name (0.2 FINDING 4). That reference is the only thing that then lives in a - binding, in a file, in a log or in a review.""" + binding, in a file, in a log or in a review. + + THE BUILT-IN FORMS ARE RESERVED (#1144 box 16.3; Copilot's overview of + openDox-code#63 at `286655f3`). A broker's reference in the `env:` or + `keyring:` form would make the record read it as the built-in resolver's, + beside the broker that holds the credential, which is two resolvers. The + record refuses that, and it would do so where neither entry point + expects a refusal. So such an answer is malformed, and it is refused + here, where both entry points already catch a broker's refusal.""" answer = runner(broker_operation_argv(binding, OPERATION_INTAKE), source=source) document = _answer_document(answer, BROKER_INTAKE_KIND, INTAKE_FIELDS) - return _declared_string(document, "reference") + reference = _declared_string(document, "reference") + if binding_mod.names_a_built_in_form(reference): + raise BrokerRefused(DIAG_BROKER_MALFORMED) + return reference # --------------------------------------------------------------------------- @@ -686,6 +697,12 @@ def _presentable(value: object) -> bool: and all("!" <= character <= "~" for character in value)) +#: What a keyring backend's failure reads as, inside the resolver only. A +#: sentinel, not None, because None is what a backend answers for an absent +#: entry, which is a different refusal. +_UNREADABLE = object() + + def _os_keyring(): """The OS keyring, through the `keyring` package, imported at call time. @@ -753,7 +770,13 @@ def resolve_credential_reference(binding, *, environ=None, value = backend.get_password(reference.name, reference.user) # A keyring backend's own error, of any class, never reaches a caller. except Exception: # noqa: BLE001 - raise BrokerRefused(DIAG_KEYRING_UNAVAILABLE) from None + value = _UNREADABLE + if value is _UNREADABLE: + # Raised outside the handler, so the refusal keeps no context + # (Copilot's overview of openDox-code#63 at `286655f3`): the + # backend's own frames may hold what it was decoding when it + # failed. + raise BrokerRefused(DIAG_KEYRING_UNAVAILABLE) if not _presentable(value): # The refusal's traceback keeps this frame, so what was read leaves # it first: a value that cannot be presented can still be most of a diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 5943b157..bfd7eda5 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -2280,6 +2280,31 @@ def test_the_resolver_reads_a_key_for_a_private_route(): assert environ.read == [ENV_NAME] +def test_a_broker_reference_in_a_built_in_form_is_malformed(tmp_path): + """The built-in forms are reserved. A broker's reference in one would + make the record read it as the built-in resolver's, beside the broker + that holds the credential: two resolvers, refused where neither entry + point expects a refusal (Copilot's overview of openDox-code#63 at + `286655f3`). So the broker's answer is malformed, and it is refused as + one, which both entry points already catch.""" + for index, reserved in enumerate(BUILT_IN_REFERENCES): + script = tmp_path / f"reserved-broker-{index}.py" + script.write_text( + "import json,sys\nsys.stdin.read()\n" + "print(json.dumps({'schema_version':1," + "'kind':'openprofiler_broker_intake','reference':" + + repr(reserved) + "," + "'binding':'b','provider':'p','auth_kind':'api_key','label':None," + "'created_at':'x','max_lifetime_seconds':300,'issued_by':'i'," + "'approved_by':'a','audit_ref':'opaud-x'}))\n", + encoding="utf-8") + binding = _broker_binding(script) + stdin = io.StringIO("x") + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.hand_off_credential(binding, stdin) + assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + + @pytest.mark.parametrize("endpoint", [ "http://api.example.invalid/turn", "http://localhost.evil.com/turn"]) def test_the_loopback_rule_is_the_built_in_resolvers_alone(endpoint): @@ -2465,6 +2490,9 @@ def test_a_keyring_that_cannot_be_read_refuses_and_says_nothing_of_its_own(): assert caught.value.diagnostic == provider_mod.DIAG_KEYRING_UNAVAILABLE assert "leaks" not in str(caught.value) assert caught.value.__cause__ is None + # no context either: the backend's own frames are not kept (Copilot's + # overview of openDox-code#63 at `286655f3`) + assert caught.value.__context__ is None assert opener.requests == [] From 464d8e37e5d2ad0b0776ab96863f212222085b6f Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:38:22 +0000 Subject: [PATCH 15/45] Broker path: a minted token travels only by a private route (follows T080) A broker binding over plain http:// to any host but this one is now refused when it is declared, with the one ENDPOINT_NOT_PRIVATE sentence a built-in credential earns on the same route. `mint` asks the same predicate before it asks the broker for anything, as the built-in resolver does before it reads, so a binding forced past the record still mints nothing. Only the auth kind `none`, which presents no credential, keeps a route that is not private. This is the first of the four broker-path gaps openDox-code#63 listed for Brett. His word of 2026-09-29, given in-session on #63's closing question: "Yes, separate phase-3 draft (Recommended)". The T080 ruling it extends is recorded at openxFactory#656 comment 5880893901. ENDPOINT_NOT_PRIVATE now names both credentials. T080's two route lists become shared parametrize marks, so the broker cases reuse them and T080's node ids do not change. T080's case that pinned the old scope (a broker binding declared on these routes) keeps only its `none` half, over all nine. Tests: 17 broker cases and 9 `none` cases added, 2 removed. With T080's head 3f14bb96 as the source, the 11 gap cases fail (DID NOT RAISE) and the 15 controls pass. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 63 ++++++++------- src/opendox/doxbench_provider.py | 16 +++- tests/test_model_provider_broker.py | 119 +++++++++++++++++++++++----- 3 files changed, 148 insertions(+), 50 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index 57359d2e..58493e63 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -37,14 +37,19 @@ * the BUILT-IN RESOLVER, for an `env:NAME` or `keyring:SERVICE/USERNAME` reference. It reads the reference at call time, inside `doxbench_provider` only (RULED R1Q17 (b)). Such a record needs no broker, and one given - beside it is refused, so no record has two resolvers. What it reads is a - LONG-LIVED key, so its endpoint must be a PRIVATE ROUTE: `https://`, or - `http://` to 127.0.0.1, ::1 or localhost (Brett Heap's ruling of - 2026-09-28, "Refuse unless loopback"; `is_a_private_route`); + beside it is refused, so no record has two resolvers; * NONE, for an endpoint that takes no credential. It declares the auth kind `none` rather than leaving a field out, and `credential_ref` and `broker_argv` are forbidden under it (RULED R1Q18 (a)). +WHATEVER A RECORD PRESENTS TRAVELS ONLY BY A PRIVATE ROUTE: `https://`, or +`http://` to 127.0.0.1, ::1 or localhost (`is_a_private_route`). That holds for +the built-in resolver's LONG-LIVED key (Brett Heap's ruling of 2026-09-28, +"Refuse unless loopback") and for a broker's minted token alike (his word of +2026-09-29 on openDox-code#63's closing question, "Yes, separate phase-3 +draft"). Only the auth kind `none`, which presents nothing, keeps the route it +declares. + This module classifies a reference's FORM when a binding is declared. It never reads what a reference names. @@ -213,15 +218,15 @@ class BuiltInReference(NamedTuple): #: on-this-host proxy posture an operator may legitimately run; a scheme this #: tuple does not name is refused at declaration, because `file://` or a bare #: host is not something a provider client should discover at dispatch time. -#: For a credential the built-in resolver reads, "on this host" is ENFORCED -#: (`is_a_private_route`). A broker's minted token and the auth kind `none` -#: keep the posture this tuple gives them, as the 2026-09-28 ruling leaves it. +#: For every record that presents a credential, "on this host" is ENFORCED +#: (`is_a_private_route`). The auth kind `none` presents none, so it keeps the +#: posture this tuple gives it. ENDPOINT_SCHEMES: tuple[str, ...] = ("https://", "http://") -#: The hosts a credential the built-in resolver reads may reach over plain -#: `http://`: this host, spelled exactly as Brett Heap's ruling of 2026-09-28 -#: names it ("Refuse unless loopback"). No other spelling of these addresses, -#: and no other address of the loopback range, is one of them. +#: The hosts a credential may reach over plain `http://`: this host, spelled +#: exactly as Brett Heap's ruling of 2026-09-28 names it ("Refuse unless +#: loopback"). No other spelling of these addresses, and no other address of +#: the loopback range, is one of them. LOOPBACK_HOSTS: tuple[str, ...] = ("127.0.0.1", "::1", "localhost") @@ -248,23 +253,24 @@ def is_a_private_route(endpoint: object) -> bool: """Whether `endpoint` keeps a credential from crossing a network in cleartext: `https://`, or `http://` to this host (`LOOPBACK_HOSTS`). - ONE PREDICATE. The record asks it when a binding is declared, and - `doxbench_provider`'s built-in resolver asks it again before it reads - anything.""" + ONE PREDICATE. The record asks it when a binding is declared. + `doxbench_provider` asks it again before the built-in resolver reads + anything, and before `mint` asks a broker for a token.""" return (isinstance(endpoint, str) and _PRIVATE_ROUTE.match(endpoint) is not None) -#: The refusal a credential the built-in resolver reads earns on a route that -#: is not private (Brett Heap's ruling of 2026-09-28, "Refuse unless -#: loopback"). That resolver reads a LONG-LIVED key, where a broker mints a -#: short-lived token, so plain `http://` carries one only to this host. A -#: fixed sentence, and it repeats nothing of the endpoint. +#: The refusal a record that presents a credential earns on a route that is +#: not private. Brett Heap ruled it on 2026-09-28 ("Refuse unless loopback") +#: for the built-in resolver's LONG-LIVED key. His word of 2026-09-29 gave a +#: broker's minted token the same rule. A short-lived token is still a +#: credential: sent in cleartext, it can be replayed by whoever reads it until +#: it expires. A fixed sentence, and it repeats nothing of the endpoint. ENDPOINT_NOT_PRIVATE = ( - "a credential the built-in resolver reads (an env: or keyring: reference) " - "is sent only over https://, or over http:// to this host (127.0.0.1, ::1 " - "or localhost), and this endpoint is neither; declare an https:// " - "endpoint, or a loopback one") + "a credential (a broker's minted token, or the key an env: or keyring: " + "reference names) is sent only over https://, or over http:// to this host " + "(127.0.0.1, ::1 or localhost), and this endpoint is neither; declare an " + "https:// endpoint, or a loopback one") #: The refusal a key inside the endpoint URL earns (#1144 box 16.3). Measured #: before 16.3: this record checked the endpoint's scheme and nothing else, so @@ -563,11 +569,12 @@ def _require_a_declarable_endpoint(self) -> None: f"{ENDPOINT_SCHEMES}") def _require_a_private_route(self) -> None: - """A CREDENTIAL THE BUILT-IN RESOLVER READS TRAVELS ONLY BY A PRIVATE - ROUTE (Brett Heap's ruling of 2026-09-28, "Refuse unless loopback"; - `is_a_private_route`). A broker's minted token and the auth kind - `none` keep the route they had, as the ruling leaves them.""" - if (self.credential_source() == CREDENTIAL_FROM_BUILT_IN_RESOLVER + """A CREDENTIAL TRAVELS ONLY BY A PRIVATE ROUTE (`is_a_private_route`), + whichever resolver answers it: the built-in resolver's key (Brett + Heap's ruling of 2026-09-28, "Refuse unless loopback") or a broker's + minted token (his word of 2026-09-29). The auth kind `none` presents + no credential, so its route is its own.""" + if (self.credential_source() != NO_CREDENTIAL and not is_a_private_route(self.endpoint)): raise BindingRefused(ENDPOINT_NOT_PRIVATE) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index ab0956dd..ab94f8e0 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -628,7 +628,21 @@ def mint(binding, *, retry_of: str | None = None, WHERE the token is good and WHAT GRAMMAR that endpoint speaks come from the BINDING, not from this answer: the declaration emits neither and says why — the broker is provider-agnostic about the request grammar and will not name - an endpoint it would then be accountable for (0.2 FINDING 3).""" + an endpoint it would then be accountable for (0.2 FINDING 3). + + NO TOKEN IS ASKED FOR ON A ROUTE THAT IS NOT PRIVATE (Brett Heap's word of + 2026-09-29: a broker's minted token keeps the rules a built-in credential + keeps). The record refuses such a binding when it is declared + (`doxbench_binding.ENDPOINT_NOT_PRIVATE`), so no declared binding reaches + this check. It is repeated before the broker is asked all the same, as the + built-in resolver repeats it before it reads, because this is the function + that obtains the token. What reaches it is a programming error, and + nothing has been minted when it is raised.""" + if not binding_mod.is_a_private_route(binding.endpoint): + raise AssertionError( + f"binding {binding.id!r} would present a minted token over a " + "route that is not private, which the record refuses when it is " + "declared; nothing was minted") answer = runner(broker_operation_argv(binding, OPERATION_MINT, retry_of=retry_of)) document = _answer_document(answer, BROKER_MINT_KIND, MINT_FIELDS) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index bfd7eda5..7b49fdfe 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -25,7 +25,9 @@ 034 phase 3, slice P3-B). 16.1 is the OpenAI-compatible dialect (T078), 16.2 is the model name the provider receives (T079), and 16.3 is the credential staying a reference: a key in the URL or an extra field refused, the built-in -`env:` and keyring resolver, and the auth kind `none` (T080). +`env:` and keyring resolver, and the auth kind `none` (T080). Its last section +holds a broker's minted token to the rules T080 gave a built-in credential +(the broker path's hardening, Brett Heap's word of 2026-09-29). THE FAKE BROKER SPEAKS THE DECLARED CONTRACT (task 2.6). It was this repository's own invented stdin/stdout protocol until the reconciliation, which @@ -2161,8 +2163,10 @@ def test_a_binding_no_broker_answers_has_no_broker_operation(): f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}") BACKSLASH = chr(92) - -@pytest.mark.parametrize("endpoint", [ +#: The routes a credential may take, and the routes it may not. One list of +#: each, shared by the built-in resolver's cases here and the broker path's +#: cases below, because the rule is one rule. +ON_A_PRIVATE_ROUTE = pytest.mark.parametrize("endpoint", [ "https://api.example.invalid/v1/chat/completions", "http://127.0.0.1:8080/v1/chat/completions", "http://[::1]:8080/v1/chat/completions", @@ -2171,15 +2175,7 @@ def test_a_binding_no_broker_answers_has_no_broker_operation(): "http://localhost", ], ids=["https", "ipv4-loopback", "ipv6-loopback", "localhost", "localhost-in-capitals", "no-path"]) -def test_a_built_in_credential_is_declared_on_a_private_route(endpoint): - for reference in BUILT_IN_REFERENCES: - binding = _built_in_binding(reference, endpoint=endpoint) - assert binding.endpoint == endpoint - assert binding.credential_source() == ( - binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER) - - -@pytest.mark.parametrize("endpoint", [ +NOT_ON_A_PRIVATE_ROUTE = pytest.mark.parametrize("endpoint", [ "http://api.example.invalid/v1/chat/completions", "http://localhost.evil.com/v1/chat/completions", "http://127.0.0.1.evil.com/v1/chat/completions", @@ -2193,6 +2189,18 @@ def test_a_built_in_credential_is_declared_on_a_private_route(endpoint): "localhost-only-in-the-path", "a-loopback-address-not-named", "another-spelling-of-ipv6-loopback", "trailing-dot", "percent-encoded-dot", "unspecified-address"]) + + +@ON_A_PRIVATE_ROUTE +def test_a_built_in_credential_is_declared_on_a_private_route(endpoint): + for reference in BUILT_IN_REFERENCES: + binding = _built_in_binding(reference, endpoint=endpoint) + assert binding.endpoint == endpoint + assert binding.credential_source() == ( + binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER) + + +@NOT_ON_A_PRIVATE_ROUTE def test_a_built_in_credential_over_http_to_another_host_is_refused(endpoint): """Refused when it is declared, by the constructor and from a stored record alike, with the one fixed sentence.""" @@ -2305,15 +2313,12 @@ def test_a_broker_reference_in_a_built_in_form_is_malformed(tmp_path): assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED -@pytest.mark.parametrize("endpoint", [ - "http://api.example.invalid/turn", "http://localhost.evil.com/turn"]) -def test_the_loopback_rule_is_the_built_in_resolvers_alone(endpoint): - """The ruling leaves the broker path as it is today: a broker's minted - token may still be declared over plain http:// to any host, which is the - pre-existing gap the PR notes. The auth kind `none` presents no - credential, so it keeps its route too.""" - assert _binding(endpoint=endpoint).credential_source() == ( - binding_mod.CREDENTIAL_FROM_BROKER) +@NOT_ON_A_PRIVATE_ROUTE +def test_a_none_binding_keeps_a_route_that_is_not_private(endpoint): + """The auth kind `none` presents no credential, so it is the one kind + that keeps such a route. Until the broker path's hardening (the last + section of this file), this case also declared a broker binding on these + routes, pinning T080's scope. That half is now refused.""" assert _none_binding(endpoint=endpoint).credential_source() == ( binding_mod.NO_CREDENTIAL) @@ -2903,3 +2908,75 @@ def test_set_credential_refuses_a_binding_no_broker_answers(tmp_path, capsys, args, source=_UnreadableSource()) == 1 assert "names no broker" in capsys.readouterr().err assert store.get(binding.id) == binding, "nothing changed" + + +# --- the broker path keeps the same rules (follows T080) ----------------- +# Brett Heap's word of 2026-09-29, answering openDox-code#63's closing +# question ("Should the broker path follow it?"): "Yes, separate phase-3 +# draft". A broker's minted token keeps every rule T080 gave a credential the +# built-in resolver reads. At `main`, and at T080's head, the broker path had +# four gaps, each measured over real sockets and a real broker child: +# +# 1. the token could be declared over plain http:// to any host; +# 2. a redirect, or an environment proxy, carried it elsewhere; +# 3. a provider-unreachable refusal chained urllib's error, whose frames +# held the token in their locals; +# 4. a token that cannot be presented went to urllib as it was, so one +# outside latin-1 failed there as DIAG_PROVIDER_UNREACHABLE. +# +# Each gap's cases below fail at T080's head, and the controls beside them +# (a private route declared, a presentable token presented) pass there too. +# Every token here is an obvious fake. + + +@ON_A_PRIVATE_ROUTE +def test_a_broker_token_is_declared_on_a_private_route(endpoint): + binding = _binding(endpoint=endpoint, dialect=OPENAI_CHAT) + assert binding.endpoint == endpoint + assert binding.credential_source() == binding_mod.CREDENTIAL_FROM_BROKER + + +@NOT_ON_A_PRIVATE_ROUTE +def test_a_broker_token_over_http_to_another_host_is_refused(endpoint): + """Gap 1. Refused when it is declared, by the constructor and from a + stored record alike, with the fixed sentence a built-in credential + earns on the same route.""" + with pytest.raises(binding_mod.BindingRefused) as caught: + _binding(endpoint=endpoint, dialect=OPENAI_CHAT) + assert str(caught.value) == binding_mod.ENDPOINT_NOT_PRIVATE + record = dict(_binding().as_record(), endpoint=endpoint) + with pytest.raises(binding_mod.BindingRefused) as caught: + binding_mod.ModelProviderBinding.from_record(record) + assert str(caught.value) == binding_mod.ENDPOINT_NOT_PRIVATE + + +def test_mint_asks_no_broker_for_a_token_on_a_route_that_is_not_private( + tmp_path): + """Gap 1, in `mint` itself, as the built-in resolver checks before it + reads. The record refuses such a binding when it is declared, so this + one is forced past that check, as no declaration can do. It still + cannot make a broker mint.""" + script = _write_broker(tmp_path) + binding = _broker_binding(script) + object.__setattr__(binding, "endpoint", "http://api.example.invalid/v1") + with pytest.raises(AssertionError) as caught: + provider_mod.mint(binding) + assert "nothing was minted" in str(caught.value) + assert _seen_all(script) == [], "the broker was never asked" + + +def test_the_cli_refuses_a_broker_binding_over_http_to_another_host( + tmp_path, capsys): + checkout = tmp_path / "checkout" + checkout.mkdir() + args = cli_mod.build_parser().parse_args([ + "model-binding", "add", "--repo-root", str(checkout), + "--id", "cleartext-broker", "--label", "L", "--provider", "local", + "--credential-ref", FAKE_REFERENCE, "--auth-kind", "api_key", + "--credential-approver", "brett@opensoft.one", + "--endpoint", "http://api.example.invalid/v1/chat/completions", + "--dialect", OPENAI_CHAT, "--", "openprofiler-broker"]) + assert args.func(args) == 1 + assert binding_mod.ENDPOINT_NOT_PRIVATE in capsys.readouterr().err + store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) + assert store.list() == (), "nothing is stored" From 823dc0332aabfb7a79c3760d0ab1d8de615b36da Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:43:59 +0000 Subject: [PATCH 16/45] Broker path: a minted token follows no redirect, uses no proxy, and chains nothing Gaps 2 and 3 of the four broker-path gaps openDox-code#63 listed for Brett, closed with T080's own rules for a built-in credential (his word of 2026-09-29, given in-session). One port method, `_call_provider`, now makes every provider request, on both paths. A request that carries a credential (a broker's minted token, or what the built-in resolver read) goes through `_open_with_a_credential`, which is T080's opener renamed. It declines every redirect, and over plain http:// it uses no proxy. Its refusal is raised afresh, with no cause and no context. The auth kind `none` presents nothing, so it keeps the default opener and its refusal's chain, as before. The broker branch of `dispatch` answers an expiry outside every handler. The one re-mint and the one paid retry of the 2026-08-26 ruling do what they did, and a refusal raised by either keeps no context. DIAG_PROVIDER_REDIRECTED and the redirect handler now name both credentials. Measured at T080's head 3f14bb96, over real sockets and a real broker child: - a POST answered 301, 302 or 303 reached the redirect's target as a GET that carried the token; - with http_proxy set, a request to 127.0.0.1 went to the proxy with it; - a refused connection chained urllib's URLError, and do_open.headers, _send_request.headers, _send_output.msg, send.data and request.headers held the token. Tests: 12 broker cases. With 3f14bb96 as the source all 12 fail, and T080's two refactored cases pass. T080's redirect and proxy cases now share their scaffolds with the broker's (`_a_provider_that_redirects`, `_an_environment_proxy`). The proxy scaffold drops urlopen's cached global opener, so the proxy environment is read the way a process started with it reads it. The ENDPOINT_NOT_PRIVATE sentence is rewrapped, with the same text. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 6 +- src/opendox/doxbench_provider.py | 143 ++++++++++++++---------- tests/test_model_provider_broker.py | 167 +++++++++++++++++++++++++--- 3 files changed, 239 insertions(+), 77 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index 58493e63..477690fd 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -268,9 +268,9 @@ def is_a_private_route(endpoint: object) -> bool: #: it expires. A fixed sentence, and it repeats nothing of the endpoint. ENDPOINT_NOT_PRIVATE = ( "a credential (a broker's minted token, or the key an env: or keyring: " - "reference names) is sent only over https://, or over http:// to this host " - "(127.0.0.1, ::1 or localhost), and this endpoint is neither; declare an " - "https:// endpoint, or a loopback one") + "reference names) is sent only over https://, or over http:// to this " + "host (127.0.0.1, ::1 or localhost), and this endpoint is neither; " + "declare an https:// endpoint, or a loopback one") #: The refusal a key inside the endpoint URL earns (#1144 box 16.3). Measured #: before 16.3: this record checked the endpoint's scheme and nothing else, so diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index ab94f8e0..08e01238 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -251,19 +251,19 @@ "the OS keyring could not be read by this process, so the keyring " "reference could not be resolved") -#: The answer to a redirect of a request that carried a credential the -#: built-in resolver read. That request follows no redirect (see -#: `_DeclineRedirects`), so the credential went to the declared endpoint and -#: nowhere else, and the sentence says what to declare instead. +#: The answer to a redirect of a request that carried a credential: a broker's +#: minted token, or what the built-in resolver read. That request follows no +#: redirect (see `_DeclineRedirects`), so the credential went to the declared +#: endpoint and nowhere else, and the sentence says what to declare instead. DIAG_PROVIDER_REDIRECTED = ( - "the provider answered with a redirect, which a credential the built-in " - "resolver reads does not follow, so it was sent nowhere else; declare " - "the endpoint the provider redirects to") + "the provider answered with a redirect, which a request carrying a " + "credential does not follow, so the credential was sent nowhere else; " + "declare the endpoint the provider redirects to") #: The closed set, so a test can assert no other sentence can be raised. #: ELEVEN: the eight the reconciliation left, the built-in resolver's two -#: (#1144 box 16.3), and the redirect a request carrying a built-in -#: credential declines. `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded +#: (#1144 box 16.3), and the redirect a request carrying a credential +#: declines. `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded #: moved: the dialect is the BINDING's, validated against the closed vocabulary #: when the operator declares it #: (`doxbench_binding.ModelProviderBinding.__post_init__`), so an unknown @@ -915,8 +915,8 @@ def authorization(self) -> str: class _Redirected(Exception): - """A provider answered a request carrying a built-in credential with a - redirect, and the redirect was declined. + """A provider answered a request carrying a credential with a redirect, + and the redirect was declined. PRIVATE and never raised out of this module: the port answers it with `DIAG_PROVIDER_REDIRECTED` before any caller sees anything.""" @@ -929,20 +929,23 @@ class _DeclineRedirects(urllib.request.HTTPRedirectHandler): `urllib`'s own handler re-sends a request's headers, all but the content ones, to whatever `Location` the provider names, whatever its host and scheme. Measured: a POST answered 301, 302 or 303 reaches the redirect's - target as a GET that still carries `Authorization: Bearer ...`. The - loopback ruling of 2026-09-28 sends a credential the built-in resolver - reads only by a private route, and a followed redirect would send it by - any route. So a request that carries one declines every redirect, with - the redirect's answer closed unread.""" + target as a GET that still carries `Authorization: Bearer ...`, whether + the bearer is a built-in credential or a broker's minted token. A + credential travels only by a private route (the loopback ruling of + 2026-09-28, which Brett Heap's word of 2026-09-29 gave a minted token + too), and a followed redirect would send it by any route. So a request + that carries one declines every redirect, with the redirect's answer + closed unread.""" def redirect_request(self, req, fp, code, msg, headers, newurl): fp.close() raise _Redirected -def _open_for_a_built_in_credential(request, *, timeout): - """`urllib.request.urlopen` for a request that carries a credential the - built-in resolver read. It changes two things, and nothing else. +def _open_with_a_credential(request, *, timeout): + """`urllib.request.urlopen` for a request that carries a credential: a + broker's minted token, or what the built-in resolver read. It changes two + things, and nothing else. * EVERY REDIRECT IS DECLINED (`_DeclineRedirects`). * A PLAIN-`http://` REQUEST GOES DIRECT, whatever proxy the environment @@ -1201,7 +1204,14 @@ def dispatch(self, prompt_envelope: object) -> object: what every request sent before the field existed, byte for byte. A RECORD NO BROKER ANSWERS takes `_dispatch_without_a_broker` instead - (#1144 box 16.3): no mint, no ledger event, and no retry.""" + (#1144 box 16.3): no mint, no ledger event, and no retry. + + EITHER WAY, THE PROVIDER IS CALLED THROUGH `_call_provider`, so a + broker's minted token keeps every rule a built-in credential keeps: no + redirect, no proxy over plain `http://`, and a refusal that chains + nothing (Brett Heap's word of 2026-09-29). The re-mint and the retry + above therefore happen outside every handler, so a refusal raised by + either keeps no context either.""" handle = getattr(prompt_envelope, "model_id", None) if not isinstance(handle, str) or not handle: entries = self._declared_catalog.entries @@ -1215,13 +1225,10 @@ def dispatch(self, prompt_envelope: object) -> object: model=model, prompt=prompt), "proposals": []} token = self._current_token(REASON_FIRST_MINT) - try: - prose = _post_to_provider( - endpoint=token.endpoint, dialect=token.dialect, - credential=_PresentedCredential(token.token), model=model, - prompt=prompt, timeout=self._timeout_seconds, - opener=self._opener) - except _TokenExpired: + prose = self._call_provider( + _PresentedCredential(token.token), endpoint=token.endpoint, + dialect=token.dialect, model=model, prompt=prompt) + if prose is None: # PER-TURN STATE, and no longer than the turn: the expired mint's # own audit reference, read before the token is dropped, so the # re-mint can name what it replaces. @@ -1231,15 +1238,12 @@ def dispatch(self, prompt_envelope: object) -> object: token = self._current_token(REASON_EXPIRY_REMINT, retry_of=replaced) self._record(REASON_PAID_RETRY) - try: - prose = _post_to_provider( - endpoint=token.endpoint, dialect=token.dialect, - credential=_PresentedCredential(token.token), model=model, - prompt=prompt, timeout=self._timeout_seconds, - opener=self._opener) - except _TokenExpired: + prose = self._call_provider( + _PresentedCredential(token.token), endpoint=token.endpoint, + dialect=token.dialect, model=model, prompt=prompt) + if prose is None: self._forget_token() - raise BrokerRefused(DIAG_TOKEN_EXPIRED_TWICE) from None + raise BrokerRefused(DIAG_TOKEN_EXPIRED_TWICE) return {"assistant_prose": prose, "proposals": []} def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: @@ -1255,21 +1259,11 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: A 401 HERE IS A REFUSAL, NOT AN EXPIRY. The 2026-08-26 retry ruling is about a MINTED token outliving its turn, and here there is no mint to repeat: the reference names the same value on a second read, so a - retry would buy a second paid call for the same refusal. + retry would buy a second paid call for the same refusal. It is raised + outside every handler, so it keeps no context. - A REQUEST CARRYING A BUILT-IN CREDENTIAL FOLLOWS NO REDIRECT AND, OVER - PLAIN `http://`, USES NO PROXY. The default opener does both, and - sends the credential header along each time, so such a request uses - `_open_for_a_built_in_credential` in its place. An opener a caller - injected is that caller's own seam and is used as given. The auth - kind `none` sends no credential, and a broker's minted token keeps - the default opener, as the 2026-09-28 ruling leaves that path. - - A REFUSAL OF A REQUEST THAT CARRIED A BUILT-IN CREDENTIAL CHAINS - NOTHING. The credential stays wrapped in a `_PresentedCredential` in - every frame here, and the refusal is raised afresh, with no cause and - no context, so no traceback it carries reaches a frame inside - `urllib` whose locals hold the request's headers.""" + The request itself is made through `_call_provider`, which keeps the + rules for a request that carries a credential.""" credential = None if (self._binding.credential_source() == binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER): @@ -1283,27 +1277,58 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: raise with self._lock: self._available = True + prose = self._call_provider( + credential, endpoint=self._binding.endpoint, + dialect=self._binding.dialect, model=model, prompt=prompt) + if prose is None: + raise BrokerRefused(DIAG_PROVIDER_REFUSED) + return prose + + def _call_provider(self, credential: _PresentedCredential | None, *, + endpoint: str, dialect: str, model: str, + prompt: str) -> str | None: + """ONE provider call, under the rules a request that carries a + credential keeps, whichever resolver answered it: a broker's minted + token, or what the built-in resolver read. Returns the prose, or None + when the provider said the credential is no longer valid (a 401), which + each path answers in its own way. + + A REQUEST THAT CARRIES A CREDENTIAL FOLLOWS NO REDIRECT AND, OVER + PLAIN `http://`, USES NO PROXY. The default opener does both, and + sends the credential header along each time, so such a request uses + `_open_with_a_credential` in its place. An opener a caller injected is + that caller's own seam, and it is used as given. + + A REFUSAL OF A REQUEST THAT CARRIED A CREDENTIAL CHAINS NOTHING. The + credential stays wrapped in a `_PresentedCredential` in every frame + here, and the refusal is raised afresh, with no cause and no context, + so no traceback it carries reaches a frame inside `urllib` whose locals + hold the request's headers (Copilot's review of openDox-code#63 at + `d240fd50`). + + T080 gave these rules to the built-in resolver's key. Brett Heap's + word of 2026-09-29 gave them to a broker's minted token too. The auth + kind `none` presents nothing, so its request keeps the default opener, + and its refusal is raised as `_post_to_provider` raised it.""" opener = self._opener if credential is not None and opener is urllib.request.urlopen: - opener = _open_for_a_built_in_credential - failure = None + opener = _open_with_a_credential try: return _post_to_provider( - endpoint=self._binding.endpoint, dialect=self._binding.dialect, - credential=credential, model=model, prompt=prompt, - timeout=self._timeout_seconds, opener=opener) + endpoint=endpoint, dialect=dialect, credential=credential, + model=model, prompt=prompt, timeout=self._timeout_seconds, + opener=opener) except _TokenExpired: - failure = DIAG_PROVIDER_REFUSED + return None except _Redirected: failure = DIAG_PROVIDER_REDIRECTED except BrokerRefused as refusal: if credential is None: raise failure = refusal.diagnostic - # RAISED HERE, OUTSIDE EVERY HANDLER, so the refusal chains nothing - # (Copilot's review of openDox-code#63 at `d240fd50`). A cause chained - # from inside `urllib` keeps frames whose locals hold the request's - # headers, and so the credential. + # RAISED HERE, OUTSIDE EVERY HANDLER, so the refusal chains nothing. A + # cause chained from inside `urllib` keeps frames whose locals hold the + # request's headers, and so the credential. raise BrokerRefused(failure) # -- token custody ------------------------------------------------------ diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 7b49fdfe..ec98a352 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -60,6 +60,7 @@ import time import types import urllib.error +import urllib.request from datetime import datetime, timezone from pathlib import Path @@ -891,14 +892,15 @@ def _expired_error(): def _port(tmp_path, *outcomes, expires=None, notice=None, clock=time.time, endpoint=ENDPOINT, dialect=binding_mod.DIALECT_XFACTORY_PROMPT_V1, - model=None): - script = _write_broker(tmp_path, expires=expires) + model=None, token=SENTINEL_TOKEN, + runner=provider_mod.subprocess_broker_runner): + script = _write_broker(tmp_path, expires=expires, token=token) binding = _broker_binding(script, endpoint=endpoint, dialect=dialect, model=model) opener = _Opener(*outcomes) port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), - opener=opener, clock=clock, + runner=runner, opener=opener, clock=clock, notice=notice if notice is not None else (lambda _text: None)) return port, opener @@ -2641,6 +2643,40 @@ def log_message(self, *_args): return +@contextlib.contextmanager +def _a_provider_that_redirects(monkeypatch, code): + """A stand-in provider that answers every request with a `code` redirect + to a second stand-in, `_ElsewhereHandler`, which records whatever it is + sent. It yields the first one's base URL.""" + monkeypatch.setattr(_ElsewhereHandler, "seen", []) + monkeypatch.setattr(_RedirectingHandler, "code", code) + with _stand_in_provider(_ElsewhereHandler) as elsewhere, \ + _stand_in_provider(_RedirectingHandler) as base: + monkeypatch.setattr(_RedirectingHandler, "location", + f"{elsewhere}/v1/chat/completions") + yield base + + +@contextlib.contextmanager +def _an_environment_proxy(monkeypatch): + """A stand-in proxy that `http_proxy` names, recording into + `_ElsewhereHandler.seen`, for the length of one test. + + `urlopen`'s global opener is dropped first. `urlopen` builds it on first + use and reads the proxy environment then, so a request made through + `urlopen` here reads this environment, as it would in a process started + with the variable set. Without that, a proxy case would pass or fail by + whichever earlier test built the opener.""" + monkeypatch.setattr(_ElsewhereHandler, "seen", []) + monkeypatch.setattr(urllib.request, "_opener", None) + for name in ("no_proxy", "NO_PROXY"): + monkeypatch.delenv(name, raising=False) + with _stand_in_provider(_ElsewhereHandler) as proxy: + for name in ("http_proxy", "HTTP_PROXY"): + monkeypatch.setenv(name, proxy) + yield proxy + + @pytest.mark.parametrize("code", [301, 302, 303, 307, 308]) def test_a_built_in_credential_follows_no_redirect(monkeypatch, code): """Copilot's review of openDox-code#63 at `4abc6d4d`, over real sockets. @@ -2648,12 +2684,7 @@ def test_a_built_in_credential_follows_no_redirect(monkeypatch, code): GET to the `Location`, with the credential header still on it (measured). A request that carries a built-in credential declines the redirect, and the second server hears nothing at all.""" - monkeypatch.setattr(_ElsewhereHandler, "seen", []) - monkeypatch.setattr(_RedirectingHandler, "code", code) - with _stand_in_provider(_ElsewhereHandler) as elsewhere, \ - _stand_in_provider(_RedirectingHandler) as base: - monkeypatch.setattr(_RedirectingHandler, "location", - f"{elsewhere}/v1/chat/completions") + with _a_provider_that_redirects(monkeypatch, code) as base: binding = _built_in_binding(endpoint=f"{base}/v1/chat/completions") port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), @@ -2673,14 +2704,9 @@ def test_a_built_in_credential_over_http_to_this_host_uses_no_proxy( `http_proxy` set, urllib's default opener sends a request addressed to `127.0.0.1` to the proxy, credential header and all (measured). This request goes direct, and the stand-in proxy hears nothing.""" - monkeypatch.setattr(_ElsewhereHandler, "seen", []) monkeypatch.setattr(_ChatCompletionsHandler, "seen", {}) - for name in ("no_proxy", "NO_PROXY"): - monkeypatch.delenv(name, raising=False) - with _stand_in_provider(_ElsewhereHandler) as proxy, \ + with _an_environment_proxy(monkeypatch), \ _stand_in_provider(_ChatCompletionsHandler) as base: - for name in ("http_proxy", "HTTP_PROXY"): - monkeypatch.setenv(name, proxy) binding = _built_in_binding(endpoint=f"{base}/v1/chat/completions") port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), @@ -2980,3 +3006,114 @@ def test_the_cli_refuses_a_broker_binding_over_http_to_another_host( assert binding_mod.ENDPOINT_NOT_PRIVATE in capsys.readouterr().err store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) assert store.list() == (), "nothing is stored" + + +def _minting_port(tmp_path, endpoint, *, token=SENTINEL_TOKEN): + """A port as a served install builds one, for a binding at `endpoint` + whose broker, a real child, mints `token`. It keeps the default opener, + so nothing stands between the port and the socket.""" + binding = _broker_binding(_write_broker(tmp_path, token=token), + endpoint=endpoint, dialect=OPENAI_CHAT) + return provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + notice=lambda _text: None) + + +def _refused_turn(port) -> provider_mod.BrokerRefused: + """The refusal one turn on `port` ends in.""" + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + return caught.value + + +@pytest.mark.parametrize("code", [301, 302, 303, 307, 308]) +def test_a_broker_token_follows_no_redirect(tmp_path, monkeypatch, code): + """Gap 2, over real sockets. At T080's head a POST answered 301, 302 or + 303 reached the redirect's target as a GET that still carried the minted + token, and the turn was answered from there. A 307 or 308 read as the + provider refusing. The redirect is declined, the second server hears + nothing, and the refusal chains nothing.""" + with _a_provider_that_redirects(monkeypatch, code) as base: + refusal = _refused_turn( + _minting_port(tmp_path, f"{base}/v1/chat/completions")) + assert refusal.diagnostic == provider_mod.DIAG_PROVIDER_REDIRECTED + assert _ElsewhereHandler.seen == [], "the token went nowhere else" + assert refusal.__cause__ is None + assert refusal.__context__ is None + + +def test_a_broker_token_over_http_to_this_host_uses_no_proxy(tmp_path, + monkeypatch): + """Gap 2, over real sockets. With `http_proxy` set, T080's head sent a + request that carried a minted token to the proxy, even one addressed to + `127.0.0.1` (measured with a fresh global opener, as in a process + started with the variable set). The request goes direct, and the + stand-in proxy hears nothing.""" + monkeypatch.setattr(_ChatCompletionsHandler, "seen", {}) + with _an_environment_proxy(monkeypatch), \ + _stand_in_provider(_ChatCompletionsHandler) as base: + answer = _minting_port( + tmp_path, f"{base}/v1/chat/completions").dispatch(_Envelope()) + assert answer["assistant_prose"] == "answered in the chat grammar" + assert _ChatCompletionsHandler.seen["authorization"] == ( + f"Bearer {SENTINEL_TOKEN}") + assert _ElsewhereHandler.seen == [], "the proxy heard nothing" + + +def test_a_refused_connection_keeps_no_frame_that_holds_the_token(tmp_path): + """Gap 3, over the real transport. At T080's head this refusal chained + urllib's `URLError`, and five frames it kept held the token in their + locals (measured: `do_open.headers`, `_send_request.headers`, + `_send_output.msg`, `send.data` and `request.headers`). The refusal + chains nothing, and no frame it keeps holds the token.""" + with _a_closed_loopback_port() as closed: + refusal = _refused_turn(_minting_port( + tmp_path, f"http://127.0.0.1:{closed}/v1/chat/completions")) + assert refusal.diagnostic == provider_mod.DIAG_PROVIDER_UNREACHABLE + assert refusal.__cause__ is None + assert refusal.__context__ is None + assert _locals_holding(refusal, SENTINEL_TOKEN) == [] + + +@pytest.mark.parametrize("outcomes,expected", [ + ((urllib.error.URLError("down"),), + provider_mod.DIAG_PROVIDER_UNREACHABLE), + ((urllib.error.HTTPError(ENDPOINT, 500, "boom", {}, + io.BytesIO(b"provider detail")),), + provider_mod.DIAG_PROVIDER_REFUSED), + ((b"not json",), provider_mod.DIAG_PROVIDER_MALFORMED), + ((_expired_error(), _expired_error()), + provider_mod.DIAG_TOKEN_EXPIRED_TWICE), +], ids=["unreachable", "refused", "malformed", "expired-twice"]) +def test_a_refusal_of_a_turn_that_presented_a_token_chains_nothing( + tmp_path, outcomes, expected): + """Gap 3, for each refusal a presented token can meet. At T080's head + each of them kept urllib's error, or the expiry, as its cause or its + context.""" + port, _opener = _port(tmp_path, *outcomes) + refusal = _refused_turn(port) + assert refusal.diagnostic == expected + assert refusal.__cause__ is None + assert refusal.__context__ is None + + +def test_a_re_mint_the_broker_refuses_after_an_expiry_keeps_no_context( + tmp_path): + """The one re-mint the 2026-08-26 ruling allows happens outside every + handler, so the broker's refusal of it keeps no context. At T080's head + it kept the expiry, which kept urllib's error.""" + asked: list = [] + + def refuses_a_second_mint(argv, **kwargs): + asked.append(argv) + if len(asked) > 1: + raise provider_mod.BrokerRefused(provider_mod.DIAG_BROKER_REFUSED) + return provider_mod.subprocess_broker_runner(argv, **kwargs) + + port, opener = _port(tmp_path, _expired_error(), + runner=refuses_a_second_mint) + refusal = _refused_turn(port) + assert refusal.diagnostic == provider_mod.DIAG_BROKER_REFUSED + assert len(opener.requests) == 1, "no paid retry without a token" + assert refusal.__context__ is None From 7d465b6a291781275819e60b042e3aed0db63012 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:47:44 +0000 Subject: [PATCH 17/45] Broker path: a minted token must be presentable, and no mint refusal keeps it Gap 4 of the four broker-path gaps openDox-code#63 listed for Brett, closed with T080's own rule for a built-in credential (his word of 2026-09-29, given in-session). `mint` now asks `_presentable`, the built-in resolver's own test, of the token: non-empty printable ASCII with no whitespace. Any other token is a malformed answer (DIAG_BROKER_MALFORMED). It is refused before the port holds it or any provider is contacted, so the catalog reads unavailable and no mint is recorded. At T080's head 3f14bb96: - a token outside latin-1 failed inside urllib as DIAG_PROVIDER_UNREACHABLE, which names the wrong party, with do_open.headers and putheader.values holding it; - one with a space or another non-ASCII character was sent as it was. The refusal keeps no frame that holds the token, as the built-in resolver's refusal of an unpresentable value keeps none. `mint` reads the answer through `_minted_token` and raises any refusal again, afresh, after the answer has left its frame. That covers every refusal of the mint answer, which goes one step past the fourth gap's letter: a malformed answer beside a good token also kept it at 3f14bb96 (mint.answer, mint.document, _answer_document.text and _answer_document.document). Not changed: the broker runner's own refusals (a non-zero exit, an answer past the bound, a timeout) still keep what a misbehaving broker wrote. The runner is shared by all four broker operations and sits outside the provider call the ruling names. The PR flags it for Brett. Tests: 15 cases. With 3f14bb96 as the source the 13 gap cases fail, and the 2 controls pass (every printable ASCII character but the space is presented unchanged; the declared answer mints). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 52 +++++++++++-- tests/test_model_provider_broker.py | 116 ++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 8 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 08e01238..d9c3f102 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -637,7 +637,20 @@ def mint(binding, *, retry_of: str | None = None, this check. It is repeated before the broker is asked all the same, as the built-in resolver repeats it before it reads, because this is the function that obtains the token. What reaches it is a programming error, and - nothing has been minted when it is raised.""" + nothing has been minted when it is raised. + + A TOKEN THAT CANNOT BE PRESENTED AS IT IS, IS REFUSED, with + `DIAG_BROKER_MALFORMED`. The test is the built-in resolver's own + (`_presentable`), and the refusal comes before the port holds the token + or any provider is contacted. Measured at T080's head: a token outside + latin-1 failed inside `urllib` as `DIAG_PROVIDER_UNREACHABLE`, which + names the wrong party, and one with a space or another non-ASCII + character was sent as it was. + + NO REFUSAL OF THE ANSWER KEEPS IT. The answer carries the token, so every + refusal raised once it is read is raised again here, afresh, after the + answer has left this frame. It keeps no frame, cause or context that + holds the token, as the built-in resolver lets go of what it read.""" if not binding_mod.is_a_private_route(binding.endpoint): raise AssertionError( f"binding {binding.id!r} would present a minted token over a " @@ -645,9 +658,28 @@ def mint(binding, *, retry_of: str | None = None, "declared; nothing was minted") answer = runner(broker_operation_argv(binding, OPERATION_MINT, retry_of=retry_of)) + try: + return _minted_token(answer, binding) + except BrokerRefused as refusal: + failure = refusal.diagnostic + del answer + raise BrokerRefused(failure) + + +def _minted_token(answer: object, binding) -> MintedToken: + """A mint answer, read EXACTLY (`_answer_document`), as a `MintedToken`. + + Every refusal is `DIAG_BROKER_MALFORMED`: an answer of another shape, a + token that cannot be presented as it is (`_presentable`, which also + refuses one that is not a string or is blank), or an expiry or an audit + reference the declaration does not allow. `mint` raises each one again, + holding nothing of the answer.""" document = _answer_document(answer, BROKER_MINT_KIND, MINT_FIELDS) + token = document["token"] + if not _presentable(token): + raise BrokerRefused(DIAG_BROKER_MALFORMED) return MintedToken( - token=_declared_string(document, "token"), + token=token, expires_at=_parse_expires_at(document["expires_at"]), endpoint=binding.endpoint, dialect=binding.dialect, @@ -691,8 +723,9 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: # --------------------------------------------------------------------------- def _presentable(value: object) -> bool: - """Whether a resolved value can be presented AS IT IS, as the bearer - credential of the request's Authorization header. + """Whether a credential can be presented AS IT IS, as the bearer + credential of the request's Authorization header. It is asked of a value + the built-in resolver read, and of a token a broker minted (`mint`). It must be a non-empty string of printable ASCII with no whitespace, which a bearer credential is by its grammar (RFC 6750's `b64token` is narrower @@ -706,7 +739,8 @@ def _presentable(value: object) -> bool: * any other non-ASCII character, and an embedded space, is SENT, as a credential the grammar does not allow. Trimming or re-encoding the value would present a credential other than - the one the reference names, so the value is refused instead.""" + the one the reference names or the broker minted, so the value is + refused instead.""" return (isinstance(value, str) and value != "" and all("!" <= character <= "~" for character in value)) @@ -895,9 +929,11 @@ class _PresentedCredential: of openDox-code#63 at `d240fd50`). A traceback keeps the frames it passes through, and an error reporter that records a frame's locals records them by their repr. So in this module a raw credential is a local of no frame - except the one that reads it: `resolve_credential_reference`, until it - returns. Every frame that carries a credential to the provider carries - this wrapper instead.""" + except the ones that read it, until they return: + `resolve_credential_reference`, and `subprocess_broker_runner`, `mint` + and `_minted_token` reading a broker's answer. Every frame that carries a + credential to the provider carries this wrapper instead, or a + `MintedToken`.""" __slots__ = ("_value",) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index ec98a352..84c12f86 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3117,3 +3117,119 @@ def refuses_a_second_mint(argv, **kwargs): assert refusal.diagnostic == provider_mod.DIAG_BROKER_REFUSED assert len(opener.requests) == 1, "no paid retry without a token" assert refusal.__context__ is None + + +@pytest.mark.parametrize("token", [ + f"{SENTINEL_TOKEN}€", f"{SENTINEL_TOKEN}é", + "mint-stand-in NOT-A-TOKEN", "mint-stand-in\tNOT-A-TOKEN", + f"{SENTINEL_TOKEN}\n", f"{SENTINEL_TOKEN}\x00", f"{SENTINEL_TOKEN}\x7f", + f"{SENTINEL_TOKEN} ", +], ids=["outside-latin-1", "latin-1-not-ascii", "embedded-space", "tab", + "line-break", "nul", "delete", "trailing-space"]) +def test_an_unpresentable_token_is_refused_before_any_request(tmp_path, + token): + """Gap 4. A bearer credential is printable ASCII with no whitespace, and + the minted token is held to the test the built-in resolver's value + meets. At T080's head each of these reached the opener as it was. A + refused answer is no mint: nothing is held, and nothing is recorded.""" + port, opener = _port(tmp_path, _chat_completion(), dialect=OPENAI_CHAT, + token=token) + refusal = _refused_turn(port) + assert refusal.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + assert opener.requests == [], "no provider was contacted" + assert port.catalog().entries[0].available is False + assert port.ledger == [] + + +def test_a_token_outside_latin_1_is_refused_before_any_header_is_built( + tmp_path, monkeypatch): + """Gap 4, over a real socket, because the failure was `urllib`'s. At + T080's head such a token failed while the header was encoded, and the + turn read `DIAG_PROVIDER_UNREACHABLE`, which names the wrong party. It + is now the broker's answer that is refused, nothing is chained, and no + request is sent.""" + monkeypatch.setattr(_ChatCompletionsHandler, "seen", {}) + with _stand_in_provider(_ChatCompletionsHandler) as base: + refusal = _refused_turn(_minting_port( + tmp_path, f"{base}/v1/chat/completions", + token=f"{SENTINEL_TOKEN}€")) + assert refusal.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + assert refusal.__cause__ is None + assert refusal.__context__ is None + assert _ChatCompletionsHandler.seen == {}, "no request reached the server" + + +def test_a_token_of_printable_ascii_is_presented_as_it_is(tmp_path): + """The control for gap 4: the check refuses what a bearer credential + cannot be and nothing more, so every printable ASCII character but the + space is presented unchanged.""" + token = "mint-" + "".join(chr(code) for code in range(0x21, 0x7F)) + port, opener = _port(tmp_path, _chat_completion("a"), dialect=OPENAI_CHAT, + token=token) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + assert opener.requests[0].get_header("Authorization") == f"Bearer {token}" + + +def test_an_unpresentable_token_leaves_no_frame_that_holds_it(tmp_path): + """A token refused as unpresentable can still be most of a token, such + as one with a line break after it. The refusal keeps no frame that holds + it, as the built-in resolver's refusal of an unpresentable value keeps + none.""" + port, _opener = _port(tmp_path, _chat_completion(), dialect=OPENAI_CHAT, + token=f"{SENTINEL_TOKEN}\n") + refusal = _refused_turn(port) + assert refusal.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + assert _locals_holding(refusal, SENTINEL_TOKEN) == [] + + +def _mint_answer(**changes) -> dict: + """A mint answer in the declared shape, carrying `SENTINEL_TOKEN`, with + `changes` applied.""" + answer = { + "schema_version": 1, "kind": "openprofiler_broker_mint", + "reference": FAKE_REFERENCE, "binding": "openprofiler-demo", + "provider": "demo-provider", "auth_kind": "api_key", + "token": SENTINEL_TOKEN, "token_type": "api_key", + "issued_at": "2026-08-26T14:07:52Z", + "expires_at": _iso(time.time() + 300), "expires_in_seconds": 300, + "scope": [], "issued_by": "openprofiler-broker/0.1.4-fake", + "approved_by": "brett@opensoft.one", + "audit_ref": "opaud-" + "0" * 24, "retry_of": None, + "enforcement": {"expiry": "broker_bookkeeping", "scope": "declared"}} + answer.update(changes) + return answer + + +def _broker_answering(tmp_path, text: str) -> Path: + """A broker that answers every operation with `text`, verbatim.""" + script = tmp_path / "answering-broker.py" + script.write_text(f"import sys\nsys.stdout.write({text!r})\n", + encoding="utf-8") + return script + + +def test_the_declared_mint_answer_mints(tmp_path): + """The control for the case below: this answer, unchanged, mints.""" + script = _broker_answering(tmp_path, json.dumps(_mint_answer())) + assert provider_mod.mint(_broker_binding(script)).token == SENTINEL_TOKEN + + +@pytest.mark.parametrize("text", [ + json.dumps(_mint_answer(expires_at="not-an-instant")), + json.dumps(_mint_answer(debug_note="a key the declaration does not name")), + json.dumps(_mint_answer())[:-1], +], ids=["expiry-malformed", "undeclared-key", "not-json"]) +def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( + tmp_path, text): + """The same rule for every refusal of the answer that carried the token. + At T080's head the answer stayed in the refusal's frames (measured: + `mint.answer`, `mint.document`, `_answer_document.text` and + `_answer_document.document`). Its refusal keeps no frame, cause or + context that holds the token.""" + binding = _broker_binding(_broker_answering(tmp_path, text)) + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.mint(binding) + assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert _locals_holding(caught.value, SENTINEL_TOKEN) == [] From af457e6659ef8377a353a08aaa23119db57b36c5 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:00:54 +0000 Subject: [PATCH 18/45] Broker path tests: two escapes and three docstrings The two unpresentable-token literals outside ASCII are written as backslash-u escapes, as the rest of this module writes them, so the source adds no non-ASCII character. The gap-1 control and the operator door's case each gain a line saying what they hold. The `none` case says how T080 once pinned its scope there (two routes, with a broker binding). No test changes what it runs. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_provider_broker.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 84c12f86..0213fbd2 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -2318,9 +2318,9 @@ def test_a_broker_reference_in_a_built_in_form_is_malformed(tmp_path): @NOT_ON_A_PRIVATE_ROUTE def test_a_none_binding_keeps_a_route_that_is_not_private(endpoint): """The auth kind `none` presents no credential, so it is the one kind - that keeps such a route. Until the broker path's hardening (the last - section of this file), this case also declared a broker binding on these - routes, pinning T080's scope. That half is now refused.""" + that keeps such a route. Before the broker path's hardening (the last + section of this file), T080 pinned its scope here by declaring a broker + binding on two such routes as well. That half is now refused.""" assert _none_binding(endpoint=endpoint).credential_source() == ( binding_mod.NO_CREDENTIAL) @@ -2957,6 +2957,7 @@ def test_set_credential_refuses_a_binding_no_broker_answers(tmp_path, capsys, @ON_A_PRIVATE_ROUTE def test_a_broker_token_is_declared_on_a_private_route(endpoint): + """The control for gap 1: every route a built-in credential may take.""" binding = _binding(endpoint=endpoint, dialect=OPENAI_CHAT) assert binding.endpoint == endpoint assert binding.credential_source() == binding_mod.CREDENTIAL_FROM_BROKER @@ -2993,6 +2994,7 @@ def test_mint_asks_no_broker_for_a_token_on_a_route_that_is_not_private( def test_the_cli_refuses_a_broker_binding_over_http_to_another_host( tmp_path, capsys): + """Gap 1, through the operator door: refused, and nothing is stored.""" checkout = tmp_path / "checkout" checkout.mkdir() args = cli_mod.build_parser().parse_args([ @@ -3120,7 +3122,7 @@ def refuses_a_second_mint(argv, **kwargs): @pytest.mark.parametrize("token", [ - f"{SENTINEL_TOKEN}€", f"{SENTINEL_TOKEN}é", + f"{SENTINEL_TOKEN}\u20ac", f"{SENTINEL_TOKEN}\u00e9", "mint-stand-in NOT-A-TOKEN", "mint-stand-in\tNOT-A-TOKEN", f"{SENTINEL_TOKEN}\n", f"{SENTINEL_TOKEN}\x00", f"{SENTINEL_TOKEN}\x7f", f"{SENTINEL_TOKEN} ", @@ -3152,7 +3154,7 @@ def test_a_token_outside_latin_1_is_refused_before_any_header_is_built( with _stand_in_provider(_ChatCompletionsHandler) as base: refusal = _refused_turn(_minting_port( tmp_path, f"{base}/v1/chat/completions", - token=f"{SENTINEL_TOKEN}€")) + token=f"{SENTINEL_TOKEN}\u20ac")) assert refusal.diagnostic == provider_mod.DIAG_BROKER_MALFORMED assert refusal.__cause__ is None assert refusal.__context__ is None From a2c838a09ced781814a8672737775f8c54461d48 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:16:40 +0000 Subject: [PATCH 19/45] Broker path tests: the redirect case says which codes urllib followed Copilot's review of openDox-code#64 at af457e66 (thread 4133295457): the docstring's "301, 302 or 303" wrapped so that it read as repeating 303, and "a 307 or 308 read as the provider refusing" was hard to parse. It now says that at T080's head urllib followed only the 301, 302 and 303 codes, with a GET that still carried the token, and refused a 307 or a 308 with DIAG_PROVIDER_REFUSED, as the base probe measured. No test changes what it runs. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_provider_broker.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 0213fbd2..8b2df2aa 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3031,11 +3031,15 @@ def _refused_turn(port) -> provider_mod.BrokerRefused: @pytest.mark.parametrize("code", [301, 302, 303, 307, 308]) def test_a_broker_token_follows_no_redirect(tmp_path, monkeypatch, code): - """Gap 2, over real sockets. At T080's head a POST answered 301, 302 or - 303 reached the redirect's target as a GET that still carried the minted - token, and the turn was answered from there. A 307 or 308 read as the - provider refusing. The redirect is declined, the second server hears - nothing, and the refusal chains nothing.""" + """Gap 2, over real sockets, for each redirect code. + + At T080's head, urllib followed only the 301, 302 and 303 codes. For + those it sent a GET that still carried the minted token to the + redirect's target, and the turn was answered from there. It did not + follow a 307 or a 308, and the turn refused with + `DIAG_PROVIDER_REFUSED`, as though the provider had refused. Now every + redirect is declined, the second server hears nothing, and the refusal + chains nothing.""" with _a_provider_that_redirects(monkeypatch, code) as base: refusal = _refused_turn( _minting_port(tmp_path, f"{base}/v1/chat/completions")) From 788d764b569208c8e92b5235498a462e3b7f6b27 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:26:05 +0000 Subject: [PATCH 20/45] Broker path: a mint answer whose expiry is no finite number is malformed Copilot's review of openDox-code#64 at a2c838a0 (thread 4133360345): `_parse_expires_at` let an integer past a float's range escape as an OverflowError, and took NaN and the infinities as an expiry. `mint` re-raises only a BrokerRefused afresh, so the OverflowError escaped with the answer still in its frames. That defeats this PR's rule that no refusal of a mint keeps the answer. A NaN or infinite expiry also minted a token that would never expire, or would always have expired. An expiry must now be a finite number, or an ISO-8601 instant as before. Anything else is a malformed answer (DIAG_BROKER_MALFORMED), which `mint` raises again holding nothing. The same measurement found one more escape of the same class: arrays nested past the recursion limit fit inside the 64 KiB answer bound (30,000 of them in 60 KB), and json.loads' RecursionError escaped `_answer_document`. That is now malformed too, for all four broker operations. Tests: 5 cases join the malformed-answer case (an expiry past a float, NaN, Infinity, -Infinity, and an answer nested past the limit), and each answer is held inside the runner's bound. With 3f14bb96 as the source all 8 of that case's params fail. Three mutants are killed: the finite check removed (4 fail), the overflow left to float() (1), and RecursionError not caught (1). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 25 ++++++++++++++++++++++--- tests/test_model_provider_broker.py | 28 +++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index d9c3f102..475dc8af 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -99,6 +99,7 @@ import dataclasses import json +import math import os import shutil import subprocess @@ -359,11 +360,26 @@ def _parse_expires_at(value: object) -> float: Accepts an ISO-8601 instant (the spelling `credential-contracts` uses for its own `expires_at`) or a plain number of epoch seconds. A naive instant is read as UTC — the alternative, reading it in the console host's local zone, - would make a token's life depend on where the operator lives.""" + would make a token's life depend on where the operator lives. + + A NUMBER MUST BE FINITE (Copilot's review of openDox-code#64 at + `a2c838a0`). JSON can carry an integer too large for a float, which + `float()` refuses with an `OverflowError`. Python's JSON reader also takes + `NaN`, `Infinity` and `-Infinity`, and none of them is an instant: the + token would never expire, or would always have expired. Each is a + malformed answer, refused with the fixed sentence, so no token whose + expiry cannot be read is held, and `mint` raises the refusal again, + holding nothing of the answer.""" if isinstance(value, bool): raise BrokerRefused(DIAG_BROKER_MALFORMED) if isinstance(value, (int, float)): - return float(value) + try: + seconds = float(value) + except OverflowError: + seconds = math.inf + if not math.isfinite(seconds): + raise BrokerRefused(DIAG_BROKER_MALFORMED) + return seconds if not isinstance(value, str) or not value.strip(): raise BrokerRefused(DIAG_BROKER_MALFORMED) text = value.strip() @@ -546,7 +562,10 @@ def _answer_document(text: object, kind: str, fields) -> dict: raise BrokerRefused(DIAG_BROKER_MALFORMED) try: document = json.loads(text) - except (ValueError, TypeError) as error: + # A RecursionError too: arrays nested past the interpreter's limit fit + # well inside MAX_BROKER_ANSWER_BYTES (measured: 30,000 of them in 60 KB), + # and such an answer is malformed, not a crash that escapes with it. + except (ValueError, TypeError, RecursionError) as error: raise BrokerRefused(DIAG_BROKER_MALFORMED) from error if not isinstance(document, dict): raise BrokerRefused(DIAG_BROKER_MALFORMED) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 8b2df2aa..c9917e4e 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3220,18 +3220,40 @@ def test_the_declared_mint_answer_mints(tmp_path): assert provider_mod.mint(_broker_binding(script)).token == SENTINEL_TOKEN +#: A mint answer nested past the interpreter's recursion limit, and still well +#: inside the broker's answer bound. +_NESTED_PAST_THE_LIMIT = (json.dumps(_mint_answer())[:-1] + ', "deep": ' + + "[" * 30_000 + "]" * 30_000 + "}") + + @pytest.mark.parametrize("text", [ json.dumps(_mint_answer(expires_at="not-an-instant")), json.dumps(_mint_answer(debug_note="a key the declaration does not name")), json.dumps(_mint_answer())[:-1], -], ids=["expiry-malformed", "undeclared-key", "not-json"]) + json.dumps(_mint_answer(expires_at=10 ** 400)), + json.dumps(_mint_answer(expires_at=float("nan"))), + json.dumps(_mint_answer(expires_at=float("inf"))), + json.dumps(_mint_answer(expires_at=float("-inf"))), + _NESTED_PAST_THE_LIMIT, +], ids=["expiry-malformed", "undeclared-key", "not-json", + "expiry-past-a-float", "expiry-nan", "expiry-infinite", + "expiry-minus-infinite", "nested-past-the-recursion-limit"]) def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( tmp_path, text): """The same rule for every refusal of the answer that carried the token. At T080's head the answer stayed in the refusal's frames (measured: `mint.answer`, `mint.document`, `_answer_document.text` and - `_answer_document.document`). Its refusal keeps no frame, cause or - context that holds the token.""" + `_answer_document.document`). + + Some answers escaped `mint` outright (Copilot's review of + openDox-code#64 at `a2c838a0`): an expiry past a float's range, as an + `OverflowError`, and an answer nested past the recursion limit, as a + `RecursionError`. An expiry of `NaN` or an infinity minted a token that + would never expire, or would always have expired. Each is now a + malformed answer, and its refusal keeps no frame, cause or context that + holds the token.""" + assert len(text.encode("utf-8")) <= provider_mod.MAX_BROKER_ANSWER_BYTES, \ + "a case for the answer's parser, not for the runner's bound" binding = _broker_binding(_broker_answering(tmp_path, text)) with pytest.raises(provider_mod.BrokerRefused) as caught: provider_mod.mint(binding) From 25788f91d3f4fddb3578558df196c04f09b88fbc Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:19:41 +0000 Subject: [PATCH 21/45] Broker runner: a misbehaving broker's refusal keeps nothing it wrote Brett Heap's word of 2026-09-29 (openxFactory#656, comment 5901112350): "Yes, add to #64". When a broker misbehaves (it exits non-zero, answers past the size bound, or times out after writing), the shared runner's refusal carries no broker output, no cause and no context, for all four operations. The refusal names the operation and the failure class, and never the bytes. What was measured at 788d764b, with a broker that wrote a fake token first: - exit non-zero, or past the bound: the token stayed in the runner's frame (answer, and the child's _fileobj2output). Past the bound read as MALFORMED. - timeout: the refusal chained the TimeoutExpired, whose output and whose frames inside subprocess held the token. - an answer that is not UTF-8: a UnicodeDecodeError escaped holding the token, and no refusal was raised at all. - no refusal named its operation, and two of the sentences said "so no token could be minted" for intake, revoke and list too. The runner's work moves to _run_broker, which returns an answer or a sentence and raises no refusal of its own. subprocess_broker_runner raises the refusal after that call returns, so it is outside every handler and in a frame that never held the child. A decode error is caught, and so is a decode error while a refused child is reaped. The four operations now ask through one wrapper, _broker_operation. It raises each refusal again, afresh, with the operation named, so an injected runner is covered too. BrokerRefused takes operation=, but only from OPERATIONS and only beside a broker's sentence (BROKER_DIAGNOSTICS). Its message reads "broker : ", and .diagnostic is unchanged. The broker's sentences no longer name an operation. An answer past the bound has its own sentence, DIAG_BROKER_OVERSIZE, so there are twelve fixed diagnostics. Tests: 36 new cases. They cover the shared runner for five misbehaviours, each of the four operations for each misbehaviour, a broker that cannot be started, an injected runner, the operation vocabulary, and the operator's set-credential door. With 788d764b as the source, 37 cases fail: the 36 new ones except the one guard, plus the two tests that were updated. 14 new mutants are killed, and all 16 earlier ones are still killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 234 +++++++++++++++++++----- tests/test_model_provider_broker.py | 268 ++++++++++++++++++++++++++-- 2 files changed, 442 insertions(+), 60 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 475dc8af..15f1f7fa 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -70,7 +70,10 @@ this module raises carries one of the FIXED sentences below, composed from nothing the broker or the provider said, so `doxbench_model.dispatch_turn` maps it onto the same redacted `model_failed` every other adapter failure - already maps onto. + already maps onto. A broker's refusal keeps nothing the broker wrote: no + cause, no context, and no frame that holds its answer (Brett Heap's word + of 2026-09-29). It names the operation that failed, one of the declared + four, and the failure class. THE PROGRAM IS DECLARED; THE VERBS ARE THE DECLARATION'S (task 2.6). This was written while openProfiler was unbuilt, so it named the operation in a JSON @@ -224,14 +227,32 @@ #: the provider said. A broker's stderr, a provider's error body and an #: exception's text are all dropped unread at the boundary that observes them, #: exactly as `dispatch_turn` drops a provider exception's text. +#: +#: THE BROKER'S SENTENCES NAME A FAILURE CLASS AND NO OPERATION. They are +#: shared by all four operations, and a refusal raised by one of them names +#: that operation beside the sentence (`BrokerRefused.operation`). At +#: `788d764b` two of them said "so no token could be minted" whichever +#: operation had failed, and a broker that answered past the bound was refused +#: as MALFORMED, beside every answer of the wrong shape. DIAG_BROKER_UNREACHABLE = ( - "the credential broker could not be started, so no token could be minted") + "the credential broker could not be started") DIAG_BROKER_REFUSED = ( - "the credential broker refused, so no token could be minted") + "the credential broker exited non-zero, and its answer is withheld by " + "design") DIAG_BROKER_MALFORMED = ( - "the credential broker's answer did not match the declared mint contract") + "the credential broker's answer did not match the operation's declared " + "contract, and it is withheld by design") DIAG_BROKER_TIMEOUT = ( - "the credential broker did not answer within the declared timeout") + "the credential broker did not answer within the declared timeout, and " + "anything it wrote is withheld by design") +#: `MAX_BROKER_ANSWER_BYTES` exceeded. The provider's bound stays on +#: `DIAG_PROVIDER_MALFORMED` (see `MAX_PROVIDER_ANSWER_BYTES`). The broker's +#: has its own sentence: Brett Heap's word of 2026-09-29 names three ways a +#: broker misbehaves, this is one of them, and a refusal names its class. It +#: says only that the bound was passed, never by how much. +DIAG_BROKER_OVERSIZE = ( + "the credential broker's answer was larger than the declared bound, and " + "it is withheld by design") DIAG_PROVIDER_UNREACHABLE = ( "the provider could not be reached and its details are withheld by design") DIAG_PROVIDER_REFUSED = ( @@ -262,9 +283,10 @@ "declare the endpoint the provider redirects to") #: The closed set, so a test can assert no other sentence can be raised. -#: ELEVEN: the eight the reconciliation left, the built-in resolver's two -#: (#1144 box 16.3), and the redirect a request carrying a credential -#: declines. `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded +#: TWELVE: the eight the reconciliation left, the built-in resolver's two +#: (#1144 box 16.3), the redirect a request carrying a credential declines, +#: and a broker answer past the bound (2026-09-29). +#: `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded #: moved: the dialect is the BINDING's, validated against the closed vocabulary #: when the operator declares it #: (`doxbench_binding.ModelProviderBinding.__post_init__`), so an unknown @@ -276,7 +298,14 @@ DIAG_BROKER_TIMEOUT, DIAG_PROVIDER_UNREACHABLE, DIAG_PROVIDER_REFUSED, DIAG_PROVIDER_MALFORMED, DIAG_TOKEN_EXPIRED_TWICE, DIAG_REFERENCE_UNRESOLVED, DIAG_KEYRING_UNAVAILABLE, - DIAG_PROVIDER_REDIRECTED, + DIAG_PROVIDER_REDIRECTED, DIAG_BROKER_OVERSIZE, +}) + +#: The sentences a BROKER's failure is stated in, the only ones a refusal may +#: name an operation beside. +BROKER_DIAGNOSTICS: frozenset[str] = frozenset({ + DIAG_BROKER_UNREACHABLE, DIAG_BROKER_REFUSED, DIAG_BROKER_MALFORMED, + DIAG_BROKER_TIMEOUT, DIAG_BROKER_OVERSIZE, }) @@ -286,16 +315,33 @@ class BrokerRefused(RuntimeError): Deliberately carries no payload, no status code, no stderr and no response body: there is no attribute a caller could log that discloses provider or - broker detail, which is the same discipline `TurnDispatchFailure` keeps.""" - - def __init__(self, diagnostic: str) -> None: + broker detail, which is the same discipline `TurnDispatchFailure` keeps. + + A BROKER'S REFUSAL NAMES ITS OPERATION, so that a refusal holding + nothing a broker wrote (Brett Heap's word of 2026-09-29, openxFactory#656) + still says what failed. `operation` is one of `OPERATIONS`, given only + beside a broker's sentence (`BROKER_DIAGNOSTICS`), and the message reads + "broker : ". Both halves come from this module's + closed vocabularies, so the message still holds nothing a broker wrote. + `diagnostic` stays the sentence alone, and `operation` is None for a + provider's refusal and for one the runner raises by itself.""" + + def __init__(self, diagnostic: str, *, + operation: str | None = None) -> None: if diagnostic not in FIXED_DIAGNOSTICS: raise AssertionError( "a broker refusal carries a FIXED diagnostic; composing one " "from what the broker or the provider said is exactly what " "this class exists to prevent") - super().__init__(diagnostic) + if operation is not None and (operation not in OPERATIONS + or diagnostic not in BROKER_DIAGNOSTICS): + raise AssertionError( + "a refusal names an operation only beside a broker's " + "sentence, and only one of the declared four") + super().__init__(diagnostic if operation is None + else f"broker {operation}: {diagnostic}") self.diagnostic = diagnostic + self.operation = operation class _TokenExpired(Exception): @@ -439,7 +485,44 @@ def subprocess_broker_runner(argv, *, source=None, broker's own words must never reach a caller, inheriting this process's stderr would put them on the console, and capturing them into a pipe would make this process's memory a function of how noisy a declared program - chooses to be. The kernel drops them instead, unread by construction.""" + chooses to be. The kernel drops them instead, unread by construction. + + A BROKER THAT MISBEHAVES IS REFUSED WITH NOTHING IT WROTE (Brett Heap's + word of 2026-09-29, openxFactory#656), for all four operations. That is a + broker that exits non-zero, answers past `MAX_BROKER_ANSWER_BYTES`, times + out, or writes an answer that is not UTF-8. Each refusal is raised here, + after `_run_broker` has returned: outside every handler, so it keeps no + cause and no context, and from the one frame that never held the child or + its answer. Measured at #64's `788d764b`, a broker that wrote a token and + then exited non-zero, or wrote past the bound, left it in this frame's + `answer` and in the child's buffers. One that wrote it and then timed out + chained the `TimeoutExpired` that holds it. One that wrote it beside a + byte that is not UTF-8 escaped as a `UnicodeDecodeError` that holds it, + and was no refusal at all.""" + answer, failure = _run_broker(argv, source=source, timeout=timeout) + if failure is not None: + raise BrokerRefused(failure) + return answer + + +def _reap(child) -> None: + """Kill a child this runner is refusing and finish its communication, + dropping whatever it wrote. The drop includes an answer that is not + UTF-8, whose `UnicodeDecodeError` would otherwise escape from the + handler that called this, holding that answer.""" + child.kill() + try: + child.communicate() + except UnicodeDecodeError: + pass + + +def _run_broker(argv, *, source, + timeout: float) -> tuple[str | None, str | None]: + """The work of `subprocess_broker_runner`: `(answer, None)`, or + `(None, sentence)` for a refusal. It raises no refusal itself, so no + refusal keeps its frame, which holds the child and what the child + wrote.""" try: child = subprocess.Popen( # noqa: S603 - argv from a declared binding plus the declared subcommand, never a shell string list(argv), @@ -449,8 +532,8 @@ def subprocess_broker_runner(argv, *, source=None, env=bridge_mod.child_environment(os.environ), text=True, ) - except (OSError, ValueError) as error: - raise BrokerRefused(DIAG_BROKER_UNREACHABLE) from error + except (OSError, ValueError): + return None, DIAG_BROKER_UNREACHABLE try: try: if source is not None: @@ -475,20 +558,26 @@ def subprocess_broker_runner(argv, *, source=None, # it is also the last place in this process that could have held the # pipe the credential travelled down. child.stdin = None - answer, _dropped_stderr = child.communicate(timeout=timeout) - except subprocess.TimeoutExpired as error: - child.kill() - child.communicate() - raise BrokerRefused(DIAG_BROKER_TIMEOUT) from error - except OSError as error: - child.kill() - child.communicate() - raise BrokerRefused(DIAG_BROKER_UNREACHABLE) from error + try: + output, _dropped_stderr = child.communicate(timeout=timeout) + except UnicodeDecodeError: + # An answer that is not UTF-8. `communicate` decodes only after + # it has waited for the child, so the exit code is read below + # as for any other answer. + output = None + except subprocess.TimeoutExpired: + _reap(child) + return None, DIAG_BROKER_TIMEOUT + except OSError: + _reap(child) + return None, DIAG_BROKER_UNREACHABLE if child.returncode != 0: - raise BrokerRefused(DIAG_BROKER_REFUSED) - if len(answer.encode("utf-8")) > MAX_BROKER_ANSWER_BYTES: - raise BrokerRefused(DIAG_BROKER_MALFORMED) - return answer + return None, DIAG_BROKER_REFUSED + if output is None: + return None, DIAG_BROKER_MALFORMED + if len(output.encode("utf-8")) > MAX_BROKER_ANSWER_BYTES: + return None, DIAG_BROKER_OVERSIZE + return output, None def broker_operation_argv(binding, operation: str, *, @@ -585,6 +674,37 @@ def _declared_string(document: Mapping, field: str) -> str: return value +def _broker_operation(binding, operation: str, read, *, runner, + source=None, retry_of: str | None = None): + """Run one declared `operation` through `runner`, and return what `read` + makes of its answer. It is the one way each of the four operations asks + the broker. + + EVERY REFUSAL KEEPS NOTHING THE BROKER WROTE (Brett Heap's word of + 2026-09-29, openxFactory#656), AND NAMES THE OPERATION. A refusal from the + runner, or from `read`, is raised again here with the operation named + (`BrokerRefused.operation`). It is raised afresh, outside the handler and + after the answer has left this frame, so it keeps no cause, no context + and no frame that holds the answer, whichever runner was injected. The + answer is dropped even when it carries no secret, since a broker that + misbehaves may write anything into it. + + `source` is given to the runner only when there is one, which is + `intake`'s case. Every other operation reads no standard input.""" + argv = broker_operation_argv(binding, operation, retry_of=retry_of) + answer = None + try: + if source is None: + answer = runner(argv) + else: + answer = runner(argv, source=source) + return read(answer) + except BrokerRefused as refusal: + failure = refusal.diagnostic + del answer + raise BrokerRefused(failure, operation=operation) + + # --------------------------------------------------------------------------- # the credential hand-off (task 1.3) # --------------------------------------------------------------------------- @@ -617,9 +737,16 @@ def hand_off_credential(binding, source, *, beside the broker that holds the credential, which is two resolvers. The record refuses that, and it would do so where neither entry point expects a refusal. So such an answer is malformed, and it is refused - here, where both entry points already catch a broker's refusal.""" - answer = runner(broker_operation_argv(binding, OPERATION_INTAKE), - source=source) + here, where both entry points already catch a broker's refusal. + + A refusal names `intake` and keeps nothing the broker wrote + (`_broker_operation`).""" + return _broker_operation(binding, OPERATION_INTAKE, _intake_reference, + runner=runner, source=source) + + +def _intake_reference(answer: object) -> str: + """An intake answer, read EXACTLY, as the `reference` it returns.""" document = _answer_document(answer, BROKER_INTAKE_KIND, INTAKE_FIELDS) reference = _declared_string(document, "reference") if binding_mod.names_a_built_in_form(reference): @@ -667,22 +794,19 @@ def mint(binding, *, retry_of: str | None = None, character was sent as it was. NO REFUSAL OF THE ANSWER KEEPS IT. The answer carries the token, so every - refusal raised once it is read is raised again here, afresh, after the - answer has left this frame. It keeps no frame, cause or context that - holds the token, as the built-in resolver lets go of what it read.""" + refusal is raised again, afresh, after the answer has left the frame that + read it (`_broker_operation`), and it names `mint`. It keeps no frame, + cause or context that holds the token, as the built-in resolver lets go + of what it read.""" if not binding_mod.is_a_private_route(binding.endpoint): raise AssertionError( f"binding {binding.id!r} would present a minted token over a " "route that is not private, which the record refuses when it is " "declared; nothing was minted") - answer = runner(broker_operation_argv(binding, OPERATION_MINT, - retry_of=retry_of)) - try: - return _minted_token(answer, binding) - except BrokerRefused as refusal: - failure = refusal.diagnostic - del answer - raise BrokerRefused(failure) + return _broker_operation( + binding, OPERATION_MINT, + lambda answer: _minted_token(answer, binding), + runner=runner, retry_of=retry_of) def _minted_token(answer: object, binding) -> MintedToken: @@ -691,8 +815,8 @@ def _minted_token(answer: object, binding) -> MintedToken: Every refusal is `DIAG_BROKER_MALFORMED`: an answer of another shape, a token that cannot be presented as it is (`_presentable`, which also refuses one that is not a string or is blank), or an expiry or an audit - reference the declaration does not allow. `mint` raises each one again, - holding nothing of the answer.""" + reference the declaration does not allow. `_broker_operation` raises each + one again, holding nothing of the answer.""" document = _answer_document(answer, BROKER_MINT_KIND, MINT_FIELDS) token = document["token"] if not _presentable(token): @@ -712,8 +836,14 @@ def revoke(binding, *, runner=subprocess_broker_runner) -> str: trail through a revocation and refuses an unknown reference rather than answering silently, so "there was nothing there" and "it is gone now" stay different answers — and both reach a caller here as the same fixed refusal - or the same returned reference, never as the broker's own words.""" - answer = runner(broker_operation_argv(binding, OPERATION_REVOKE)) + or the same returned reference, never as the broker's own words. A + refusal names `revoke` (`_broker_operation`).""" + return _broker_operation(binding, OPERATION_REVOKE, _revocation_audit_ref, + runner=runner) + + +def _revocation_audit_ref(answer: object) -> str: + """A revocation answer, read EXACTLY, as its `audit_ref`.""" document = _answer_document(answer, BROKER_REVOCATION_KIND, REVOCATION_FIELDS) if document["revoked"] is not True: @@ -727,8 +857,14 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: Safe to read and safe to print: `list` never opens a custody file, and the index it reads carries no credential material. Returned as the declaration's own list of entries rather than reshaped, because a consumer that reshapes - an index it does not own invents a second contract for it.""" - answer = runner(broker_operation_argv(binding, OPERATION_LIST)) + an index it does not own invents a second contract for it. A refusal + names `list` (`_broker_operation`).""" + return _broker_operation(binding, OPERATION_LIST, _reference_index, + runner=runner) + + +def _reference_index(answer: object) -> list: + """A reference-index answer, read EXACTLY, as its list of entries.""" document = _answer_document(answer, BROKER_REFERENCE_LIST_KIND, REFERENCE_LIST_FIELDS) references = document["references"] diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index c9917e4e..76415601 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -50,6 +50,7 @@ import contextlib import dataclasses +import functools import http.server import io import json @@ -1141,12 +1142,19 @@ def test_the_fixed_diagnostics_are_all_reachable_and_no_more(): declaration-time refusal; keeping an unraisable sentence would be a refusal nobody can trigger. ELEVEN since #1144 box 16.3: the built-in resolver's two joined, and so did the redirect a built-in credential declines. - Section (f) below reaches each of the three.""" - assert len(provider_mod.FIXED_DIAGNOSTICS) == 11 + Section (f) below reaches each of the three. TWELVE since Brett Heap's + word of 2026-09-29: a broker answer past the bound has its own sentence, + and the last section reaches it.""" + assert len(provider_mod.FIXED_DIAGNOSTICS) == 12 assert {provider_mod.DIAG_REFERENCE_UNRESOLVED, provider_mod.DIAG_KEYRING_UNAVAILABLE, - provider_mod.DIAG_PROVIDER_REDIRECTED} <= \ + provider_mod.DIAG_PROVIDER_REDIRECTED, + provider_mod.DIAG_BROKER_OVERSIZE} <= \ provider_mod.FIXED_DIAGNOSTICS + assert provider_mod.BROKER_DIAGNOSTICS == { + provider_mod.DIAG_BROKER_UNREACHABLE, + provider_mod.DIAG_BROKER_REFUSED, provider_mod.DIAG_BROKER_MALFORMED, + provider_mod.DIAG_BROKER_TIMEOUT, provider_mod.DIAG_BROKER_OVERSIZE} assert not hasattr(provider_mod, "DIAG_DIALECT_UNKNOWN") @@ -1351,14 +1359,19 @@ def test_a_broker_that_hangs_is_refused_at_the_declared_timeout(tmp_path): def test_the_broker_answer_is_bounded(tmp_path): - script = tmp_path / "loud-broker.py" - script.write_text( - "import sys\n" - f"sys.stdout.write('x' * {provider_mod.MAX_BROKER_ANSWER_BYTES + 1})\n", - encoding="utf-8") - with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.mint(_broker_binding(script)) - assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + """One byte past the bound has its own sentence since Brett Heap's word + of 2026-09-29. It was `DIAG_BROKER_MALFORMED`, beside every answer of + the wrong shape. An answer at the bound is read, and refused only as + what it is: here, not JSON.""" + bound = provider_mod.MAX_BROKER_ANSWER_BYTES + for size, expected in ((bound + 1, provider_mod.DIAG_BROKER_OVERSIZE), + (bound, provider_mod.DIAG_BROKER_MALFORMED)): + script = tmp_path / f"loud-broker-{size}.py" + script.write_text(f"import sys\nsys.stdout.write('x' * {size})\n", + encoding="utf-8") + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.mint(_broker_binding(script)) + assert caught.value.diagnostic == expected def test_the_subprocess_runner_never_uses_a_shell(tmp_path): @@ -3261,3 +3274,236 @@ def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( assert caught.value.__cause__ is None assert caught.value.__context__ is None assert _locals_holding(caught.value, SENTINEL_TOKEN) == [] + + +# --- a broker that misbehaves is refused with nothing it wrote ----------- +# Brett Heap's word of 2026-09-29 (openxFactory#656, the lane's latest RULED +# comment): "Yes, add to #64". When a broker misbehaves, the shared runner's +# refusal carries no broker output, no cause and no context, for all four +# operations. So that it stays useful, it names the operation and the failure +# class, and never the bytes. Measured at #64's `788d764b`, with a broker that +# wrote the token before it misbehaved: +# +# * exits non-zero: the runner's frame held the token, in `answer` and in +# the child's `_fileobj2output`; +# * answers past the bound: the same, and the refusal read as MALFORMED; +# * times out: the refusal chained the `TimeoutExpired`, whose `output` +# and whose frames inside `subprocess` held the token; +# * answers in bytes that are not UTF-8: a `UnicodeDecodeError` escaped +# holding the token in `object`, and no refusal was raised at all. +# +# No refusal named its operation, and two of the sentences said no token +# could be minted, whichever operation had failed. Every case below fails at +# `788d764b`. Every token here is an obvious fake. + +#: Long enough for a Python child to start and write, well before it expires. +_MISBEHAVING_TIMEOUT = 1.0 + +#: What each broker below does once it has written `SENTINEL_TOKEN`, and the +#: failure class its refusal names. The sentence is named here, and read +#: only once the refusal has been checked for what it keeps. +_MISBEHAVIOURS = { + "exits-non-zero": ( + "sys.stdout.write(TOKEN)\nwrote()\nsys.exit(3)\n", + "DIAG_BROKER_REFUSED"), + "answers-past-the-bound": ( + "sys.stdout.write(TOKEN + 'x' * BOUND)\nwrote()\n", + "DIAG_BROKER_OVERSIZE"), + "times-out": ( + "sys.stdout.write(TOKEN)\nwrote()\ntime.sleep(30)\n", + "DIAG_BROKER_TIMEOUT"), + "answers-in-no-utf-8": ( + "sys.stdout.buffer.write(TOKEN.encode() + b'\\xff')\nwrote()\n", + "DIAG_BROKER_MALFORMED"), + "answers-in-no-utf-8-and-times-out": ( + "sys.stdout.buffer.write(TOKEN.encode() + b'\\xff')\nwrote()\n" + "time.sleep(30)\n", + "DIAG_BROKER_TIMEOUT"), +} + +#: The broker's preamble. `wrote()` flushes, then leaves a mark beside the +#: script, so a test can show the token was written before the misbehaviour. +_MISBEHAVING_PREAMBLE = ( + "import pathlib, sys, time\n" + "TOKEN = {token!r}\n" + "BOUND = {bound!r}\n" + "def wrote():\n" + " sys.stdout.flush()\n" + " pathlib.Path(sys.argv[0] + '.wrote').touch()\n") + + +def _misbehaving_broker(tmp_path, misbehaviour: str) -> Path: + body, _sentence = _MISBEHAVIOURS[misbehaviour] + script = tmp_path / f"{misbehaviour}-broker.py" + script.write_text(_MISBEHAVING_PREAMBLE.format( + token=SENTINEL_TOKEN, bound=provider_mod.MAX_BROKER_ANSWER_BYTES) + + body, encoding="utf-8") + return script + + +def _sentence_for(misbehaviour: str) -> str: + return getattr(provider_mod, _MISBEHAVIOURS[misbehaviour][1]) + + +def _wrote(script: Path) -> bool: + return Path(str(script) + ".wrote").is_file() + + +def _kept_anywhere(exception, secret: str) -> list[str]: + """`_locals_holding`, and one level deeper. The attributes of each local + are searched too, since that is where a `Popen` keeps what its child + wrote (`_fileobj2output`). So are the refusal's own arguments and + attributes.""" + found = set(_locals_holding(exception, secret)) + for frame in _frames_kept_by(exception): + for name, value in list(frame.f_locals.items()): + attributes = getattr(value, "__dict__", None) + if (isinstance(attributes, dict) + and secret in _safe_repr(attributes)): + found.add(f"{frame.f_code.co_name}.{name}.__dict__") + if (secret in _safe_repr(exception.args) + or secret in _safe_repr(vars(exception))): + found.add("the refusal itself") + return sorted(found) + + +#: Each operation, asked through its own function, with the real runner. +_OPERATIONS_ASKED = { + provider_mod.OPERATION_INTAKE: lambda binding, runner: ( + provider_mod.hand_off_credential( + binding, io.StringIO("sk-stand-in-intake-NOT-A-KEY"), + runner=runner)), + provider_mod.OPERATION_MINT: lambda binding, runner: ( + provider_mod.mint(binding, runner=runner)), + provider_mod.OPERATION_REVOKE: lambda binding, runner: ( + provider_mod.revoke(binding, runner=runner)), + provider_mod.OPERATION_LIST: lambda binding, runner: ( + provider_mod.list_references(binding, runner=runner)), +} + + +def test_every_operation_is_asked_here(): + assert tuple(_OPERATIONS_ASKED) == provider_mod.OPERATIONS + + +@pytest.mark.parametrize("misbehaviour", sorted(_MISBEHAVIOURS)) +def test_the_shared_runner_refuses_a_misbehaving_broker_keeping_nothing( + tmp_path, misbehaviour): + """The runner itself, called directly. Its refusal keeps no cause, no + context, and no frame or attribute that holds what the broker wrote. + It names the failure class. It is not told the operation, so it names + none.""" + script = _misbehaving_broker(tmp_path, misbehaviour) + argv = provider_mod.broker_operation_argv( + _broker_binding(script), provider_mod.OPERATION_MINT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.subprocess_broker_runner( + argv, timeout=_MISBEHAVING_TIMEOUT) + refusal = caught.value + assert _wrote(script), "the broker wrote the token before it misbehaved" + assert refusal.__cause__ is None + assert refusal.__context__ is None + assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] + expected = _sentence_for(misbehaviour) + assert refusal.diagnostic == expected + assert refusal.operation is None + assert str(refusal) == expected + + +@pytest.mark.parametrize("operation", provider_mod.OPERATIONS) +@pytest.mark.parametrize("misbehaviour", sorted(_MISBEHAVIOURS)) +def test_a_misbehaving_broker_is_refused_naming_the_operation( + tmp_path, misbehaviour, operation): + """Each of the four operations, through the real runner. The refusal + names the operation and the failure class, and keeps nothing the broker + wrote.""" + script = _misbehaving_broker(tmp_path, misbehaviour) + runner = functools.partial(provider_mod.subprocess_broker_runner, + timeout=_MISBEHAVING_TIMEOUT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + _OPERATIONS_ASKED[operation](_broker_binding(script), runner) + refusal = caught.value + assert _wrote(script), "the broker wrote the token before it misbehaved" + assert refusal.__cause__ is None + assert refusal.__context__ is None + assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] + expected = _sentence_for(misbehaviour) + assert refusal.diagnostic == expected + assert refusal.operation == operation + assert str(refusal) == f"broker {operation}: {expected}" + + +@pytest.mark.parametrize("operation", provider_mod.OPERATIONS) +def test_a_broker_that_cannot_be_started_chains_nothing(tmp_path, operation): + """A program that does not exist wrote nothing, and its refusal chains + nothing either. At `788d764b` it chained the `FileNotFoundError`, by the + runner and by the operation alike.""" + binding = _binding(broker_argv=(str(tmp_path / "no-such-broker"),)) + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.subprocess_broker_runner( + provider_mod.broker_operation_argv(binding, operation)) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert caught.value.diagnostic == provider_mod.DIAG_BROKER_UNREACHABLE + with pytest.raises(provider_mod.BrokerRefused) as caught: + _OPERATIONS_ASKED[operation](binding, + provider_mod.subprocess_broker_runner) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert caught.value.operation == operation + assert str(caught.value) == ( + f"broker {operation}: {provider_mod.DIAG_BROKER_UNREACHABLE}") + + +@pytest.mark.parametrize("operation", provider_mod.OPERATIONS) +def test_an_injected_runners_refusal_is_named_too(tmp_path, operation): + """The operation is named by the function that asked, so a runner that + was injected is covered as the default one is.""" + def refusing(argv, **_kwargs): + raise provider_mod.BrokerRefused(provider_mod.DIAG_BROKER_REFUSED) + + with pytest.raises(provider_mod.BrokerRefused) as caught: + _OPERATIONS_ASKED[operation](_binding(), refusing) + assert caught.value.operation == operation + assert caught.value.diagnostic == provider_mod.DIAG_BROKER_REFUSED + assert caught.value.__context__ is None + + +def test_only_a_broker_sentence_names_a_declared_operation(): + refusal = provider_mod.BrokerRefused( + provider_mod.DIAG_BROKER_TIMEOUT, + operation=provider_mod.OPERATION_REVOKE) + assert str(refusal) == f"broker revoke: {provider_mod.DIAG_BROKER_TIMEOUT}" + assert refusal.diagnostic == provider_mod.DIAG_BROKER_TIMEOUT + assert refusal.operation == provider_mod.OPERATION_REVOKE + with pytest.raises(AssertionError): + provider_mod.BrokerRefused(provider_mod.DIAG_BROKER_REFUSED, + operation="exfiltrate") + with pytest.raises(AssertionError): + provider_mod.BrokerRefused(provider_mod.DIAG_PROVIDER_REFUSED, + operation=provider_mod.OPERATION_MINT) + provider_refusal = provider_mod.BrokerRefused( + provider_mod.DIAG_PROVIDER_REFUSED) + assert provider_refusal.operation is None + assert str(provider_refusal) == provider_mod.DIAG_PROVIDER_REFUSED + + +def test_the_operator_door_names_the_operation_and_withholds_the_answer( + tmp_path, capsys): + """What an operator reads when `set-credential` meets a broker that wrote + and then exited non-zero: the operation and the failure class, and none + of what it wrote.""" + script = _misbehaving_broker(tmp_path, "exits-non-zero") + checkout = tmp_path / "checkout" + (checkout / "ideation" / "dashboard").mkdir(parents=True) + store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) + store.add(_broker_binding(script, credential_ref="opref-" + "0" * 24)) + args = cli_mod.build_parser().parse_args([ + "model-binding", "set-credential", "--repo-root", str(checkout), + "--id", "openprofiler-demo"]) + assert cli_mod.cmd_model_binding_set_credential( + args, source=io.StringIO("sk-stand-in-intake-NOT-A-KEY")) == 1 + captured = capsys.readouterr() + assert captured.err == ( + f"broker intake: {provider_mod.DIAG_BROKER_REFUSED}\n") + assert SENTINEL_TOKEN not in captured.out + captured.err From b847ef3d33910e6752cc4322f27e2310b1f39464 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:56:06 +0000 Subject: [PATCH 22/45] Broker runner: the answer's bound limits what is read Copilot's review of #64 at 25788f91 (high): communicate() read all of a broker's output before MAX_BROKER_ANSWER_BYTES was checked. So the bound limited nothing in memory, and a broker that wrote without end was read until the timeout and refused as a timeout. _run_broker now reads the answer on a reader thread, at most one byte past the bound. A broker that writes that byte is refused with DIAG_BROKER_OVERSIZE and killed at once. The thread starts before the credential is written, so the answer drains while intake's stdin is streamed. The runner then waits for the child's exit within what is left of the timeout. The answer is decoded as UTF-8 (JSON's encoding; the locale's before), and an answer that is not UTF-8 is malformed. Every refusal is still raised by subprocess_broker_runner after _run_broker has returned, so it keeps no cause, no context and nothing the broker wrote. Tests: a broker that writes without end is refused at the bound, well inside a 5 s timeout. At 25788f91 it read until the timeout (5.1 s measured), and that is the one case that fails there. A sixth misbehaviour, a broker that closes its output and then hangs, is refused as a timeout by the runner and by each operation. With 788d764b as the source, all six new cases fail too (43 of the 44 runner cases). The runner's mutants are rewritten for the new code. All 31 mutants are killed, among them the whole output read as at 25788f91, and no deadline on the child's exit. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 121 +++++++++++++++++----------- tests/test_model_provider_broker.py | 39 ++++++++- 2 files changed, 110 insertions(+), 50 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 15f1f7fa..944fc29f 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -505,24 +505,44 @@ def subprocess_broker_runner(argv, *, source=None, return answer -def _reap(child) -> None: - """Kill a child this runner is refusing and finish its communication, - dropping whatever it wrote. The drop includes an answer that is not - UTF-8, whose `UnicodeDecodeError` would otherwise escape from the - handler that called this, holding that answer.""" - child.kill() +def _read_at_most(stream, limit: int, into: list) -> None: + """A reader thread's work: at most `limit` bytes of the child's standard + output, or fewer if it ends first, appended to `into`. A read that fails + appends nothing.""" try: - child.communicate() - except UnicodeDecodeError: + into.append(stream.read(limit)) + except (OSError, ValueError): pass +def _reap(child, reader) -> None: + """Kill a child this runner is refusing, wait for it, and let its reader + finish. Whatever the child wrote is dropped with the reader.""" + child.kill() + child.wait() + reader.join() + child.stdout.close() + + def _run_broker(argv, *, source, timeout: float) -> tuple[str | None, str | None]: """The work of `subprocess_broker_runner`: `(answer, None)`, or `(None, sentence)` for a refusal. It raises no refusal itself, so no refusal keeps its frame, which holds the child and what the child - wrote.""" + wrote. + + THE BOUND IS A BOUND ON WHAT IS READ (Copilot's review of + openDox-code#64 at `25788f91`). A reader thread reads at most one byte + past `MAX_BROKER_ANSWER_BYTES`, and a broker that writes that byte is + refused and killed there. At `25788f91` the whole of the child's output + was read before the bound was checked, so a broker that wrote without + end filled this process's memory until the timeout, and was refused as + a timeout. The thread also drains the answer while the credential is + still being written, as `communicate` did not. + + The answer is decoded as UTF-8, JSON's own encoding, where + `communicate` used the locale's. An answer that is not UTF-8 is + malformed.""" try: child = subprocess.Popen( # noqa: S603 - argv from a declared binding plus the declared subcommand, never a shell string list(argv), @@ -534,50 +554,59 @@ def _run_broker(argv, *, source, ) except (OSError, ValueError): return None, DIAG_BROKER_UNREACHABLE + received: list[bytes] = [] + reader = threading.Thread( + target=_read_at_most, + args=(child.stdout.buffer, MAX_BROKER_ANSWER_BYTES + 1, received), + name="broker-answer", daemon=True) + reader.start() try: + if source is not None: + # The credential's ONLY path through this process: handle to + # pipe, in chunks, never assembled. + shutil.copyfileobj(source, child.stdin) + child.stdin.close() + except BrokenPipeError: + # THE REFUSAL ARRIVING. Nothing is raised here; the exit code and + # the child's own answer are read below, exactly as the declaration + # instructs. The close is still attempted so the descriptor is not + # left to a garbage collector, and its own broken pipe is dropped + # for the same reason the first one was. try: - if source is not None: - # The credential's ONLY path through this process: handle to - # pipe, in chunks, never assembled. - shutil.copyfileobj(source, child.stdin) child.stdin.close() - except BrokenPipeError: - # THE REFUSAL ARRIVING. Nothing is raised here; the exit code and - # the child's own answer are read below, exactly as the declaration - # instructs. The close is still attempted so the descriptor is not - # left to a garbage collector, and its own broken pipe is dropped - # for the same reason the first one was. - try: - child.stdin.close() - except OSError: - pass - # `communicate` flushes `child.stdin` before reading, which raises on a - # handle this function has already closed — and closing it IS the - # signal a streamed credential's end of file needs. Dropping the - # reference is the documented way to say "stdin is finished with", and - # it is also the last place in this process that could have held the - # pipe the credential travelled down. - child.stdin = None - try: - output, _dropped_stderr = child.communicate(timeout=timeout) - except UnicodeDecodeError: - # An answer that is not UTF-8. `communicate` decodes only after - # it has waited for the child, so the exit code is read below - # as for any other answer. - output = None - except subprocess.TimeoutExpired: - _reap(child) - return None, DIAG_BROKER_TIMEOUT + except OSError: + pass except OSError: - _reap(child) + _reap(child, reader) return None, DIAG_BROKER_UNREACHABLE - if child.returncode != 0: + # Closing `child.stdin` IS the signal a streamed credential's end of file + # needs. Dropping the reference says "stdin is finished with", and it was + # the last place in this process that could have held the pipe the + # credential travelled down. + child.stdin = None + deadline = time.monotonic() + timeout + reader.join(timeout) + if reader.is_alive(): + _reap(child, reader) + return None, DIAG_BROKER_TIMEOUT + if not received: + _reap(child, reader) + return None, DIAG_BROKER_UNREACHABLE + if len(received[0]) > MAX_BROKER_ANSWER_BYTES: + _reap(child, reader) + return None, DIAG_BROKER_OVERSIZE + try: + returncode = child.wait(timeout=max(0.0, deadline - time.monotonic())) + except subprocess.TimeoutExpired: + _reap(child, reader) + return None, DIAG_BROKER_TIMEOUT + child.stdout.close() + if returncode != 0: return None, DIAG_BROKER_REFUSED - if output is None: + try: + return received[0].decode("utf-8"), None + except UnicodeDecodeError: return None, DIAG_BROKER_MALFORMED - if len(output.encode("utf-8")) > MAX_BROKER_ANSWER_BYTES: - return None, DIAG_BROKER_OVERSIZE - return output, None def broker_operation_argv(binding, operation: str, *, diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 76415601..aaeaa930 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3287,8 +3287,9 @@ def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( # * exits non-zero: the runner's frame held the token, in `answer` and in # the child's `_fileobj2output`; # * answers past the bound: the same, and the refusal read as MALFORMED; -# * times out: the refusal chained the `TimeoutExpired`, whose `output` -# and whose frames inside `subprocess` held the token; +# * times out, with its output open or closed: the refusal chained the +# `TimeoutExpired`, whose `output` and whose frames inside `subprocess` +# held the token; # * answers in bytes that are not UTF-8: a `UnicodeDecodeError` escaped # holding the token in `object`, and no refusal was raised at all. # @@ -3307,11 +3308,14 @@ def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( "sys.stdout.write(TOKEN)\nwrote()\nsys.exit(3)\n", "DIAG_BROKER_REFUSED"), "answers-past-the-bound": ( - "sys.stdout.write(TOKEN + 'x' * BOUND)\nwrote()\n", + "sys.stdout.write(TOKEN)\nwrote()\nsys.stdout.write('x' * BOUND)\n", "DIAG_BROKER_OVERSIZE"), "times-out": ( "sys.stdout.write(TOKEN)\nwrote()\ntime.sleep(30)\n", "DIAG_BROKER_TIMEOUT"), + "closes-its-output-and-times-out": ( + "sys.stdout.write(TOKEN)\nwrote()\nos.close(1)\ntime.sleep(30)\n", + "DIAG_BROKER_TIMEOUT"), "answers-in-no-utf-8": ( "sys.stdout.buffer.write(TOKEN.encode() + b'\\xff')\nwrote()\n", "DIAG_BROKER_MALFORMED"), @@ -3324,7 +3328,7 @@ def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( #: The broker's preamble. `wrote()` flushes, then leaves a mark beside the #: script, so a test can show the token was written before the misbehaviour. _MISBEHAVING_PREAMBLE = ( - "import pathlib, sys, time\n" + "import os, pathlib, sys, time\n" "TOKEN = {token!r}\n" "BOUND = {bound!r}\n" "def wrote():\n" @@ -3410,6 +3414,33 @@ def test_the_shared_runner_refuses_a_misbehaving_broker_keeping_nothing( assert str(refusal) == expected +def test_a_broker_that_writes_without_end_is_refused_at_the_bound(tmp_path): + """Copilot's review of openDox-code#64 at `25788f91`: the bound was + checked only once the whole answer had been read, so it bounded nothing + in memory. A broker that writes without end is now refused as soon as it + passes the bound, well inside the timeout, and it is killed there. At + `25788f91`, and at `788d764b`, the runner read it until the timeout and + refused it as a timeout.""" + script = tmp_path / "endless-broker.py" + script.write_text( + "import sys, time\n" + f"sys.stdout.write({SENTINEL_TOKEN!r})\n" + "while True:\n" + " sys.stdout.write('x' * 65536)\n" + " sys.stdout.flush()\n" + " time.sleep(0.01)\n", encoding="utf-8") + argv = provider_mod.broker_operation_argv( + _broker_binding(script), provider_mod.OPERATION_MINT) + started = time.monotonic() + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.subprocess_broker_runner(argv, timeout=5.0) + assert time.monotonic() - started < 2.5, "refused at the bound" + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert _kept_anywhere(caught.value, SENTINEL_TOKEN) == [] + assert caught.value.diagnostic == provider_mod.DIAG_BROKER_OVERSIZE + + @pytest.mark.parametrize("operation", provider_mod.OPERATIONS) @pytest.mark.parametrize("misbehaviour", sorted(_MISBEHAVIOURS)) def test_a_misbehaving_broker_is_refused_naming_the_operation( From e3eec6b18be7e6727f1e92f8e54fe9cd00006dda Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:06:01 +0000 Subject: [PATCH 23/45] Broker runner: reap the child when anything else escapes b847ef3d's reader is a daemon thread. When the credential's own source failed mid-copy (the operator's input, which the ruling does not reach), the error escaped with the child still running and the reader blocked on it, and the interpreter aborted at exit ("Fatal Python error: _enter_buffered_busy"), measured 3 of 3 times. _run_broker now reaps the child before any other exception goes on, and drops what the child wrote. The work after the reader starts moves to _answer_of, unchanged. The runner's tests also assert that no refusal keeps a frame holding the child. That kills three mutants that the reap had made equivalent: a refusal raised where it is seen, for the exit, the bound and the timeout. Tests: the failing source in a child interpreter exits 1 with its own UnicodeDecodeError and no fatal error. At b847ef3d it aborts. In this process, with a broker that wrote first, what escapes keeps nothing the broker wrote. All 33 mutants are killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 19 +++++++ tests/test_model_provider_broker.py | 83 +++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 944fc29f..cd94602a 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -560,6 +560,25 @@ def _run_broker(argv, *, source, args=(child.stdout.buffer, MAX_BROKER_ANSWER_BYTES + 1, received), name="broker-answer", daemon=True) reader.start() + try: + return _answer_of(child, reader, received, source=source, + timeout=timeout) + except BaseException: + # Anything else that escapes, such as the credential's own source + # failing while it is copied (the operator's input, not the broker's + # output), goes on as it was. The child is reaped first, and what it + # wrote is dropped: a reader left blocked in its daemon thread would + # abort the interpreter when it exits. + _reap(child, reader) + received.clear() + raise + + +def _answer_of(child, reader, received: list, *, source, + timeout: float) -> tuple[str | None, str | None]: + """`_run_broker`'s work once the child and its reader are running: the + credential, if any, then the answer, within the bound and the + timeout.""" try: if source is not None: # The credential's ONLY path through this process: handle to diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index aaeaa930..2b7dfc62 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3371,6 +3371,16 @@ def _kept_anywhere(exception, secret: str) -> list[str]: return sorted(found) +def _children_kept_by(exception) -> list[str]: + """The frame locals that hold a broker child (`subprocess.Popen`). A + child holds the pipe its answer came down, and whatever that pipe's + buffers still hold, so no refusal keeps one.""" + return sorted({f"{frame.f_code.co_name}.{name}" + for frame in _frames_kept_by(exception) + for name, value in list(frame.f_locals.items()) + if isinstance(value, subprocess.Popen)}) + + #: Each operation, asked through its own function, with the real runner. _OPERATIONS_ASKED = { provider_mod.OPERATION_INTAKE: lambda binding, runner: ( @@ -3394,9 +3404,9 @@ def test_every_operation_is_asked_here(): def test_the_shared_runner_refuses_a_misbehaving_broker_keeping_nothing( tmp_path, misbehaviour): """The runner itself, called directly. Its refusal keeps no cause, no - context, and no frame or attribute that holds what the broker wrote. - It names the failure class. It is not told the operation, so it names - none.""" + context, no frame or attribute that holds what the broker wrote, and no + frame that holds the child. It names the failure class. It is not told + the operation, so it names none.""" script = _misbehaving_broker(tmp_path, misbehaviour) argv = provider_mod.broker_operation_argv( _broker_binding(script), provider_mod.OPERATION_MINT) @@ -3408,6 +3418,7 @@ def test_the_shared_runner_refuses_a_misbehaving_broker_keeping_nothing( assert refusal.__cause__ is None assert refusal.__context__ is None assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] + assert _children_kept_by(refusal) == [] expected = _sentence_for(misbehaviour) assert refusal.diagnostic == expected assert refusal.operation is None @@ -3438,6 +3449,7 @@ def test_a_broker_that_writes_without_end_is_refused_at_the_bound(tmp_path): assert caught.value.__cause__ is None assert caught.value.__context__ is None assert _kept_anywhere(caught.value, SENTINEL_TOKEN) == [] + assert _children_kept_by(caught.value) == [] assert caught.value.diagnostic == provider_mod.DIAG_BROKER_OVERSIZE @@ -3458,6 +3470,7 @@ def test_a_misbehaving_broker_is_refused_naming_the_operation( assert refusal.__cause__ is None assert refusal.__context__ is None assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] + assert _children_kept_by(refusal) == [] expected = _sentence_for(misbehaviour) assert refusal.diagnostic == expected assert refusal.operation == operation @@ -3486,6 +3499,70 @@ def test_a_broker_that_cannot_be_started_chains_nothing(tmp_path, operation): f"broker {operation}: {provider_mod.DIAG_BROKER_UNREACHABLE}") +def test_a_credential_source_that_fails_leaves_no_broker_running(tmp_path): + """The credential's own source is the operator's input, not the + broker's output, so the ruling does not reach it. A source that fails + while it is copied still escapes as it did. But the broker must not be + left running with its reader blocked on it, which aborted the + interpreter at exit at `b847ef3d` ("Fatal Python error: + _enter_buffered_busy"). A child interpreter runs it, so that its exit + is what is measured.""" + binding = _broker_binding(_write_broker(tmp_path)) + fields = {field.name: getattr(binding, field.name) + for field in dataclasses.fields(binding)} + program = ( + "from opendox import doxbench_binding as b\n" + "from opendox import doxbench_provider as p\n" + "class Failing:\n" + " parts = ['sk-stand-in-input-side-NOT-A-KEY']\n" + " def read(self, _size=-1):\n" + " if self.parts:\n" + " return self.parts.pop()\n" + " raise UnicodeDecodeError('utf-8', b'x', 0, 1, 'stand-in')\n" + f"binding = b.ModelProviderBinding(**{fields!r})\n" + "p.hand_off_credential(binding, Failing())\n") + run = subprocess.run([sys.executable, "-c", program], cwd=tmp_path, + capture_output=True, text=True, timeout=60, + check=False) + assert "Fatal Python error" not in run.stderr + assert run.returncode == 1 + assert "UnicodeDecodeError" in run.stderr + assert "sk-stand-in-input-side-NOT-A-KEY" not in run.stderr + + +class _SourceFailingOnceMarked: + """A credential source that fails on its second read, once `mark` + exists, so the broker has written before it fails.""" + + def __init__(self, mark: Path) -> None: + self.parts = ["sk-stand-in-input-side-NOT-A-KEY"] + self.mark = mark + + def read(self, _size=-1): + if self.parts: + return self.parts.pop() + deadline = time.monotonic() + 10 + while not self.mark.exists() and time.monotonic() < deadline: + time.sleep(0.01) + raise UnicodeDecodeError("utf-8", b"x", 0, 1, "stand-in") + + +def test_a_failing_credential_source_escapes_with_no_broker_output(tmp_path): + """The same escape, in this process, from a broker that wrote the + token before it read its standard input. What escapes keeps nothing the + broker wrote, as a refusal would not.""" + script = tmp_path / "early-writing-broker.py" + script.write_text(_MISBEHAVING_PREAMBLE.format( + token=SENTINEL_TOKEN, bound=provider_mod.MAX_BROKER_ANSWER_BYTES) + + "sys.stdout.write(TOKEN)\nwrote()\nsys.stdin.read()\n", + encoding="utf-8") + source = _SourceFailingOnceMarked(Path(str(script) + ".wrote")) + with pytest.raises(UnicodeDecodeError) as caught: + provider_mod.hand_off_credential(_broker_binding(script), source) + assert _wrote(script), "the broker wrote before the source failed" + assert _kept_anywhere(caught.value, SENTINEL_TOKEN) == [] + + @pytest.mark.parametrize("operation", provider_mod.OPERATIONS) def test_an_injected_runners_refusal_is_named_too(tmp_path, operation): """The operation is named by the function that asked, so a runner that From a603a032739071f53da58f661538d7ec9a5ca9d3 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:46:31 +0000 Subject: [PATCH 24/45] Broker runner: a refusal kills the broker's process group, and waits boundedly Copilot's review of #64 at b847ef3d: a descendant that inherits the broker's standard output kept the pipe open after the broker was killed, so the reader, and the refusal, waited forever. That was measured at e3eec6b1: a broker with such a descendant and a 0.5 s timeout was still not refused after 10 s. The broker now starts in its own process group (process_group=0; the session and terminal stay this process's). A refusal kills the whole group, and waits at most BROKER_REAP_SECONDS (2 s) for the reader. A descendant that left the group is left to the daemon reader, and the refusal does not wait on it. The reader reads the descriptor with os.read, so a reader still blocked at interpreter exit holds no buffered lock that finalization needs. Tests: a descendant in the group, refused within the timeout plus the grace, and one that left it (setsid), refused within the grace plus a margin. Both hang at e3eec6b1. The failing-source case now also asserts that the broker is not left running. All 36 mutants are killed, among them the broker killed alone, no group of its own, and an unbounded wait for the reader. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 59 +++++++++++++++++++++++++---- tests/test_model_provider_broker.py | 57 +++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index cd94602a..82d1a240 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -105,6 +105,7 @@ import math import os import shutil +import signal import subprocess import sys import threading @@ -203,6 +204,11 @@ #: cannot answer. BROKER_TIMEOUT_SECONDS = 30.0 +#: How long a refused broker's reader is waited for once the broker's process +#: group has been killed. A descendant that left the group can still hold the +#: answer's pipe open, and the refusal does not wait on it past this. +BROKER_REAP_SECONDS = 2.0 + #: The largest answer a broker may write. A bound, not a policy: an unbounded #: read of a child's stdout is a way to spend this process's memory by #: misconfiguring a binding. @@ -508,20 +514,53 @@ def subprocess_broker_runner(argv, *, source=None, def _read_at_most(stream, limit: int, into: list) -> None: """A reader thread's work: at most `limit` bytes of the child's standard output, or fewer if it ends first, appended to `into`. A read that fails - appends nothing.""" + appends nothing. + + It reads the descriptor itself (`os.read`), not the buffered file, so a + reader still blocked when the interpreter exits holds no lock that + finalization needs. The thread keeps `stream`, so the descriptor is not + closed and reused under it.""" + chunks: list[bytes] = [] + size = 0 try: - into.append(stream.read(limit)) + descriptor = stream.fileno() + while size < limit: + chunk = os.read(descriptor, min(65_536, limit - size)) + if not chunk: + break + chunks.append(chunk) + size += len(chunk) except (OSError, ValueError): - pass + return + into.append(b"".join(chunks)) -def _reap(child, reader) -> None: - """Kill a child this runner is refusing, wait for it, and let its reader - finish. Whatever the child wrote is dropped with the reader.""" +def _kill_the_group(child) -> None: + """Kill the broker and every descendant still in its process group. A + descendant holding the answer's pipe open would otherwise keep the + reader, and so the refusal, waiting. Where process groups do not exist, + the broker alone is killed.""" + killpg = getattr(os, "killpg", None) + if killpg is not None: + try: + killpg(child.pid, signal.SIGKILL) + return + except OSError: + pass child.kill() + + +def _reap(child, reader) -> None: + """Kill a child this runner is refusing, and its process group, wait for + it, and let its reader finish, for at most `BROKER_REAP_SECONDS`. + Whatever the child wrote is dropped with the reader. A reader still + blocked then (a descendant that left the group holds the pipe) is left + to its daemon thread, with the descriptor it reads.""" + _kill_the_group(child) child.wait() - reader.join() - child.stdout.close() + reader.join(BROKER_REAP_SECONDS) + if not reader.is_alive(): + child.stdout.close() def _run_broker(argv, *, source, @@ -551,6 +590,10 @@ def _run_broker(argv, *, source, stderr=subprocess.DEVNULL, env=bridge_mod.child_environment(os.environ), text=True, + # Its own process group, so a refusal can kill its descendants + # too (`_kill_the_group`). The session, and so the terminal, is + # this process's. + process_group=0 if hasattr(os, "killpg") else None, ) except (OSError, ValueError): return None, DIAG_BROKER_UNREACHABLE diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 2b7dfc62..da900f0e 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -54,6 +54,7 @@ import http.server import io import json +import os import socket import subprocess import sys @@ -3530,6 +3531,52 @@ def test_a_credential_source_that_fails_leaves_no_broker_running(tmp_path): assert "sk-stand-in-input-side-NOT-A-KEY" not in run.stderr +@pytest.mark.parametrize("leaves_the_group", [False, True], + ids=["descendant-in-its-group", + "descendant-that-left-it"]) +def test_a_broker_whose_descendant_holds_its_output_is_still_refused_in_time( + tmp_path, leaves_the_group): + """Copilot's review of openDox-code#64 at `b847ef3d`: a descendant that + inherits the broker's standard output kept the pipe open after the + broker was killed, so the reader, and the refusal, waited forever. The + broker now has its own process group, which a refusal kills whole. A + descendant that left the group is waited for no longer than + `BROKER_REAP_SECONDS`.""" + script = tmp_path / "forking-broker.py" + script.write_text( + "import os, subprocess, sys, time\n" + "subprocess.Popen([sys.executable, '-c', " + f"'import os, time\\n{'os.setsid()' if leaves_the_group else 'pass'}" + "\\ntime.sleep(10)'])\n" + f"sys.stdout.write({SENTINEL_TOKEN!r})\n" + "sys.stdout.flush()\n" + "time.sleep(30)\n", encoding="utf-8") + argv = provider_mod.broker_operation_argv( + _broker_binding(script), provider_mod.OPERATION_MINT) + caught: list = [] + + def run(): + try: + provider_mod.subprocess_broker_runner(argv, timeout=0.5) + except provider_mod.BrokerRefused as refusal: + caught.append(refusal) + + runner = threading.Thread(target=run, daemon=True) + started = time.monotonic() + runner.start() + runner.join(10) + assert not runner.is_alive(), "the refusal waited on the descendant" + elapsed = time.monotonic() - started + [refusal] = caught + grace = provider_mod.BROKER_REAP_SECONDS + assert elapsed < 0.5 + grace + 3 + if not leaves_the_group: + assert elapsed < 0.5 + grace, \ + "the whole group is killed, so nothing is left to wait on" + assert refusal.diagnostic == provider_mod.DIAG_BROKER_TIMEOUT + assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] + + class _SourceFailingOnceMarked: """A credential source that fails on its second read, once `mark` exists, so the broker has written before it fails.""" @@ -3550,17 +3597,23 @@ def read(self, _size=-1): def test_a_failing_credential_source_escapes_with_no_broker_output(tmp_path): """The same escape, in this process, from a broker that wrote the token before it read its standard input. What escapes keeps nothing the - broker wrote, as a refusal would not.""" + broker wrote, as a refusal would not, and the broker is not left + running, where it would read the end of its input and store whatever + part of the credential had reached it.""" script = tmp_path / "early-writing-broker.py" script.write_text(_MISBEHAVING_PREAMBLE.format( token=SENTINEL_TOKEN, bound=provider_mod.MAX_BROKER_ANSWER_BYTES) - + "sys.stdout.write(TOKEN)\nwrote()\nsys.stdin.read()\n", + + "pathlib.Path(sys.argv[0] + '.pid').write_text(str(os.getpid()))\n" + "sys.stdout.write(TOKEN)\nwrote()\nsys.stdin.read()\n", encoding="utf-8") source = _SourceFailingOnceMarked(Path(str(script) + ".wrote")) with pytest.raises(UnicodeDecodeError) as caught: provider_mod.hand_off_credential(_broker_binding(script), source) assert _wrote(script), "the broker wrote before the source failed" assert _kept_anywhere(caught.value, SENTINEL_TOKEN) == [] + pid = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) @pytest.mark.parametrize("operation", provider_mod.OPERATIONS) From e75900ff31eb6a5c7206de793424d37f93799b72 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:39:00 +0000 Subject: [PATCH 25/45] Broker runner: one selector loop, one deadline, no reader thread Copilot's review of #64 at a603a032: a descendant that left the broker's process group left the timed-out reader thread blocked, with its pipe descriptor, behind every refusal, and that reader could still add to what had been read after received was cleared. The reader thread is gone. One selector loop in the calling thread streams the credential to the broker's stdin in PIPE_BUF writes and reads the answer, as communicate does on POSIX, under one deadline that now covers the credential's streaming too. A refusal kills the broker's process group and closes this process's ends of both pipes. So a descendant that left the group costs no thread and no descriptor, and nothing can add to the answer once the loop stops. The answer is read straight into received, so no other local holds it. BROKER_REAP_SECONDS is gone. Measured at a603a032: - a broker whose descendant left the group was refused only after the 2 s reap grace (2.5 s with a 0.5 s timeout), and left its reader behind; - a broker that never read a 1 MB credential held the refusal past 10 s, because the timeout began only after the credential was written. Tests: the descendant case now also asserts that no thread and no descriptor is left behind, and that an in-group descendant is killed. A broker that reads 5000 bytes of a 1 MB credential is refused at its 0.5 s timeout. The failing-source case now lets the answer be read first. That exposed a local, chunk, holding the answer in the escaping traceback, and it is removed. All 35 mutants are killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 191 ++++++++++++++-------------- tests/test_model_provider_broker.py | 109 ++++++++++++---- 2 files changed, 179 insertions(+), 121 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 82d1a240..23645517 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -100,11 +100,13 @@ from __future__ import annotations +import codecs import dataclasses import json import math import os -import shutil +import select +import selectors import signal import subprocess import sys @@ -204,10 +206,8 @@ #: cannot answer. BROKER_TIMEOUT_SECONDS = 30.0 -#: How long a refused broker's reader is waited for once the broker's process -#: group has been killed. A descendant that left the group can still hold the -#: answer's pipe open, and the refusal does not wait on it past this. -BROKER_REAP_SECONDS = 2.0 +#: How much of the credential is read from its source at a time. +_STDIN_CHUNK = 8192 #: The largest answer a broker may write. A bound, not a policy: an unbounded #: read of a child's stdout is a way to spend this process's memory by @@ -462,8 +462,8 @@ def subprocess_broker_runner(argv, *, source=None, operation reads no standard input at all, and this function closes the pipe immediately for them, which is what the declaration says a caller may do. - The credential is STREAMED, not read: `shutil.copyfileobj` moves it in - chunks from the operator's handle to the child's pipe, so the whole value + The credential is STREAMED, not read: `_answer_of` moves it in chunks + from the operator's handle to the child's pipe, so the whole value never becomes a string in this process and there is no variable holding it to outlive the call. @@ -511,35 +511,11 @@ def subprocess_broker_runner(argv, *, source=None, return answer -def _read_at_most(stream, limit: int, into: list) -> None: - """A reader thread's work: at most `limit` bytes of the child's standard - output, or fewer if it ends first, appended to `into`. A read that fails - appends nothing. - - It reads the descriptor itself (`os.read`), not the buffered file, so a - reader still blocked when the interpreter exits holds no lock that - finalization needs. The thread keeps `stream`, so the descriptor is not - closed and reused under it.""" - chunks: list[bytes] = [] - size = 0 - try: - descriptor = stream.fileno() - while size < limit: - chunk = os.read(descriptor, min(65_536, limit - size)) - if not chunk: - break - chunks.append(chunk) - size += len(chunk) - except (OSError, ValueError): - return - into.append(b"".join(chunks)) - - def _kill_the_group(child) -> None: """Kill the broker and every descendant still in its process group. A descendant holding the answer's pipe open would otherwise keep the - reader, and so the refusal, waiting. Where process groups do not exist, - the broker alone is killed.""" + answer from ending. Where process groups do not exist, the broker alone + is killed.""" killpg = getattr(os, "killpg", None) if killpg is not None: try: @@ -550,17 +526,25 @@ def _kill_the_group(child) -> None: child.kill() -def _reap(child, reader) -> None: +def _reap(child) -> None: """Kill a child this runner is refusing, and its process group, wait for - it, and let its reader finish, for at most `BROKER_REAP_SECONDS`. - Whatever the child wrote is dropped with the reader. A reader still - blocked then (a descendant that left the group holds the pipe) is left - to its daemon thread, with the descriptor it reads.""" + it, and close both of this process's ends of its pipes. Nothing is left + reading them. A descendant that left the group keeps only its own copy + of the pipe, which nothing here waits on.""" _kill_the_group(child) child.wait() - reader.join(BROKER_REAP_SECONDS) - if not reader.is_alive(): - child.stdout.close() + _close_quietly(child.stdin) + child.stdin = None + child.stdout.close() + + +def _close_quietly(stream) -> None: + if stream is None: + return + try: + stream.close() + except OSError: + pass def _run_broker(argv, *, source, @@ -571,13 +555,20 @@ def _run_broker(argv, *, source, wrote. THE BOUND IS A BOUND ON WHAT IS READ (Copilot's review of - openDox-code#64 at `25788f91`). A reader thread reads at most one byte - past `MAX_BROKER_ANSWER_BYTES`, and a broker that writes that byte is + openDox-code#64 at `25788f91`). At most one byte past + `MAX_BROKER_ANSWER_BYTES` is read, and a broker that writes that byte is refused and killed there. At `25788f91` the whole of the child's output was read before the bound was checked, so a broker that wrote without - end filled this process's memory until the timeout, and was refused as - a timeout. The thread also drains the answer while the credential is - still being written, as `communicate` did not. + end filled this process's memory until the timeout. + + ONE LOOP, IN THIS THREAD, AND ONE DEADLINE (Copilot's reviews at + `b847ef3d` and `a603a032`). The credential is written and the answer is + read by one selector loop, as `communicate` does on POSIX, and the + timeout covers both. There is no reader thread to abandon. A refusal + kills the broker's process group and closes this process's ends of both + pipes, so a descendant that left the group, and holds the answer's pipe + open, costs no thread and no descriptor here and can add nothing to + what was read. The answer is decoded as UTF-8, JSON's own encoding, where `communicate` used the locale's. An answer that is not UTF-8 is @@ -598,75 +589,83 @@ def _run_broker(argv, *, source, except (OSError, ValueError): return None, DIAG_BROKER_UNREACHABLE received: list[bytes] = [] - reader = threading.Thread( - target=_read_at_most, - args=(child.stdout.buffer, MAX_BROKER_ANSWER_BYTES + 1, received), - name="broker-answer", daemon=True) - reader.start() try: - return _answer_of(child, reader, received, source=source, - timeout=timeout) + return _answer_of(child, received, source=source, timeout=timeout) except BaseException: # Anything else that escapes, such as the credential's own source # failing while it is copied (the operator's input, not the broker's # output), goes on as it was. The child is reaped first, and what it - # wrote is dropped: a reader left blocked in its daemon thread would - # abort the interpreter when it exits. - _reap(child, reader) + # wrote is dropped. Nothing else can add to it once this loop has + # stopped. + _reap(child) received.clear() raise -def _answer_of(child, reader, received: list, *, source, +def _answer_of(child, received: list, *, source, timeout: float) -> tuple[str | None, str | None]: - """`_run_broker`'s work once the child and its reader are running: the - credential, if any, then the answer, within the bound and the - timeout.""" - try: - if source is not None: - # The credential's ONLY path through this process: handle to - # pipe, in chunks, never assembled. - shutil.copyfileobj(source, child.stdin) - child.stdin.close() - except BrokenPipeError: - # THE REFUSAL ARRIVING. Nothing is raised here; the exit code and - # the child's own answer are read below, exactly as the declaration - # instructs. The close is still attempted so the descriptor is not - # left to a garbage collector, and its own broken pipe is dropped - # for the same reason the first one was. - try: - child.stdin.close() - except OSError: - pass - except OSError: - _reap(child, reader) - return None, DIAG_BROKER_UNREACHABLE - # Closing `child.stdin` IS the signal a streamed credential's end of file - # needs. Dropping the reference says "stdin is finished with", and it was - # the last place in this process that could have held the pipe the - # credential travelled down. - child.stdin = None + """`_run_broker`'s work once the child is running: the credential, if + any, streamed to its standard input, and its answer read, within the + bound and the timeout.""" deadline = time.monotonic() + timeout - reader.join(timeout) - if reader.is_alive(): - _reap(child, reader) - return None, DIAG_BROKER_TIMEOUT - if not received: - _reap(child, reader) - return None, DIAG_BROKER_UNREACHABLE - if len(received[0]) > MAX_BROKER_ANSWER_BYTES: - _reap(child, reader) - return None, DIAG_BROKER_OVERSIZE + encoder = codecs.getincrementalencoder(child.stdin.encoding)() + pending = b"" + source_done = source is None + size = 0 + with selectors.DefaultSelector() as selector: + selector.register(child.stdout, selectors.EVENT_READ) + selector.register(child.stdin, selectors.EVENT_WRITE) + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + _reap(child) + return None, DIAG_BROKER_TIMEOUT + for key, _events in selector.select(remaining): + if key.fileobj is child.stdin: + if not pending and not source_done: + # The credential's ONLY path through this process: + # handle to pipe, in chunks, never assembled. + text = source.read(_STDIN_CHUNK) + pending = encoder.encode(text, final=not text) + source_done = not text + try: + written = os.write(child.stdin.fileno(), + pending[:select.PIPE_BUF]) + except BrokenPipeError: + # THE REFUSAL ARRIVING. Nothing is raised here; the + # exit code and the child's own answer are read + # below, exactly as the declaration instructs. + written, pending, source_done = 0, b"", True + pending = pending[written:] + if source_done and not pending: + # Closing it IS the signal a streamed credential's + # end of file needs. + selector.unregister(child.stdin) + _close_quietly(child.stdin) + child.stdin = None + continue + # Read straight into `received`, so no other name in this + # frame holds what the broker wrote. + received.append(os.read(child.stdout.fileno(), + MAX_BROKER_ANSWER_BYTES + 1 - size)) + if not received[-1]: + received.pop() + selector.unregister(child.stdout) + continue + size += len(received[-1]) + if size > MAX_BROKER_ANSWER_BYTES: + _reap(child) + return None, DIAG_BROKER_OVERSIZE try: returncode = child.wait(timeout=max(0.0, deadline - time.monotonic())) except subprocess.TimeoutExpired: - _reap(child, reader) + _reap(child) return None, DIAG_BROKER_TIMEOUT child.stdout.close() if returncode != 0: return None, DIAG_BROKER_REFUSED try: - return received[0].decode("utf-8"), None + return b"".join(received).decode("utf-8"), None except UnicodeDecodeError: return None, DIAG_BROKER_MALFORMED diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index da900f0e..e30e6745 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3531,23 +3531,42 @@ def test_a_credential_source_that_fails_leaves_no_broker_running(tmp_path): assert "sk-stand-in-input-side-NOT-A-KEY" not in run.stderr +def _still_running(pid: int, *, within: float = 2.0) -> bool: + """Whether `pid` is still running after `within` seconds. A zombie, + which a container's first process may never reap, has stopped.""" + deadline = time.monotonic() + within + while True: + try: + state = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1] + except OSError: + return False + if state.split()[0] in ("Z", "X"): + return False + if time.monotonic() > deadline: + return True + time.sleep(0.05) + + @pytest.mark.parametrize("leaves_the_group", [False, True], ids=["descendant-in-its-group", "descendant-that-left-it"]) def test_a_broker_whose_descendant_holds_its_output_is_still_refused_in_time( tmp_path, leaves_the_group): - """Copilot's review of openDox-code#64 at `b847ef3d`: a descendant that - inherits the broker's standard output kept the pipe open after the - broker was killed, so the reader, and the refusal, waited forever. The - broker now has its own process group, which a refusal kills whole. A - descendant that left the group is waited for no longer than - `BROKER_REAP_SECONDS`.""" + """Copilot's reviews of openDox-code#64 at `b847ef3d` and `a603a032`. + A descendant that inherits the broker's standard output kept the pipe + open after the broker was killed. At `e3eec6b1` the refusal waited on it + forever. At `a603a032` a descendant that left the group left a reader + thread blocked, and its descriptor open, behind every refusal. The + broker now has its own process group, which a refusal kills whole, and + one loop in the calling thread reads the answer. So the refusal comes + at the timeout, and leaves no thread and no descriptor behind.""" script = tmp_path / "forking-broker.py" script.write_text( "import os, subprocess, sys, time\n" - "subprocess.Popen([sys.executable, '-c', " + "descendant = subprocess.Popen([sys.executable, '-c', " f"'import os, time\\n{'os.setsid()' if leaves_the_group else 'pass'}" "\\ntime.sleep(10)'])\n" + "open(sys.argv[0] + '.pid', 'w').write(str(descendant.pid))\n" f"sys.stdout.write({SENTINEL_TOKEN!r})\n" "sys.stdout.flush()\n" "time.sleep(30)\n", encoding="utf-8") @@ -3561,6 +3580,8 @@ def run(): except provider_mod.BrokerRefused as refusal: caught.append(refusal) + threads = threading.active_count() + descriptors = len(os.listdir("/proc/self/fd")) runner = threading.Thread(target=run, daemon=True) started = time.monotonic() runner.start() @@ -3568,43 +3589,81 @@ def run(): assert not runner.is_alive(), "the refusal waited on the descendant" elapsed = time.monotonic() - started [refusal] = caught - grace = provider_mod.BROKER_REAP_SECONDS - assert elapsed < 0.5 + grace + 3 + assert elapsed < 0.5 + 2, "refused at the timeout" + assert threading.active_count() == threads, "no thread is left behind" + assert len(os.listdir("/proc/self/fd")) == descriptors, \ + "no descriptor is left behind" + descendant = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) if not leaves_the_group: - assert elapsed < 0.5 + grace, \ - "the whole group is killed, so nothing is left to wait on" + assert not _still_running(descendant), \ + "the broker's whole process group is killed" assert refusal.diagnostic == provider_mod.DIAG_BROKER_TIMEOUT assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] +def test_a_broker_that_never_reads_the_credential_is_refused_in_time( + tmp_path): + """The timeout covers the credential's streaming too. At `a603a032` the + credential was written before the timeout began, so a broker that never + read a credential larger than its pipe held the refusal until the broker + itself exited.""" + script = tmp_path / "deaf-broker.py" + # It reads a little and then stops, so the pipe has room for some of + # the credential but not for all of it. + script.write_text("import os, time\nos.read(0, 5000)\ntime.sleep(30)\n", + encoding="utf-8") + binding = _broker_binding(script) + runner = functools.partial(provider_mod.subprocess_broker_runner, + timeout=0.5) + caught: list = [] + + def run(): + try: + provider_mod.hand_off_credential( + binding, io.StringIO("k" * 1_000_000), runner=runner) + except provider_mod.BrokerRefused as refusal: + caught.append(refusal) + + thread = threading.Thread(target=run, daemon=True) + started = time.monotonic() + thread.start() + thread.join(10) + assert not thread.is_alive(), "the refusal waited on the broker" + assert time.monotonic() - started < 0.5 + 2 + [refusal] = caught + assert refusal.diagnostic == provider_mod.DIAG_BROKER_TIMEOUT + assert refusal.operation == provider_mod.OPERATION_INTAKE + + class _SourceFailingOnceMarked: - """A credential source that fails on its second read, once `mark` - exists, so the broker has written before it fails.""" + """A credential source that gives a small part at each read, and fails + a few reads after `mark` exists. So the broker has written, and its + answer has been read, before the source fails.""" def __init__(self, mark: Path) -> None: - self.parts = ["sk-stand-in-input-side-NOT-A-KEY"] self.mark = mark + self.reads_since_marked = 0 def read(self, _size=-1): - if self.parts: - return self.parts.pop() - deadline = time.monotonic() + 10 - while not self.mark.exists() and time.monotonic() < deadline: - time.sleep(0.01) - raise UnicodeDecodeError("utf-8", b"x", 0, 1, "stand-in") + time.sleep(0.02) + if self.mark.exists(): + self.reads_since_marked += 1 + if self.reads_since_marked > 5: + raise UnicodeDecodeError("utf-8", b"x", 0, 1, "stand-in") + return "sk-stand-in-input-side-NOT-A-KEY" def test_a_failing_credential_source_escapes_with_no_broker_output(tmp_path): """The same escape, in this process, from a broker that wrote the - token before it read its standard input. What escapes keeps nothing the - broker wrote, as a refusal would not, and the broker is not left - running, where it would read the end of its input and store whatever - part of the credential had reached it.""" + token while the credential was still streaming. What escapes keeps + nothing the broker wrote, as a refusal would not. The broker is not + left running, where it would read the end of its input and store + whatever part of the credential had reached it.""" script = tmp_path / "early-writing-broker.py" script.write_text(_MISBEHAVING_PREAMBLE.format( token=SENTINEL_TOKEN, bound=provider_mod.MAX_BROKER_ANSWER_BYTES) + "pathlib.Path(sys.argv[0] + '.pid').write_text(str(os.getpid()))\n" - "sys.stdout.write(TOKEN)\nwrote()\nsys.stdin.read()\n", + "sys.stdout.write(TOKEN)\nwrote()\ntime.sleep(30)\n", encoding="utf-8") source = _SourceFailingOnceMarked(Path(str(script) + ".wrote")) with pytest.raises(UnicodeDecodeError) as caught: From da9e639a68cc7785bba5c27304681a74225713f6 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:33:27 +0000 Subject: [PATCH 26/45] Broker hardening: a provider answer nested past the recursion limit is malformed Copilot's review of openDox-code#64 at e1a6cb0f (Changes recommended): _post_to_provider caught ValueError and UnicodeDecodeError from the provider answer's parse, but not RecursionError. An answer nested past the interpreter's limit fits well inside the provider's answer bound (30,000 levels take 60 KB). So its parse escaped as a RecursionError, whose traceback kept _post_to_provider's frame, and so the request with its authorization header. That was so on every path: a broker's token, the built-in resolver's key, and the auth kind none. The parse now refuses it as DIAG_PROVIDER_MALFORMED, like any other answer it cannot read, and _call_provider raises the refusal of a request that carried a credential afresh, with no cause and no context. One case for each credential source. With the old catch, which is this branch at e1a6cb0f, all three fail with the RecursionError. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 7 ++++- tests/test_model_provider_broker.py | 44 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 23645517..24e89030 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -1314,7 +1314,12 @@ def _post_to_provider(*, endpoint: str, dialect: str, raise BrokerRefused(DIAG_PROVIDER_MALFORMED) try: document = json.loads(payload.decode("utf-8")) - except (ValueError, UnicodeDecodeError) as error: + # A RecursionError too: arrays nested past the interpreter's limit fit + # well inside MAX_PROVIDER_ANSWER_BYTES (30,000 of them take 60 KB). Such + # an answer is malformed, and must not escape as a crash whose traceback + # keeps this frame, and so the request and its authorization header + # (Copilot's review of openDox-code#64 at `e1a6cb0f`). + except (ValueError, UnicodeDecodeError, RecursionError) as error: raise BrokerRefused(DIAG_PROVIDER_MALFORMED) from error if not isinstance(document, dict): raise BrokerRefused(DIAG_PROVIDER_MALFORMED) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index e30e6745..c60d3f09 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3277,6 +3277,50 @@ def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( assert _locals_holding(caught.value, SENTINEL_TOKEN) == [] +#: A provider answer nested past the interpreter's recursion limit, and still +#: well inside the provider's answer bound. +_PROVIDER_ANSWER_NESTED_PAST_THE_LIMIT = ( + b'{"choices": ' + b"[" * 30_000 + b"]" * 30_000 + b"}") + + +@pytest.mark.parametrize("source", ["broker", "built-in", "none"]) +def test_a_provider_answer_nested_past_the_recursion_limit_is_malformed( + tmp_path, source): + """Copilot's review of openDox-code#64 at `e1a6cb0f`. The provider's + answer is parsed in `_post_to_provider`, whose frame holds the request, + and so its authorization header. An answer nested past the recursion + limit fits well inside the answer's bound. Its parse escaped as a + `RecursionError`, with that frame in its traceback, on every path. It + is now a malformed answer. A refusal of a request that carried a + credential chains nothing, and keeps no frame of the call that held it. + Under the auth kind `none` nothing was presented, so its refusal is held + to the fixed sentence alone.""" + payload = _PROVIDER_ANSWER_NESTED_PAST_THE_LIMIT + assert len(payload) <= provider_mod.MAX_PROVIDER_ANSWER_BYTES, \ + "a case for the answer's parser, not for its bound" + secret = None + if source == "broker": + port, _opener = _port(tmp_path, payload) + secret = SENTINEL_TOKEN + elif source == "built-in": + port, _opener = _unbrokered_port( + _built_in_binding(), payload, environ={ENV_NAME: KEY_SENTINEL}) + secret = KEY_SENTINEL + else: + port, _opener = _unbrokered_port(_none_binding(), payload) + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED + if secret is not None: + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + kept = {frame.f_code.co_name + for frame in _frames_kept_by(caught.value)} + assert "_post_to_provider" not in kept, kept + assert _locals_holding(caught.value, secret) == [] + + # --- a broker that misbehaves is refused with nothing it wrote ----------- # Brett Heap's word of 2026-09-29 (openxFactory#656, the lane's latest RULED # comment): "Yes, add to #64". When a broker misbehaves, the shared runner's From f0d28a7928e6ad4cfc81ff04d187856a80383a1f Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:35:05 +0000 Subject: [PATCH 27/45] Broker hardening tests: one throwing call per pytest.raises block (SonarCloud S5778) SonarCloud's analysis of openDox-code#64 reports four S5778 code smells in tests/test_model_provider_broker.py, each a pytest.raises block that also built its binding, its argv or the operation it asks. Each of those is now built before its block. No assertion changes meaning, and the case count is unchanged: the four tests run 33 cases, all passing. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_provider_broker.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index c60d3f09..9dd819a7 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3508,8 +3508,9 @@ def test_a_misbehaving_broker_is_refused_naming_the_operation( script = _misbehaving_broker(tmp_path, misbehaviour) runner = functools.partial(provider_mod.subprocess_broker_runner, timeout=_MISBEHAVING_TIMEOUT) + ask, binding = _OPERATIONS_ASKED[operation], _broker_binding(script) with pytest.raises(provider_mod.BrokerRefused) as caught: - _OPERATIONS_ASKED[operation](_broker_binding(script), runner) + ask(binding, runner) refusal = caught.value assert _wrote(script), "the broker wrote the token before it misbehaved" assert refusal.__cause__ is None @@ -3528,9 +3529,9 @@ def test_a_broker_that_cannot_be_started_chains_nothing(tmp_path, operation): nothing either. At `788d764b` it chained the `FileNotFoundError`, by the runner and by the operation alike.""" binding = _binding(broker_argv=(str(tmp_path / "no-such-broker"),)) + argv = provider_mod.broker_operation_argv(binding, operation) with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.subprocess_broker_runner( - provider_mod.broker_operation_argv(binding, operation)) + provider_mod.subprocess_broker_runner(argv) assert caught.value.__cause__ is None assert caught.value.__context__ is None assert caught.value.diagnostic == provider_mod.DIAG_BROKER_UNREACHABLE @@ -3710,8 +3711,9 @@ def test_a_failing_credential_source_escapes_with_no_broker_output(tmp_path): "sys.stdout.write(TOKEN)\nwrote()\ntime.sleep(30)\n", encoding="utf-8") source = _SourceFailingOnceMarked(Path(str(script) + ".wrote")) + binding = _broker_binding(script) with pytest.raises(UnicodeDecodeError) as caught: - provider_mod.hand_off_credential(_broker_binding(script), source) + provider_mod.hand_off_credential(binding, source) assert _wrote(script), "the broker wrote before the source failed" assert _kept_anywhere(caught.value, SENTINEL_TOKEN) == [] pid = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) @@ -3726,8 +3728,9 @@ def test_an_injected_runners_refusal_is_named_too(tmp_path, operation): def refusing(argv, **_kwargs): raise provider_mod.BrokerRefused(provider_mod.DIAG_BROKER_REFUSED) + ask, binding = _OPERATIONS_ASKED[operation], _binding() with pytest.raises(provider_mod.BrokerRefused) as caught: - _OPERATIONS_ASKED[operation](_binding(), refusing) + ask(binding, refusing) assert caught.value.operation == operation assert caught.value.diagnostic == provider_mod.DIAG_BROKER_REFUSED assert caught.value.__context__ is None From a271d307d4c5e45e3bcadcd6f98256dca51b56ee Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:46:40 +0000 Subject: [PATCH 28/45] Broker hardening: name ValueError alone for the parse's decode error (SonarCloud S5713) SonarCloud's analysis of openDox-code#64 at f0d28a79 flagged the provider answer's catch, which da9e639a had touched, as S5713: a UnicodeDecodeError is a ValueError, which the catch already names. The catch now names ValueError and RecursionError, and the comment above it says why. Nothing it catches changes. Two mutants of it are killed: without RecursionError, the three nested-answer cases fail; without ValueError, the malformed-answer chain case fails with a JSONDecodeError. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 24e89030..8ba7447a 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -1314,12 +1314,13 @@ def _post_to_provider(*, endpoint: str, dialect: str, raise BrokerRefused(DIAG_PROVIDER_MALFORMED) try: document = json.loads(payload.decode("utf-8")) - # A RecursionError too: arrays nested past the interpreter's limit fit - # well inside MAX_PROVIDER_ANSWER_BYTES (30,000 of them take 60 KB). Such - # an answer is malformed, and must not escape as a crash whose traceback - # keeps this frame, and so the request and its authorization header - # (Copilot's review of openDox-code#64 at `e1a6cb0f`). - except (ValueError, UnicodeDecodeError, RecursionError) as error: + # A ValueError covers a UnicodeDecodeError, which is one. A RecursionError + # too: arrays nested past the interpreter's limit fit well inside + # MAX_PROVIDER_ANSWER_BYTES (30,000 of them take 60 KB). Such an answer + # is malformed, and must not escape as a crash whose traceback keeps this + # frame, and so the request and its authorization header (Copilot's + # review of openDox-code#64 at `e1a6cb0f`). + except (ValueError, RecursionError) as error: raise BrokerRefused(DIAG_PROVIDER_MALFORMED) from error if not isinstance(document, dict): raise BrokerRefused(DIAG_PROVIDER_MALFORMED) From bbcb565e058a35f15bb5f24c191c0907cd5d380c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:59:01 +0000 Subject: [PATCH 29/45] Broker runner: a refusal after the broker exits kills what is left of its group Copilot's review of openDox-code#64 at a271d307 named a note it had missed before, in code this branch wrote. _answer_of refused a broker that exited non-zero, or answered in bytes that are not UTF-8, without killing its process group. _run_broker's docstring says a refusal does. Measured at a271d307 with a broker that left a descendant in its group, holding none of its pipes: both refusals came at once, and the descendant was still running a second later, one more for each such call. Both paths now reap the broker, as the timeout and the bound already did. The broker has exited by then, so _reap's kill reaches what is left of its group, and the broker's own wait returns at once. Two cases, one per refusal. Each of the two mutants, one reap removed, fails exactly its own case. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 6 +++++ tests/test_model_provider_broker.py | 41 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 8ba7447a..ec572b6c 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -662,11 +662,17 @@ def _answer_of(child, received: list, *, source, _reap(child) return None, DIAG_BROKER_TIMEOUT child.stdout.close() + # A REFUSAL KILLS WHAT IS LEFT OF THE GROUP, here as at the timeout and + # the bound. The broker has exited, but a descendant still in its group + # would outlive it, one more for each such call (Copilot's review of + # openDox-code#64 at `a271d307`). if returncode != 0: + _reap(child) return None, DIAG_BROKER_REFUSED try: return b"".join(received).decode("utf-8"), None except UnicodeDecodeError: + _reap(child) return None, DIAG_BROKER_MALFORMED diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 9dd819a7..54eb229e 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -55,6 +55,7 @@ import io import json import os +import signal import socket import subprocess import sys @@ -3646,6 +3647,46 @@ def run(): assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] +@pytest.mark.parametrize("misbehaviour", ["exits-non-zero", + "answers-in-no-utf-8"]) +def test_a_refusal_after_the_broker_exits_kills_what_is_left_of_its_group( + tmp_path, misbehaviour): + """Copilot's review of openDox-code#64 at `a271d307`, a note it had + missed before. A broker left a descendant in its group, holding none of + its pipes, and then exited non-zero or answered in bytes that are not + UTF-8. It was refused at once, but the descendant went on running, and + each such call left one more. Every refusal of the runner now kills + what is left of the broker's group, as the timeout and the bound + already did.""" + if misbehaviour == "exits-non-zero": + last, expected = "sys.exit(3)\n", provider_mod.DIAG_BROKER_REFUSED + else: + last = "sys.stdout.buffer.write(b'\\xff')\n" + expected = provider_mod.DIAG_BROKER_MALFORMED + script = tmp_path / "descendant-leaving-broker.py" + script.write_text( + "import subprocess, sys\n" + "descendant = subprocess.Popen(\n" + " [sys.executable, '-c', 'import time; time.sleep(20)'],\n" + " stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,\n" + " stderr=subprocess.DEVNULL)\n" + "open(sys.argv[0] + '.pid', 'w').write(str(descendant.pid))\n" + f"sys.stdout.write({SENTINEL_TOKEN!r})\n" + "sys.stdout.flush()\n" + last, encoding="utf-8") + argv = provider_mod.broker_operation_argv( + _broker_binding(script), provider_mod.OPERATION_MINT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.subprocess_broker_runner(argv, timeout=5) + descendant = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) + try: + assert caught.value.diagnostic == expected + assert not _still_running(descendant), \ + "the descendant was left running" + finally: + with contextlib.suppress(ProcessLookupError): + os.kill(descendant, signal.SIGKILL) + + def test_a_broker_that_never_reads_the_credential_is_refused_in_time( tmp_path): """The timeout covers the credential's streaming too. At `a603a032` the From f8bc8aca8f94dbe112b15d357a1bb5fb1ed4270a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:20:18 +0000 Subject: [PATCH 30/45] Broker runner: signal the group before the broker is reaped, and bound the reap's wait Copilot's review of openDox-code#64 at bbcb565e (Changes recommended), two threads: - bbcb565e reaped an exited broker with wait() and only then killed its group. The group's id is the broker's pid, which can be reused once the broker is reaped, so the signal could reach an unrelated process group. The runner now reads the broker's exit with waitid(WNOWAIT), which leaves it a zombie that keeps its pid (_exit_status_unreaped). A refusal kills what is left of the group, and the broker is reaped after. _reap signals the group only while the broker is unreaped. Where waitid does not exist, the exit is read with a reap, and no group is signalled. - _reap's wait() was unbounded. A broker stuck in uninterruptible I/O outlives SIGKILL, and then the refusal never reached its caller and the pipes stayed open. The wait is now bounded by _REAP_GRACE_SECONDS (5 s, as runtime/local_git_adapter._stop_the_whole_group bounds its own), and CPython reaps such a broker once its Popen is collected. The tail of _answer_of moves to _settled, which decodes the answer in place in `received`, so no other name holds what the broker wrote. Two new cases, and an order check in the two group cases: the group is signalled once, while the broker is a zombie. Five mutants are killed: reap before the signal (2 cases), an unbounded wait (1), a signal after the reap (1), waitid without WNOWAIT (2), no reap after a non-zero exit (1). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 96 ++++++++++++++++++++++++---- tests/test_model_provider_broker.py | 97 ++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 15 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index ec572b6c..0ad24643 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -209,6 +209,13 @@ #: How much of the credential is read from its source at a time. _STDIN_CHUNK = 8192 +#: How long a refusal waits for a broker it has killed. SIGKILL ends a broker +#: at once unless it is stuck in uninterruptible I/O, so this is no second +#: clock, as `runtime/local_git_adapter._stop_the_whole_group`'s bound is not. +#: Past it the refusal goes on, and CPython reaps the broker once its `Popen` +#: is collected (`subprocess._active`). +_REAP_GRACE_SECONDS = 5.0 + #: The largest answer a broker may write. A bound, not a policy: an unbounded #: read of a child's stdout is a way to spend this process's memory by #: misconfiguring a binding. @@ -530,12 +537,27 @@ def _reap(child) -> None: """Kill a child this runner is refusing, and its process group, wait for it, and close both of this process's ends of its pipes. Nothing is left reading them. A descendant that left the group keeps only its own copy - of the pipe, which nothing here waits on.""" - _kill_the_group(child) - child.wait() + of the pipe, which nothing here waits on. + + THE GROUP IS SIGNALLED ONLY WHILE THE BROKER IS UNREAPED (Copilot's + review of openDox-code#64 at `bbcb565e`). Its id is the broker's pid, + which can be reused once the broker is reaped, and a signal then could + reach an unrelated process group. A broker whose exit was read is + still unreaped (`_exit_status_unreaped`), so its group is killed here + first and the broker reaped after. + + THE WAIT IS BOUNDED (the same review): a broker stuck in uninterruptible + I/O outlives SIGKILL, and the refusal does not wait on it past + `_REAP_GRACE_SECONDS`.""" + if child.returncode is None: + _kill_the_group(child) + try: + child.wait(timeout=_REAP_GRACE_SECONDS) + except subprocess.TimeoutExpired: + pass _close_quietly(child.stdin) child.stdin = None - child.stdout.close() + _close_quietly(child.stdout) def _close_quietly(stream) -> None: @@ -656,24 +678,72 @@ def _answer_of(child, received: list, *, source, if size > MAX_BROKER_ANSWER_BYTES: _reap(child) return None, DIAG_BROKER_OVERSIZE - try: - returncode = child.wait(timeout=max(0.0, deadline - time.monotonic())) - except subprocess.TimeoutExpired: + return _settled(child, received, deadline) + + +def _settled(child, received: list, + deadline: float) -> tuple[str | None, str | None]: + """The broker's answer once its output has ended. Its exit is read + without reaping it, so a refusal can still kill what is left of its + group (`_reap`). An answer is returned with the broker reaped. + + A REFUSAL KILLS WHAT IS LEFT OF THE GROUP, here as at the timeout and + the bound. The broker has exited, but a descendant still in its group + would outlive it, one more for each such call (Copilot's review of + openDox-code#64 at `a271d307`).""" + returncode = _exit_status_unreaped(child, deadline) + if returncode is None: _reap(child) return None, DIAG_BROKER_TIMEOUT - child.stdout.close() - # A REFUSAL KILLS WHAT IS LEFT OF THE GROUP, here as at the timeout and - # the bound. The broker has exited, but a descendant still in its group - # would outlive it, one more for each such call (Copilot's review of - # openDox-code#64 at `a271d307`). if returncode != 0: _reap(child) return None, DIAG_BROKER_REFUSED try: - return b"".join(received).decode("utf-8"), None + # Decoded in place, so no other name holds what the broker wrote. + received[:] = [b"".join(received).decode("utf-8")] except UnicodeDecodeError: _reap(child) return None, DIAG_BROKER_MALFORMED + # It has exited, so this reaps it at once. + child.wait() + _close_quietly(child.stdout) + return received.pop(), None + + +def _exit_status_unreaped(child, deadline: float) -> int | None: + """The broker's exit status once it has exited, read WITHOUT reaping + it, or None at `deadline`. It reads as `Popen.returncode` does: the + exit code, or the negated number of the signal that ended it. + + NOT REAPED, SO ITS GROUP'S ID IS STILL ITS OWN (Copilot's review of + openDox-code#64 at `bbcb565e`). Read with `WNOWAIT`, the broker stays a + zombie and keeps its pid, which is its group's id, so `_reap` can kill + what is left of the group before the broker is reaped. Where `waitid` + does not exist, the broker is reaped as it is read, and `_reap` then + signals no group. So does a broker something else has reaped, which + `Popen` reads as an exit of 0.""" + waitid = getattr(os, "waitid", None) + if waitid is None: + try: + return child.wait(timeout=max(0.0, deadline - time.monotonic())) + except subprocess.TimeoutExpired: + return None + delay = 0.0005 + while True: + try: + state = waitid(os.P_PID, child.pid, + os.WEXITED | os.WNOHANG | os.WNOWAIT) + except ChildProcessError: + return child.wait() + if state is not None: + if state.si_code == os.CLD_EXITED: + return state.si_status + return -state.si_status + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + time.sleep(min(delay, remaining, 0.05)) + delay *= 2 def broker_operation_argv(binding, operation: str, *, diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 54eb229e..3044ceb4 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3647,17 +3647,40 @@ def run(): assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] +def _process_state(pid: int) -> str | None: + """The state letter `/proc` gives `pid` (`Z` for a zombie, which has + exited and is not yet reaped), or None once it is gone.""" + try: + stat = Path(f"/proc/{pid}/stat").read_text() + except OSError: + return None + return stat.rsplit(")", 1)[1].split()[0] + + @pytest.mark.parametrize("misbehaviour", ["exits-non-zero", "answers-in-no-utf-8"]) def test_a_refusal_after_the_broker_exits_kills_what_is_left_of_its_group( - tmp_path, misbehaviour): + tmp_path, monkeypatch, misbehaviour): """Copilot's review of openDox-code#64 at `a271d307`, a note it had missed before. A broker left a descendant in its group, holding none of its pipes, and then exited non-zero or answered in bytes that are not UTF-8. It was refused at once, but the descendant went on running, and each such call left one more. Every refusal of the runner now kills what is left of the broker's group, as the timeout and the bound - already did.""" + already did. + + Copilot's review at `bbcb565e`: the group's id is the broker's pid, + which can be reused once the broker is reaped. At `bbcb565e` the broker + was reaped first and its group signalled after. Now the group is + signalled while the broker is a zombie, exited and not yet reaped.""" + signalled: list = [] + killpg = os.killpg + + def recording_killpg(pgid, sig): + signalled.append(_process_state(pgid)) + return killpg(pgid, sig) + + monkeypatch.setattr(os, "killpg", recording_killpg) if misbehaviour == "exits-non-zero": last, expected = "sys.exit(3)\n", provider_mod.DIAG_BROKER_REFUSED else: @@ -3680,6 +3703,8 @@ def test_a_refusal_after_the_broker_exits_kills_what_is_left_of_its_group( descendant = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) try: assert caught.value.diagnostic == expected + assert signalled == ["Z"], \ + "the group is signalled once, before the broker is reaped" assert not _still_running(descendant), \ "the descendant was left running" finally: @@ -3687,6 +3712,74 @@ def test_a_refusal_after_the_broker_exits_kills_what_is_left_of_its_group( os.kill(descendant, signal.SIGKILL) +def test_where_an_exit_cannot_be_read_unreaped_no_group_is_signalled( + tmp_path, monkeypatch): + """Where `os.waitid` does not exist, the broker is reaped as its exit + is read, so its pid, which is its group's id, may already be reused. + Its refusal then signals no group at all. A descendant still in that + group is not reached there, which is the one cost.""" + monkeypatch.delattr(os, "waitid") + signalled: list = [] + killpg = os.killpg + + def recording_killpg(pgid, sig): + signalled.append(pgid) + return killpg(pgid, sig) + + monkeypatch.setattr(os, "killpg", recording_killpg) + script = tmp_path / "descendant-leaving-broker.py" + script.write_text( + "import subprocess, sys\n" + "descendant = subprocess.Popen(\n" + " [sys.executable, '-c', 'import time; time.sleep(20)'],\n" + " stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,\n" + " stderr=subprocess.DEVNULL)\n" + "open(sys.argv[0] + '.pid', 'w').write(str(descendant.pid))\n" + "sys.exit(3)\n", encoding="utf-8") + argv = provider_mod.broker_operation_argv( + _broker_binding(script), provider_mod.OPERATION_MINT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.subprocess_broker_runner(argv, timeout=5) + descendant = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) + try: + assert caught.value.diagnostic == provider_mod.DIAG_BROKER_REFUSED + assert signalled == [], "no group is signalled after the reap" + finally: + with contextlib.suppress(ProcessLookupError): + os.kill(descendant, signal.SIGKILL) + + +def test_a_refusal_waits_on_a_killed_broker_only_so_long(monkeypatch): + """Copilot's review of openDox-code#64 at `bbcb565e`. SIGKILL ends a + broker at once unless it is stuck in uninterruptible I/O. Then the + refusal's wait, unbounded at `bbcb565e`, never returned, and this + process's ends of the pipes stayed open. The wait is bounded, and the + pipes are closed past it. The stand-in here is a real broker whose + `wait` behaves as that one's would.""" + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, process_group=0) + reap = child.wait + asked: list = [] + + def a_wait_sigkill_cannot_end(timeout=None): + asked.append(timeout) + if timeout is None: + raise AssertionError("an unbounded wait") + raise subprocess.TimeoutExpired(child.args, timeout) + + monkeypatch.setattr(child, "wait", a_wait_sigkill_cannot_end) + try: + provider_mod._reap(child) + assert asked == [provider_mod._REAP_GRACE_SECONDS] + assert child.stdin is None + assert child.stdout.closed + finally: + monkeypatch.undo() + child.kill() + reap(timeout=10) + + def test_a_broker_that_never_reads_the_credential_is_refused_in_time( tmp_path): """The timeout covers the credential's streaming too. At `a603a032` the From 31bddd839fb8029a94ab69527d3c7f6a1b1aeb7c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:13:04 +0000 Subject: [PATCH 31/45] T100, 16.3a: a served repository's bindings are trusted per machine (plan 034) RULED openxFactory#656 comment 5962785556, item 2 ("Trust per machine (Recommended)"); the text conformed to is T007 batch M's (openxFactory#1219). The entry points read the SERVED repository's bindings document, so a repository someone else wrote could run a program of its choosing on the first chat turn (broker_argv) and send any secret of the operator's to an endpoint of its choosing (an env: or keyring: reference). Both findings of the 2026-10-02 adversarial review are reproduced by the first two cases of tests/test_model_binding_trust.py, which fail at this change's base for the defect's own reasons. A binding read from the served repository now runs a broker, resolves a credential reference, or contacts its endpoint ONLY once the operator has trusted that exact binding on this machine: - src/opendox/doxbench_trust.py (new): the per-machine store, keyed by (resolved repository root, binding id, sha256 of the binding's canonical full record), one private file in openDox's state directory, checked as openDox-code#69's bundle tree is (no links, not writable by others, created by descriptor, written through an exclusive no-follow temporary); a state directory at or inside the served root is refused, naming OPENDOX_STATE_DIR. A policy seam (register / register_default / current / policy / unregister): openDox's strict store is the NEUTRAL default, registered lazily by the consumers where no host registered one, so a host keeps its own flow under its own policy. - doxbench_install: the factory asks the policy; an untrusted binding resolves UntrustedBindingPort (catalog available:false, every turn refused by name) and the start says so on stderr. A checkout with no bindings never asks. - doxbench_provider: in depth, every broker operation, the built-in resolver, and the port's dispatch and catalog refuse what no verdict covers, auth kind none included. - cli_model_binding: add and edit record trust before they write anything; `trust ` prints what it would run and where the credential goes (never the credential) and then records; set-credential is gated before its spawn and re-trusts a trusted binding it rewrote; list says whether each binding is trusted and prints every value escaped. - serve_workbench: the console intake refuses by name a broker the repository declares unless the policy admits the binding; a refused turn on an untrusted binding carries a fixed sentence saying how to trust it. .github/workflows/validate.yml: EXPECT_SKIPPED 11 -> 14, for the three strict-xfail cases that wait on openDox-code#69 (the store's default location), #74 (the rail's trust remedy) and #77 (a served turn that reaches its model step standalone). JUnit reports an xfail as a skip. Each moves back by one, with its reason, as T100 merges main after that draft lands. The floors are not moved. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/validate.yml | 15 +- src/opendox/cli_model_binding.py | 235 ++++- src/opendox/doxbench_install.py | 87 +- src/opendox/doxbench_provider.py | 77 +- src/opendox/doxbench_trust.py | 899 ++++++++++++++++ src/opendox/serve_workbench.py | 41 +- tests/test_model_binding_trust.py | 1395 +++++++++++++++++++++++++ tests/test_model_provider_broker.py | 147 ++- tests/test_openprofiler_broker_e2e.py | 26 +- 9 files changed, 2821 insertions(+), 101 deletions(-) create mode 100644 src/opendox/doxbench_trust.py create mode 100644 tests/test_model_binding_trust.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5227f2fe..cae23b4b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -257,7 +257,20 @@ jobs: MIN_SELECTED: "2476" MIN_PASSED: "2465" # EXACT — the load-bearing number. Moves only with its reason. - EXPECT_SKIPPED: "11" + # + # MOVED FROM 11 TO 14 BY plan 034 T100 (#1144 16.3a), for its three + # strict-xfail cases in `tests/test_model_binding_trust.py`, each of + # which waits on another draft and names it: the trust store's + # default location (openDox-code#69's `config.state_dir`), the chat + # rail's trust remedy (openDox-code#74's visible no-model line), and + # a served turn that reaches its model step standalone + # (openDox-code#77's scope seam). JUnit reports an xfail as a skip. + # Each turns into a pass when T100 merges `main` after that draft has + # landed, so this moves back by one with each, WITH the reason, and + # is 11 again once all three are in. The floors are not moved: CI + # read 3327 / 3316 on this change's base (#64, run 37072154936), + # already 851 above them. + EXPECT_SKIPPED: "14" run: | python3 - <<'PY' > triple.env import xml.etree.ElementTree as ET, pathlib diff --git a/src/opendox/cli_model_binding.py b/src/opendox/cli_model_binding.py index 51a9709e..8f319924 100644 --- a/src/opendox/cli_model_binding.py +++ b/src/opendox/cli_model_binding.py @@ -18,6 +18,7 @@ from pathlib import Path from opendox import doxbench_binding as binding_mod +from opendox import doxbench_trust as trust_mod # =========================================================================== @@ -45,6 +46,28 @@ def _binding_store(args: argparse.Namespace) -> "binding_mod.BindingStore": return binding_mod.BindingStore(path) +def _repo_root(args: argparse.Namespace) -> Path: + """The repository whose bindings this invocation acts on, resolved: the + root a binding's trust is keyed to (#1144 16.3a).""" + return Path(args.repo_root).resolve() + + +def _record_trust(binding: "binding_mod.ModelProviderBinding", + args: argparse.Namespace): + """Record trust for the binding this act writes (#1144 16.3a; RULED + openxFactory#656 comment 5962785556, item 2): the operator declares it + here, so the operator trusts it. Returns the verdict. A store that cannot + record refuses (`doxbench_trust.TrustStoreRefused`, a `BindingRefused`), + and `add`, `edit` and `trust` ask BEFORE they write anything, so a refusal + leaves nothing written (T007 batch M).""" + return trust_mod.policy().record(binding, root=_repo_root(args)) + + +def _trusted_line(binding: "binding_mod.ModelProviderBinding", verdict) -> str: + return (f" trusted {trust_mod.shown(binding.id)} on this machine for " + f"{trust_mod.shown(verdict.root)}") + + def _declared_binding(args: argparse.Namespace) -> "binding_mod.ModelProviderBinding": return binding_mod.ModelProviderBinding( id=args.id, label=args.label, provider=args.provider, @@ -89,45 +112,98 @@ def cmd_model_binding_list(args: argparse.Namespace) -> int: if not disclosure["bindings"]: print(" (none declared — this install talks to no brokered provider)") return 0 + verdicts = _trust_lines(store, args) + # EVERY VALUE A REPOSITORY WROTE IS PRINTED ESCAPED, in a JSON string's + # form (#1144 16.3a, T007 batch M): a newline or a terminal control + # sequence in a field cannot forge or hide a line of this listing. + shown = trust_mod.shown for record in disclosure["bindings"]: - print(f" {record['id']} {record['label']}") - print(f" provider {record['provider']}") - print(f" auth kind {record['auth_kind']}") - print(f" approved by {record['approved_by']}") + print(f" {shown(record['id'])} {shown(record['label'])}") + print(f" provider {shown(record['provider'])}") + print(f" auth kind {shown(record['auth_kind'])}") + print(f" approved by {shown(record['approved_by'])}") reference = record["credential_ref"] print(f" credential ref " - f"{reference if reference is not None else NOT_DECLARED}") - print(f" endpoint {record['endpoint']}") - print(f" dialect {record['dialect']}") + f"{shown(reference) if reference is not None else NOT_DECLARED}") + print(f" endpoint {shown(record['endpoint'])}") + print(f" dialect {shown(record['dialect'])}") model = record["model"] print(f" model " - f"{model if model is not None else NO_MODEL_DECLARED}") + f"{shown(model) if model is not None else NO_MODEL_DECLARED}") argv = record["broker_argv"] - print(f" broker argv {argv if argv else NOT_DECLARED}") + print(f" broker argv {shown(argv) if argv else NOT_DECLARED}") print(f" custody {record['credential_custody']}") + print(f" trust {verdicts.get(record['id'], '')}") return 0 +def _trust_lines(store: "binding_mod.BindingStore", + args: argparse.Namespace) -> dict[str, str]: + """`list`'s trust line for each binding (#1144 16.3a): trusted on this + machine, or why not and the command that trusts it. Asked only when a + binding is declared, so an empty store never touches the state + directory.""" + root = _repo_root(args) + lines: dict[str, str] = {} + for binding in store.list(): + try: + verdict = trust_mod.policy().verdict(binding, root=root) + except Exception as exc: # noqa: BLE001 - a policy that fails trusts nothing + lines[binding.id] = (f"NOT trusted on this machine (the trust " + f"policy failed: {type(exc).__name__})") + continue + if isinstance(verdict, trust_mod.TrustVerdict) and verdict.admits( + binding): + lines[binding.id] = "trusted on this machine" + else: + reason = (getattr(verdict, "reason", None) + or trust_mod.REASON_NOT_COVERED) + lines[binding.id] = ( + f"NOT trusted on this machine ({reason}); trust it with: " + f"{trust_mod.trust_command(binding.id, str(root))}") + return lines + + def cmd_model_binding_add(args: argparse.Namespace) -> int: + """Declare a binding, and trust it on this machine (#1144 16.3a). + + THE TRUST IS RECORDED FIRST, once the binding is known to be new, so a + store that refuses (a state directory inside the served repository, a + link, a writable file) leaves NOTHING written (T007 batch M). A write + that fails after it leaves a trust for a binding never declared, which + trusts nothing that exists.""" store = _binding_store(args) try: - binding = store.add(_declared_binding(args)) + binding = _declared_binding(args) + if store.get(binding.id) is not None: + store.add(binding) # refuses the repeated id, in its own words + verdict = _record_trust(binding, args) + store.add(binding) except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 - print(f" declared {binding.id} in {store.path}") + print(f" declared {trust_mod.shown(binding.id)} in {store.path}") print(f" {binding.custody_notice()}") + print(_trusted_line(binding, verdict)) return 0 def cmd_model_binding_edit(args: argparse.Namespace) -> int: + """Replace a binding, and trust it on this machine in the form written + (#1144 16.3a). As `add`, the trust is recorded first, once the binding is + known to exist, so a store that refuses leaves nothing written.""" store = _binding_store(args) try: - binding = store.edit(_declared_binding(args)) + binding = _declared_binding(args) + if store.get(binding.id) is None: + store.edit(binding) # refuses the unknown id, in its own words + verdict = _record_trust(binding, args) + store.edit(binding) except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 - print(f" updated {binding.id} in {store.path}") + print(f" updated {trust_mod.shown(binding.id)} in {store.path}") + print(_trusted_line(binding, verdict)) return 0 @@ -158,7 +234,15 @@ def cmd_model_binding_set_credential(args: argparse.Namespace, *, A BINDING NO BROKER ANSWERS IS REFUSED, and its standard input is left unread (#1144 box 16.3). Its credential is where its `env:` or `keyring:` reference names, or it takes none, so there is no custodian to hand a - value to, and reading one here would be holding it for nothing.""" + value to, and reading one here would be holding it for nothing. + + A BINDING NOT TRUSTED ON THIS MACHINE IS REFUSED BY NAME BEFORE ITS + BROKER IS SPAWNED, and its standard input is left unread (#1144 16.3a; + RULED openxFactory#656 comment 5962785556, item 2). The broker it would + run, and the credential it would be handed, are both what a repository + chose. A TRUSTED binding is re-trusted in its new form once the reference + is rewritten, because that edit is this act's own. An untrusted one is + never trusted by this act.""" from opendox import doxbench_provider as provider_mod store = _binding_store(args) @@ -171,14 +255,116 @@ def cmd_model_binding_set_credential(args: argparse.Namespace, *, != binding_mod.CREDENTIAL_FROM_BROKER): raise binding_mod.BindingRefused(NO_BROKER_TO_HAND_TO.format( binding_id=binding.id, custody=binding.custody_notice())) + verdict = trust_mod.policy().verdict(binding, root=_repo_root(args)) + trust_mod.require_admitted(binding, verdict) reference = provider_mod.hand_off_credential( - binding, source if source is not None else sys.stdin) - store.edit(dataclasses.replace(binding, credential_ref=reference)) + binding, source if source is not None else sys.stdin, + trust=verdict) + rewritten = store.edit(dataclasses.replace(binding, + credential_ref=reference)) except (binding_mod.BindingRefused, provider_mod.BrokerRefused) as exc: print(str(exc), file=sys.stderr) return 1 print(f" the broker took custody and returned the reference {reference}") print(f" {binding_mod.CUSTODY_NOTICE}") + # RE-TRUSTED IN ITS NEW FORM, because the binding was trusted and this + # act made the change (T007 batch M). The reference is already the + # broker's, so a store that refuses now leaves the binding written and + # untrusted, which refuses it at use, and says so. + try: + verdict = _record_trust(rewritten, args) + except binding_mod.BindingRefused as exc: + print(f"{trust_mod.shown(rewritten.id)} holds the new reference, but " + f"it is NOT trusted on this machine: {exc}", file=sys.stderr) + return 1 + print(_trusted_line(rewritten, verdict)) + return 0 + + +def _trust_disclosure(binding: "binding_mod.ModelProviderBinding", + store: "binding_mod.BindingStore", + root: Path) -> list[str]: + """What `trust` prints BEFORE it records anything (#1144 16.3a; T007 batch + M): what will run (the broker argv) and where the credential goes (the + endpoint, the auth kind and the credential REFERENCE), never the + credential itself. Nothing is resolved here. EVERY VALUE IS PRINTED IN A + JSON STRING'S FORM (`doxbench_trust.shown`), because each comes from a + repository someone else may have written.""" + from opendox import doxbench_provider as provider_mod + + shown = trust_mod.shown + source = binding.credential_source() + if source == binding_mod.CREDENTIAL_FROM_BROKER: + resolved_by = "the broker below, which holds the credential" + runs = [shown(list(provider_mod.broker_operation_argv( + binding, operation))) + for operation in (provider_mod.OPERATION_INTAKE, + provider_mod.OPERATION_MINT)] + elif source == binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER: + parts = binding_mod.built_in_reference_parts(binding.credential_ref) + resolved_by = ( + "the built-in resolver, from the serving process's environment " + f"variable {shown(parts.name)}" + if parts.form == binding_mod.CREDENTIAL_REF_ENV else + "the built-in resolver, from the OS keyring entry for service " + f"{shown(parts.name)} and user {shown(parts.user)}") + runs = [] + else: + resolved_by = "nothing: this endpoint takes no credential" + runs = [] + goes = (f"to {shown(binding.endpoint)} alone, in each request's " + "authorization header, never through a redirect, and over " + "http:// through no proxy" + if source != binding_mod.NO_CREDENTIAL + else "nowhere: the auth kind none presents no credential") + reference = (shown(binding.credential_ref) + if binding.credential_ref is not None else NOT_DECLARED) + model = binding.model if binding.model is not None else binding.id + lines = [f" binding {shown(binding.id)} {shown(binding.label)}", + f" read from {shown(str(store.path))}", + f" repository {shown(str(root))}", + f" provider {shown(binding.provider)}", + f" auth kind {shown(binding.auth_kind)}", + f" credential ref {reference}", + f" resolved by {resolved_by}"] + if runs: + lines += [f" will run {run}" for run in runs] + else: + lines.append(" will run no program: no broker is declared") + lines += [f" credential goes {goes}", + f" chat goes to {shown(binding.endpoint)} " + f"(dialect {shown(binding.dialect)}, model {shown(model)})"] + return lines + + +def cmd_model_binding_trust(args: argparse.Namespace) -> int: + """TRUST one binding of this repository ON THIS MACHINE (#1144 16.3a; + RULED openxFactory#656 comment 5962785556, item 2). + + A binding read from a repository is used only once the operator has + trusted that exact binding here. This prints what is being trusted first, + what it would run and where its credential would go, and then records the + trust. It resolves no reference and spawns nothing. There is no `--yes`: + running the verb is the act.""" + store = _binding_store(args) + root = _repo_root(args) + try: + binding = store.get(args.binding_id) + if binding is None: + raise binding_mod.BindingRefused( + f"no binding with id {trust_mod.shown(args.binding_id)} is " + f"declared in {store.path}") + except binding_mod.BindingRefused as exc: + print(str(exc), file=sys.stderr) + return 1 + for line in _trust_disclosure(binding, store, root): + print(line) + try: + verdict = _record_trust(binding, args) + except binding_mod.BindingRefused as exc: + print(str(exc), file=sys.stderr) + return 1 + print(_trusted_line(binding, verdict)) return 0 @@ -283,8 +469,8 @@ def _add_binding_declaration_args(parser: argparse.ArgumentParser) -> None: def _add_model_binding_parser(sub) -> None: group = sub.add_parser( "model-binding", - help="model-provider settings: list / add / edit / remove bindings, " - "and hand a credential to the broker") + help="model-provider settings: list / add / edit / remove / trust " + "bindings, and hand a credential to the broker") verbs = group.add_subparsers(dest="model_binding_command", required=True) listing = verbs.add_parser("list", help="disclose every declared binding", @@ -319,3 +505,16 @@ def _add_model_binding_parser(sub) -> None: handing.add_argument("--id", required=True, help="the binding whose credential is being set") handing.set_defaults(func=cmd_model_binding_set_credential) + + # TRUST (#1144 16.3a; RULED openxFactory#656 comment 5962785556, item + # 2). The id is a POSITIONAL, as the ruling spells the verb: + # `opendox model-binding trust `. + trusting = verbs.add_parser( + "trust", + help="trust one binding of this repository on this machine, after " + "printing what it would run and where its credential would go", + allow_abbrev=False) + _add_binding_store_args(trusting) + trusting.add_argument("binding_id", metavar="ID", + help="the binding to trust") + trusting.set_defaults(func=cmd_model_binding_trust) diff --git a/src/opendox/doxbench_install.py b/src/opendox/doxbench_install.py index fef18092..937530c1 100644 --- a/src/opendox/doxbench_install.py +++ b/src/opendox/doxbench_install.py @@ -239,20 +239,25 @@ def brokered_catalog(binding) -> ModelCatalog: ]) -def brokered_model_port_factory(binding, *, runner=None, opener=None, - clock=None, notice=None): +def brokered_model_port_factory(binding, *, trust=None, runner=None, + opener=None, clock=None, notice=None): """The ZERO-ARGUMENT factory for a BROKER-BACKED port, memoized per process. Same shape and same reason as `model_port_factory` above: the accessor is called per REQUEST, and a port built per call would mint a fresh token for every turn and discard a live one. The seams (`runner`, `opener`, `clock`, `notice`) pass through so a test can exercise a turn without a broker and - without a provider; production declares none of them.""" + without a provider; production declares none of them. + + `trust` is the verdict that covers this exact binding (#1144 16.3a; + `doxbench_trust`). The port asks it again before every act, so a port + built without one spawns, reads and contacts nothing.""" from opendox import doxbench_provider as provider_mod seams = {name: value for name, value in ( ("runner", runner), ("opener", opener), ("clock", clock), ("notice", notice)) if value is not None} + seams["trust"] = trust catalog = brokered_catalog(binding) lock = threading.Lock() holder: dict[str, object] = {} @@ -272,6 +277,70 @@ def resolve(): return resolve +def unavailable_catalog(binding) -> ModelCatalog: + """The catalog a binding discloses when it may not be used: its one entry, + exactly as `brokered_catalog` declares it, with `available: false`. The + catalog's wire shape is closed, so no reason rides it (#1144 16.3a).""" + import dataclasses + + return ModelCatalog.from_entries([ + dataclasses.replace(entry, available=False) + for entry in brokered_catalog(binding).entries]) + + +def trust_gated_model_port_factory(binding, *, checkout_root: Path | str): + """The factory for the first approved binding, ONCE THE TRUST POLICY HAS + JUDGED IT (#1144 16.3a; plan 034 T100; RULED openxFactory#656 comment + 5962785556, item 2). + + THE ONE PLACE A BINDING BECOMES USABLE. The binding was read from the + served repository, so it is used only if the registered trust policy + (`doxbench_trust.policy()`: a host's, or openDox's strict per-machine + store where no host registered one) trusts that exact binding at + `checkout_root`. Trusted, it resolves the brokered port, handed the + verdict. Untrusted, it resolves `doxbench_trust.UntrustedBindingPort`: + the catalog lists the binding `available: false`, a turn is refused by + name, and nothing is spawned, read or contacted. The refusal is said on + stderr, naming the binding and the command that trusts it. + + A policy that raises trusts nothing. Its words are not repeated: only the + class of what it raised is named.""" + from opendox import doxbench_trust as trust_mod + from opendox.doxbench_model import EMPTY_CATALOG, ModelCatalogError + + try: + verdict = trust_mod.policy().verdict(binding, root=checkout_root) + except Exception as error: # noqa: BLE001 - a policy that fails trusts nothing + verdict = trust_mod.TrustVerdict.untrusted_for( + binding, root=checkout_root, basis=trust_mod.BASIS_HOST, + reason=f"the trust policy failed ({type(error).__name__})") + if isinstance(verdict, trust_mod.TrustVerdict) and verdict.admits(binding): + return brokered_model_port_factory(binding, trust=verdict) + if not isinstance(verdict, trust_mod.TrustVerdict) or verdict.trusted: + # A verdict for another binding, or another form of it, covers + # nothing here. + verdict = trust_mod.TrustVerdict.untrusted_for( + binding, root=checkout_root, basis=trust_mod.BASIS_HOST, + reason=trust_mod.REASON_NOT_COVERED) + sys.stderr.write("[model-provider] " + trust_mod.refusal_message( + verdict.binding_id, verdict.root, + verdict.reason or trust_mod.REASON_NEVER_TRUSTED) + "\n") + try: + catalog = unavailable_catalog(binding) + except ModelCatalogError: + # An id or a label the catalog's schema refuses (a newline, a + # terminal escape) is a binding no turn could name anyway. It is + # refused by name above, and the catalog lists nothing rather than + # the start failing on what a repository wrote. + catalog = EMPTY_CATALOG + port = trust_mod.UntrustedBindingPort(catalog, verdict) + + def resolve(): + return port + + return resolve + + def declared_model_port_factory(session_root: Path | str, *, checkout_root: Path | str, bindings_path: Path | str | None = None, @@ -307,7 +376,14 @@ def declared_model_port_factory(session_root: Path | str, *, not declared. A binding the DECLARATIONS DOCUMENT SAYS NOTHING ABOUT is unaffected, byte for byte — it was declared by hand in the settings file by the operator, and the operator is who approval is a record of (see - `doxbench_intake`'s module docstring for why the rule is not inverted).""" + `doxbench_intake`'s module docstring for why the rule is not inverted). + + A DECLARED BINDING IS USED ONLY ONCE IT IS TRUSTED (#1144 16.3a; plan 034 + T100; RULED openxFactory#656 comment 5962785556, item 2). The binding was + read from the repository this install serves, so the registered trust + policy judges the first approved one (`trust_gated_model_port_factory`). + A checkout declaring none never asks the policy, so it never touches + openDox's state directory.""" from opendox import doxbench_binding as binding_mod from opendox import doxbench_intake as intake_mod @@ -336,4 +412,5 @@ def declared_model_port_factory(session_root: Path | str, *, "from the console's model intake flow\n") if not approved: return model_port_factory(Path(session_root), spawn=spawn) - return brokered_model_port_factory(approved[0]) + return trust_gated_model_port_factory(approved[0], + checkout_root=checkout_root) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 0ad24643..fc0b1342 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -120,6 +120,7 @@ from opendox import doxbench_binding as binding_mod from opendox import doxbench_bridge as bridge_mod from opendox import doxbench_model as model_mod +from opendox import doxbench_trust as trust_mod #: THIS MODULE'S OWN NAME, declared so the structural boundary test and the #: module cannot drift into naming two different files. The test asserts the @@ -840,7 +841,7 @@ def _declared_string(document: Mapping, field: str) -> str: return value -def _broker_operation(binding, operation: str, read, *, runner, +def _broker_operation(binding, operation: str, read, *, runner, trust, source=None, retry_of: str | None = None): """Run one declared `operation` through `runner`, and return what `read` makes of its answer. It is the one way each of the four operations asks @@ -856,7 +857,17 @@ def _broker_operation(binding, operation: str, read, *, runner, misbehaves may write anything into it. `source` is given to the runner only when there is one, which is - `intake`'s case. Every other operation reads no standard input.""" + `intake`'s case. Every other operation reads no standard input. + + NO BROKER RUNS FOR A BINDING THE TRUST VERDICT DOES NOT COVER (#1144 + 16.3a; plan 034 T100; RULED openxFactory#656 comment 5962785556, item + 2). All four operations come through here, whichever runner was + injected, so this is where every broker spawn asks: `trust` must be a + `doxbench_trust.TrustVerdict` trusting exactly this binding, or the + binding is refused by name before its invocation is even assembled. + `doxbench_install` asks the policy first. This is the defence beneath + it.""" + trust_mod.require_admitted(binding, trust) argv = broker_operation_argv(binding, operation, retry_of=retry_of) answer = None try: @@ -876,7 +887,7 @@ def _broker_operation(binding, operation: str, read, *, runner, # --------------------------------------------------------------------------- -def hand_off_credential(binding, source, *, +def hand_off_credential(binding, source, *, trust=None, runner=subprocess_broker_runner) -> str: """Hand a human's credential to the broker's `intake` and keep only the reference. @@ -906,9 +917,12 @@ def hand_off_credential(binding, source, *, here, where both entry points already catch a broker's refusal. A refusal names `intake` and keeps nothing the broker wrote - (`_broker_operation`).""" + (`_broker_operation`). + + `trust` is the verdict covering this exact binding (#1144 16.3a). Without + one the binding is refused by name and `source` is never read.""" return _broker_operation(binding, OPERATION_INTAKE, _intake_reference, - runner=runner, source=source) + runner=runner, source=source, trust=trust) def _intake_reference(answer: object) -> str: @@ -925,7 +939,7 @@ def _intake_reference(answer: object) -> str: # --------------------------------------------------------------------------- -def mint(binding, *, retry_of: str | None = None, +def mint(binding, *, trust=None, retry_of: str | None = None, runner=subprocess_broker_runner) -> MintedToken: """Ask the broker for a short-lived token. @@ -972,7 +986,7 @@ def mint(binding, *, retry_of: str | None = None, return _broker_operation( binding, OPERATION_MINT, lambda answer: _minted_token(answer, binding), - runner=runner, retry_of=retry_of) + runner=runner, retry_of=retry_of, trust=trust) def _minted_token(answer: object, binding) -> MintedToken: @@ -995,7 +1009,7 @@ def _minted_token(answer: object, binding) -> MintedToken: audit_ref=_declared_string(document, "audit_ref")) -def revoke(binding, *, runner=subprocess_broker_runner) -> str: +def revoke(binding, *, trust=None, runner=subprocess_broker_runner) -> str: """Destroy the broker's custody of this binding's credential. Returns the revocation's own `audit_ref`. The declaration keeps the audit @@ -1005,7 +1019,7 @@ def revoke(binding, *, runner=subprocess_broker_runner) -> str: or the same returned reference, never as the broker's own words. A refusal names `revoke` (`_broker_operation`).""" return _broker_operation(binding, OPERATION_REVOKE, _revocation_audit_ref, - runner=runner) + runner=runner, trust=trust) def _revocation_audit_ref(answer: object) -> str: @@ -1017,7 +1031,8 @@ def _revocation_audit_ref(answer: object) -> str: return _declared_string(document, "audit_ref") -def list_references(binding, *, runner=subprocess_broker_runner) -> list: +def list_references(binding, *, trust=None, + runner=subprocess_broker_runner) -> list: """The broker's NON-SECRET reference index, as the declaration returns it. Safe to read and safe to print: `list` never opens a custody file, and the @@ -1026,7 +1041,7 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: an index it does not own invents a second contract for it. A refusal names `list` (`_broker_operation`).""" return _broker_operation(binding, OPERATION_LIST, _reference_index, - runner=runner) + runner=runner, trust=trust) def _reference_index(answer: object) -> list: @@ -1087,7 +1102,7 @@ def _os_keyring(): return keyring -def resolve_credential_reference(binding, *, environ=None, +def resolve_credential_reference(binding, *, trust=None, environ=None, keyring_backend=None) -> str: """THE BUILT-IN RESOLVER: the credential an `env:NAME` or `keyring:SERVICE/USERNAME` reference names, read NOW. @@ -1117,7 +1132,15 @@ def resolve_credential_reference(binding, *, environ=None, that check here. The check is repeated before the first read all the same, because this is the function that holds the key. What reaches it is a programming error, like a broker's reference, and nothing has been - read when it is raised.""" + read when it is raised. + + NOTHING IS READ FOR A BINDING THE TRUST VERDICT DOES NOT COVER (#1144 + 16.3a; plan 034 T100; RULED openxFactory#656 comment 5962785556, item 2). + `trust` must be a `doxbench_trust.TrustVerdict` trusting exactly this + binding. It is asked after the two programming-error checks above and + BEFORE THE FIRST READ, so a binding a repository declared and nobody + trusted reads no variable and no keyring entry. `doxbench_install` asks + the policy before any port is built. This is the defence beneath it.""" reference = binding_mod.built_in_reference_parts(binding.credential_ref) if reference is None: raise AssertionError( @@ -1129,6 +1152,7 @@ def resolve_credential_reference(binding, *, environ=None, f"binding {binding.id!r} routes a credential the built-in " "resolver reads over a route that is not private, which the " "record refuses when it is declared; nothing was read") + trust_mod.require_admitted(binding, trust) if reference.form == binding_mod.CREDENTIAL_REF_ENV: value = (os.environ if environ is None else environ).get( reference.name) @@ -1484,6 +1508,7 @@ class BrokeredProviderPort: every capabilities probe.""" def __init__(self, binding, catalog, *, + trust=None, timeout_seconds: float = 60.0, runner=subprocess_broker_runner, opener=urllib.request.urlopen, @@ -1500,6 +1525,10 @@ def __init__(self, binding, catalog, *, f"catalog must be a ModelCatalog, got {type(catalog).__name__}") self._binding = binding self._declared_catalog = catalog + # THE TRUST VERDICT (#1144 16.3a). Held, and asked again by every act + # below, so a port built around a binding nobody trusted spawns, + # reads and contacts nothing (`dispatch`). + self._trust = trust self._timeout_seconds = model_mod.validated_timeout_seconds( timeout_seconds) self._runner = runner @@ -1529,8 +1558,12 @@ def catalog(self) -> model_mod.ModelCatalog: The same honesty the harness bridge keeps: a declaration is available until something is measured, and a broker that has refused is measured. Nothing here contacts the broker, or reads a reference, to find out. - A later turn that succeeds makes the entry available again.""" - if self._available: + A later turn that succeeds makes the entry available again. + + A BINDING THE TRUST VERDICT DOES NOT COVER IS NEVER AVAILABLE (#1144 + 16.3a), so no turn can select it.""" + if self._available and isinstance(self._trust, trust_mod.TrustVerdict) \ + and self._trust.admits(self._binding): return self._declared_catalog return model_mod.ModelCatalog.from_entries([ dataclasses.replace(entry, available=False) @@ -1569,12 +1602,20 @@ def dispatch(self, prompt_envelope: object) -> object: A RECORD NO BROKER ANSWERS takes `_dispatch_without_a_broker` instead (#1144 box 16.3): no mint, no ledger event, and no retry. + A BINDING THE TRUST VERDICT DOES NOT COVER IS REFUSED FIRST, by name, + before the prompt is rendered, a broker is spawned, a reference is + read or the endpoint is contacted (#1144 16.3a; RULED openxFactory#656 + comment 5962785556, item 2). That holds for the auth kind `none` too: + it presents no credential, but it would still send the turn to an + endpoint the binding chose. + EITHER WAY, THE PROVIDER IS CALLED THROUGH `_call_provider`, so a broker's minted token keeps every rule a built-in credential keeps: no redirect, no proxy over plain `http://`, and a refusal that chains nothing (Brett Heap's word of 2026-09-29). The re-mint and the retry above therefore happen outside every handler, so a refusal raised by either keeps no context either.""" + trust_mod.require_admitted(self._binding, self._trust) handle = getattr(prompt_envelope, "model_id", None) if not isinstance(handle, str) or not handle: entries = self._declared_catalog.entries @@ -1632,7 +1673,7 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: == binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER): try: credential = _PresentedCredential(resolve_credential_reference( - self._binding, environ=self._environ, + self._binding, trust=self._trust, environ=self._environ, keyring_backend=self._keyring_backend)) except BrokerRefused: with self._lock: @@ -1713,8 +1754,8 @@ def _current_token(self, reason: str, *, return held self._token = None try: - minted = mint(self._binding, retry_of=retry_of, - runner=self._runner) + minted = mint(self._binding, trust=self._trust, + retry_of=retry_of, runner=self._runner) except BrokerRefused: self._available = False raise diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py new file mode 100644 index 00000000..8b8771ae --- /dev/null +++ b/src/opendox/doxbench_trust.py @@ -0,0 +1,899 @@ +"""PER-MACHINE TRUST OF A SERVED REPOSITORY'S MODEL BINDINGS (#1144 16.3a; +plan 034 T100; RULED openxFactory#656 comment 5962785556, item 2, Brett +Heap, 2026-10-02: *"Trust per machine (Recommended)"*). + +THE DEFECT THIS CLOSES. A checkout's bindings live in the checkout +(`doxbench_binding.DEFAULT_BINDINGS_RELPATH`), because a binding is safe to +commit. So a repository someone else wrote can declare one. Measured at +openDox-code `047bb4fa` (the adversarial review of 2026-10-02): the entry +points read the SERVED repository's bindings document, a hand-written binding +needed no approval, a `broker_argv` of `["/bin/sh", "-c", "id > $PWD/pwned"]` +ran on the first chat turn, and an `env:` or `keyring:` reference sent any +secret of the operator's to an endpoint the file chose. + +THE RULE, as the ruling states it, and it works like direnv. A binding read +from the served repository runs a broker, resolves a credential reference, or +contacts its endpoint ONLY after the operator has trusted that exact binding on +this machine. "Exact" is the binding's canonical content (`binding_digest`), so +any edit untrusts it. The auth kind `none` is no exception: it presents no +credential, but it still sends chat content to an endpoint the file chooses. + + * `opendox model-binding add` and `edit` record trust for the binding they + write, and `set-credential` re-records it after it rewrites the reference + of a binding that was trusted. None of them ever trusts a binding that was + not. + * A cloned or hand-edited binding is refused BY NAME, before any spawn, + secret read or contact, until `opendox model-binding trust `, which + prints what it trusts first. + * Bindings stay committable. The trust is recorded OUTSIDE the repository, in + the operator's own state (`MachineTrust`). + +THE KEY is `(resolved repository root, binding id, digest)`. A binding copied +to another root is untrusted there: the root is part of what was trusted. + +EVERY PATH THAT RUNS A REPOSITORY'S BROKER ASKS. A chat turn and +`model-binding set-credential` ask about the binding they would use. The +console intake's hand-off asks too, about the binding it is declaring: its +broker is the one the served repository's +`ideation/dashboard/model-declarations.yaml` names, which is no binding the +operator trusted, so under this default it is refused by name +(`INTAKE_BROKER_UNTRUSTED`). No command trusts an intake declaration's broker, +and a host's own policy may admit it (#1144 16.3a, T007 batch M). + +WHAT A REPOSITORY WROTE IS SHOWN ESCAPED. Every value a refusal, the +factory's notice, `model-binding list` or `model-binding trust` prints from a +binding is printed in a JSON string's form (`shown`), so a newline or a +terminal control sequence in a field cannot forge or hide what is shown. The +command that trusts a binding quotes each operand for a shell, and falls back +to the same JSON form for an operand no shell quoting can show safely +(`trust_command`). + +THE STORE is one private file in openDox's state directory, the one +`runtime.config.state_dir` names (`OPENDOX_STATE_DIR`, or its per-user +default; openDox-code#69). It is checked the way that change's bundle checks +its own tree: a symbolic link is refused, so is a file or a directory that +another user could write, and the file is created by descriptor, exactly 0600, +and replaced atomically. The checks are restated here rather than imported, +because this change does not stack on #69. A store that fails them is refused +by name, and nothing is trusted through it. So is a state directory that is +the served repository or lies inside it, which a clone could carry: no trust +is ever written into, or read from, the tree being served. The store holds no +secret: roots, ids and digests. + +A SEAM, AND ITS DEFAULT IS THE STRICT ONE. What decides trust is a POLICY +registered here: `verdict(binding, *, root)` and `record(binding, *, root)`. +openDox's neutral default is `MachineTrust`, the per-machine store above. A +host registers its own at process start (`register`), so a governed host's +flow is its own to decide. The default is registered, where no host has +registered one, by the two consumers the entry points already call: +`doxbench_install.declared_model_port_factory` and the `model-binding` verbs. +That departs from R1Q10 (a)'s entry-point registration (`cli.build_parser()`, +`cli.main()`, `serve.build_server()`, `serve.main()`) on purpose: it keeps +this change out of those files' single-writer order, and it is fail-closed, +because what is registered lazily is the strictest policy there is. A +checkout with no bindings never asks the policy anything, so it never touches +the state directory. A process that asks `current()` with nothing registered +is refused, naming this seam (4.2's discipline). + +ENFORCED IN DEPTH. The ONE place a binding becomes usable is +`declared_model_port_factory`, and it asks the policy. Every place that would +act on a binding asks again, of a `TrustVerdict` it is handed: +`doxbench_provider`'s port, its broker operations (all four run through one +function) and its built-in resolver each refuse a binding the verdict does not +cover, by id AND digest (`require_admitted`). No verdict is no trust. + +IMPORT WEIGHT. The standard library and `opendox.doxbench_binding`. The +runtime's `config` is imported when a state directory is first resolved, and +`doxbench_model` when the refusing port is built. This module names no +provider, holds no credential, spawns nothing and reaches no network. + +A CREATED FILE: it has no row in openxFactory's +`docs/opendox-carve-manifest.yaml`, because the manifest declares what LEAVES +openxFactory and never what a destination assembles (RULED OQ-C). +""" + +from __future__ import annotations + +import dataclasses +import hashlib +import json +import os +import shlex +import stat +import threading +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from opendox import doxbench_binding as binding_mod + +__all__ = [ + "BASIS_HOST", + "BASIS_MACHINE_TRUST", + "BindingUntrusted", + "MachineTrust", + "TRUST_FILENAME", + "TrustPolicyAlreadyRegistered", + "TrustPolicyNotRegistered", + "TrustStoreRefused", + "TrustVerdict", + "UNTRUSTED_TURN_MESSAGE", + "UntrustedBindingPort", + "INTAKE_BROKER_UNTRUSTED", + "binding_digest", + "current", + "is_registered", + "policy", + "refusal_message", + "register", + "register_default", + "require_admitted", + "resolved_root", + "shown", + "trust_command", + "unregister", +] + +# --------------------------------------------------------------------------- +# the store's identity (the workspace rule: every document carries both) +# --------------------------------------------------------------------------- + +#: The store document's `schema_version`. +SCHEMA_VERSION = 1 + +#: The store document's `kind`. +TRUST_KIND = "opendox-model-binding-trust" + +#: The store's file name, directly under the state directory. +TRUST_FILENAME = "model-binding-trust.json" + +#: The largest store this reads. A bound, not a policy: a store is a few +#: hundred bytes a binding, and an unbounded read of a file is a way to spend +#: this process's memory. +MAX_TRUST_STORE_BYTES = 1_048_576 + +#: The digest's algorithm, spelled on every digest so a stored one says how it +#: was computed. +DIGEST_PREFIX = "sha256:" + +# --------------------------------------------------------------------------- +# what a verdict rests on +# --------------------------------------------------------------------------- + +#: The verdict `MachineTrust` gives: the per-machine store says so. +BASIS_MACHINE_TRUST = "machine-trust" + +#: A host policy's verdict (a governed host's own rule). +BASIS_HOST = "host" + +#: The setting that names openDox's state directory (openDox-code#69). +STATE_DIR_SETTING = "OPENDOX_STATE_DIR" + +# --------------------------------------------------------------------------- +# the fixed sentences (composed from nothing a repository or a secret holds) +# --------------------------------------------------------------------------- + +#: Why a binding the store has never seen at this root is untrusted. +REASON_NEVER_TRUSTED = ( + "it has not been trusted for this repository on this machine") + +#: Why a binding the store trusted in another form is untrusted. +REASON_CHANGED = ( + "it has changed since it was trusted for this repository on this machine") + +#: Why a binding handed to an act with no verdict at all is untrusted. +REASON_NO_VERDICT = "no trust verdict was given for it" + +#: Why a verdict for another binding, or another form of it, does not cover it. +REASON_NOT_COVERED = ( + "the trust verdict given for it covers another binding, or another form " + "of it") + +#: What the store refusal says when the runtime defines no state directory. +#: That is this change's base before openDox-code#69 lands. Nothing can be +#: trusted then, which is the fail-closed reading. +NO_STATE_DIR = ( + "this install defines no state directory for the trust store " + "(OPENDOX_STATE_DIR, openDox-code#69), so nothing can be trusted on this " + "machine") + +#: What a refused chat turn says (`serve_workbench`'s model step). A FIXED +#: module-level constant, because a turn refusal's message never carries +#: anything a request or a repository chose: it names no binding and no path. +#: The factory's notice and `opendox model-binding list` name the binding. +UNTRUSTED_TURN_MESSAGE = ( + "the model binding this install declares is not trusted on this machine, " + "so nothing was sent: no broker ran, no credential was read and no " + "endpoint was contacted. Run \"opendox model-binding list\" to see which " + "binding and why, trust it with \"opendox model-binding trust \", and " + "restart this console") + + +#: What the console intake's hand-off is refused with when the trust policy +#: does not admit the binding it is declaring (#1144 16.3a, T007 batch M). A +#: FIXED sentence: an intake refusal's reason never carries what the request +#: did. It names the document whose broker would run, and the seam that may +#: admit one. +INTAKE_BROKER_UNTRUSTED = ( + "the console intake would hand this credential to the broker the served " + "repository's ideation/dashboard/model-declarations.yaml names, and that " + "broker is not trusted on this machine, so nothing was run and nothing " + "was read. No command trusts an intake declaration's broker; a host's own " + "trust policy (opendox.doxbench_trust.register) may admit it") + + +class BindingUntrusted(binding_mod.BindingRefused): + """A binding is not trusted on this machine, so it is not used. + + Its message names the binding's id and the command that trusts it, and + never a secret: nothing has been resolved when it is raised. A + `BindingRefused`, so every caller that already catches the binding's one + refusal class catches this one too.""" + + +class TrustStoreRefused(binding_mod.BindingRefused): + """The trust store cannot be used: it is a link, another user could write + it, it does not read, or there is no state directory to keep it in. + Nothing is trusted through it.""" + + +class TrustPolicyNotRegistered(RuntimeError): + """Nothing is registered at the trust seam. Raised instead of answering a + default, which is a registration a consumer makes (`policy()`).""" + + +class TrustPolicyAlreadyRegistered(RuntimeError): + """A second, different policy was registered over a host's, or over the + default after a consumer had read it.""" + + +# --------------------------------------------------------------------------- +# the key +# --------------------------------------------------------------------------- + + +def binding_digest(binding) -> str: + """The digest of a binding's CANONICAL FULL CONTENT: its stored record + (`ModelProviderBinding.as_record()`, the record kind and all of + `BINDING_FIELDS`), as sorted, ASCII-escaped JSON, under SHA-256. + + Canonical, so a YAML spelling that reads as the same binding (key order, a + comment, `model` left out or written null) is the same binding, and any + change to any field is a different one.""" + if not isinstance(binding, binding_mod.ModelProviderBinding): + raise TypeError( + "a digest is of a ModelProviderBinding, got " + f"{type(binding).__name__}") + text = json.dumps(binding.as_record(), sort_keys=True, + separators=(",", ":"), ensure_ascii=True) + return DIGEST_PREFIX + hashlib.sha256(text.encode("ascii")).hexdigest() + + +def resolved_root(root: Path | str) -> str: + """The repository root as the key holds it: absolute, every link + resolved. A checkout reached through a link is the checkout it reaches, + and a copy elsewhere is another root.""" + return str(Path(root).resolve()) + + +def shown(value: object) -> str: + """A value a repository wrote, as a refusal or a disclosure prints it: in + a JSON string's form (a list in a JSON array's). A newline, a terminal + control sequence or any byte outside printable ASCII is escaped, so what + is shown cannot be forged or hidden by what it shows.""" + return json.dumps(value, ensure_ascii=True) + + +def _operand(value: str) -> str: + """One operand of a printed command: quoted for a POSIX shell where it is + printable ASCII, and otherwise in a JSON string's form, which no shell + quoting could print without carrying the bytes it escapes.""" + if value.isascii() and value.isprintable(): + return shlex.quote(value) + return shown(value) + + +def trust_command(binding_id: str, root: str | None = None) -> str: + """The command that trusts `binding_id`. The id and the root come from a + repository and a checkout, and a refusal that printed them bare would hand + a pasted command, or a terminal, whatever they hold (`_operand`).""" + command = f"opendox model-binding trust {_operand(binding_id)}" + if root is not None: + command += f" --repo-root {_operand(root)}" + return command + + +# --------------------------------------------------------------------------- +# the verdict +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass(frozen=True, slots=True) +class TrustVerdict: + """What a policy says of ONE binding, in ONE form, at ONE root. + + `admits` holds it to the exact binding it was given for: the same id and + the same digest. So a verdict cannot be carried over to another binding, + or to the same binding after an edit. `reason` says why an untrusted one + is untrusted, in a fixed sentence or the store's own refusal, and never + holds a secret.""" + + binding_id: str + digest: str + root: str | None + trusted: bool + basis: str + reason: str | None = None + + @classmethod + def trusted_for(cls, binding, *, root: Path | str | None, + basis: str) -> "TrustVerdict": + """A verdict TRUSTING this exact binding. For a policy to give.""" + return cls(binding_id=binding.id, digest=binding_digest(binding), + root=None if root is None else resolved_root(root), + trusted=True, basis=basis) + + @classmethod + def untrusted_for(cls, binding, *, root: Path | str | None, basis: str, + reason: str) -> "TrustVerdict": + """A verdict REFUSING this binding, with the reason.""" + return cls(binding_id=binding.id, digest=binding_digest(binding), + root=None if root is None else resolved_root(root), + trusted=False, basis=basis, reason=reason) + + def admits(self, binding) -> bool: + """Whether this verdict trusts exactly `binding`.""" + return (self.trusted + and isinstance(binding, binding_mod.ModelProviderBinding) + and binding.id == self.binding_id + and binding_digest(binding) == self.digest) + + +def refusal_message(binding_id: str, root: str | None, reason: str) -> str: + """The refusal of an untrusted binding, BY NAME: the id, the reason, and + the command that trusts it. It names no secret, and nothing has been + resolved when it is composed.""" + where = (f" in the repository at {shown(root)}" if root is not None + else "") + return (f"model binding {shown(binding_id)}{where} is not trusted on this " + f"machine ({reason}), so it is not used: no broker runs, no " + "credential reference is resolved and no endpoint is contacted. " + "Review it, then trust it with: " + f"{trust_command(binding_id, root)}") + + +def require_admitted(binding, trust: TrustVerdict | None) -> None: + """Refuse, by name, a binding that `trust` does not cover. + + Asked by every act on a binding before it spawns, reads or contacts + anything (`doxbench_provider`). `None` is no verdict, and no verdict is no + trust.""" + if isinstance(trust, TrustVerdict) and trust.admits(binding): + return + binding_id = getattr(binding, "id", "") + if not isinstance(trust, TrustVerdict): + raise BindingUntrusted(refusal_message( + str(binding_id), None, REASON_NO_VERDICT)) + if (trust.trusted or trust.binding_id != binding_id + or not isinstance(binding, binding_mod.ModelProviderBinding) + or binding_digest(binding) != trust.digest): + raise BindingUntrusted(refusal_message( + str(binding_id), trust.root, REASON_NOT_COVERED)) + raise BindingUntrusted(refusal_message( + trust.binding_id, trust.root, trust.reason or REASON_NEVER_TRUSTED)) + + +# --------------------------------------------------------------------------- +# the store's tree (the discipline of openDox-code#69's bundle tree) +# --------------------------------------------------------------------------- + + +def _unsafe_because(info: os.stat_result, *, uid: int, own: bool, + directory: bool = True) -> str | None: + """Why one path of the store's tree is unsafe, or None. + + The rules are #69's (`runtime/bundle._unsafe_because`). The store's OWN + file and directory are this user's alone and writable by no one else. An + ancestor is this user's or root's, and one that others can write is + sticky, so nobody can rename what is not theirs. A link is refused + outright.""" + mode = info.st_mode + if stat.S_ISLNK(mode): + return "is a symbolic link" + if directory and not stat.S_ISDIR(mode): + return "is not a directory" + if not directory and not stat.S_ISREG(mode): + return "is not a regular file" + if own: + if info.st_uid != uid: + return f"is owned by uid {info.st_uid}, not by this user" + if mode & 0o022: + return (f"is writable by " + f"{'every user' if mode & 0o002 else 'its group'}" + f" (mode {stat.S_IMODE(mode):o})") + return None + if info.st_uid not in (uid, 0): + return f"is owned by uid {info.st_uid}, neither this user nor root" + if mode & 0o022 and not mode & stat.S_ISVTX: + return (f"is writable by " + f"{'every user' if mode & 0o002 else 'its group'}" + f" and is not sticky (mode {stat.S_IMODE(mode):o})") + return None + + +def _store_refused(path: Path | str, reason: str) -> TrustStoreRefused: + return TrustStoreRefused( + f"the model-binding trust store refuses {path}: it {reason}, so " + "another user could change what this machine trusts. Keep openDox's " + "state directory (OPENDOX_STATE_DIR) where only this user can change " + "it") + + +def _refuse_an_unsafe_tree(state: Path, *, existing_only: bool) -> None: + """The store's directory, and every directory above it, are this user's + to change, or the store is refused (#69's `_refuse_an_unsafe_tree`). + + With `existing_only`, only what exists is judged, which is what is asked + before anything is created.""" + uid = os.getuid() + for component in (state, *state.parents): + if existing_only and not os.path.lexists(component): + continue + info = os.lstat(component) + if stat.S_ISLNK(info.st_mode) and info.st_uid not in (uid, 0): + raise _store_refused( + component, f"is a symbolic link owned by uid {info.st_uid}, " + "neither this user nor root, who could point it elsewhere") + if existing_only and not os.path.lexists(state): + resolved_parents = [p for p in state.parents if os.path.lexists(p)] + checks = [(path, False) for path in resolved_parents] + else: + real = state.resolve() + checks = [(real, True)] + [ + (path, False) for path in dict.fromkeys( + [*real.parents, *state.parents])] + for directory, mine in checks: + if existing_only and not os.path.lexists(directory): + continue + info = os.lstat(directory) if mine else os.stat(directory) + reason = _unsafe_because(info, uid=uid, own=mine) + if reason is not None: + raise _store_refused(directory, reason) + + +def _make_private_directories(leaf: Path) -> None: + """`leaf` and every missing directory above it, each born exactly 0700, + each made relative to its parent's descriptor and opened without + following a link before anything is made beneath it (#69's + `_make_private_directories`). A directory that exists is left as it is, + and the tree check judges it.""" + uid = os.getuid() + missing: list[str] = [] + base = leaf + while not os.path.lexists(base): + missing.append(base.name) + base = base.parent + if not missing: + return + descriptor = os.open(base, os.O_RDONLY | os.O_DIRECTORY) + path = base + previous = os.umask(0o077) + try: + for name in reversed(missing): + path = path / name + try: + os.mkdir(name, 0o700, dir_fd=descriptor) + except FileExistsError: + pass # made first by another process: judged next + try: + child = os.open(name, os.O_RDONLY | os.O_DIRECTORY + | os.O_NOFOLLOW, dir_fd=descriptor) + except OSError: + info = os.stat(name, dir_fd=descriptor, follow_symlinks=False) + reason = _unsafe_because(info, uid=uid, own=True) + if reason is None: + raise + raise _store_refused(path, reason) from None + os.close(descriptor) + descriptor = child + reason = _unsafe_because(os.fstat(descriptor), uid=uid, own=True) + if reason is not None: + raise _store_refused(path, reason) + finally: + os.umask(previous) + os.close(descriptor) + + +# --------------------------------------------------------------------------- +# openDox's neutral default: the per-machine store +# --------------------------------------------------------------------------- + + +class MachineTrust: + """openDox's NEUTRAL default policy: trust recorded per machine, outside + the repository, keyed `(resolved root, binding id, digest)`. + + ONE DIGEST PER `(root, id)`. Trusting a binding again, or an edit that + records trust, replaces the digest held, so the form trusted last is the + only one trusted. Retiring a binding forgets nothing, as direnv's allow + list does not: the same content declared again at the same root is the + content that was trusted. + + `state_dir` names the directory the store lives in. Unset, it is + `runtime.config.state_dir(env)`, which openDox-code#69 defines. Where the + runtime defines none, nothing can be trusted (`NO_STATE_DIR`). + + A read-modify-write race between two processes loses one record at worst, + which can only untrust a binding, never trust one.""" + + def __init__(self, *, state_dir: Path | str | None = None, + env: Mapping[str, str] | None = None) -> None: + self._state_dir = None if state_dir is None else Path(state_dir) + self._env = env + self._lock = threading.Lock() + + def __repr__(self) -> str: + where = ("the runtime's state directory" if self._state_dir is None + else str(self._state_dir)) + return f"MachineTrust({where})" + + # -- where --------------------------------------------------------------- + + def state_dir(self) -> Path: + """The directory the store lives in, or a `TrustStoreRefused`.""" + if self._state_dir is not None: + path = self._state_dir + else: + from opendox.runtime import config + + resolver = getattr(config, "state_dir", None) + if resolver is None: + raise TrustStoreRefused(NO_STATE_DIR) + try: + path = resolver(self._env) + except config.ConfigurationError as error: + raise TrustStoreRefused( + "the model-binding trust store has no state directory: " + f"{error}") from None + if not path.is_absolute() or ".." in path.parts: + raise TrustStoreRefused( + f"the model-binding trust store's state directory {path} is " + "not an absolute path free of '..', so it is not the one " + "another process would find") + return path + + def store_path(self) -> Path: + """Where the store file is, or a `TrustStoreRefused`.""" + return self.state_dir() / TRUST_FILENAME + + def _state_dir_outside(self, root: str) -> Path: + """The state directory, refused when it IS the served repository or + lies inside it (#1144 16.3a, T007 batch M). `config.state_dir` takes + any absolute path free of `..`, and a store a clone could carry is a + store the repository writes.""" + state = self.state_dir() + resolved = state.resolve() + served = Path(root) + if resolved == served or served in resolved.parents: + setting = (STATE_DIR_SETTING if self._state_dir is None + else "the trust store's state directory") + where = ("is the served repository" if resolved == served + else "lies inside the served repository") + raise TrustStoreRefused( + f"{setting} ({shown(str(state))}) {where} " + f"({shown(root)}), where a clone could carry what this " + "machine trusts, so the model-binding trust store refuses " + f"it and trusts nothing. Set {STATE_DIR_SETTING} to a " + "directory outside the repositories this machine serves") + return state + + # -- the policy ---------------------------------------------------------- + + def verdict(self, binding, *, root: Path | str) -> TrustVerdict: + """Whether this exact binding is trusted at `root` on this machine. + A store that cannot be used trusts nothing, and says why.""" + key_root = resolved_root(root) + try: + entries = self._read(self._state_dir_outside(key_root)) + except TrustStoreRefused as refusal: + return TrustVerdict.untrusted_for( + binding, root=key_root, basis=BASIS_MACHINE_TRUST, + reason=str(refusal)) + held = entries.get((key_root, binding.id)) + if held is None: + return TrustVerdict.untrusted_for( + binding, root=key_root, basis=BASIS_MACHINE_TRUST, + reason=REASON_NEVER_TRUSTED) + if held != binding_digest(binding): + return TrustVerdict.untrusted_for( + binding, root=key_root, basis=BASIS_MACHINE_TRUST, + reason=REASON_CHANGED) + return TrustVerdict.trusted_for(binding, root=key_root, + basis=BASIS_MACHINE_TRUST) + + def record(self, binding, *, root: Path | str) -> TrustVerdict: + """Trust this exact binding at `root` on this machine, and return the + verdict that says so. Refused, with nothing written, when the store + cannot be used.""" + key_root = resolved_root(root) + digest = binding_digest(binding) + with self._lock: + state = self._state_dir_outside(key_root) + _refuse_an_unsafe_tree(state, existing_only=True) + _make_private_directories(state) + _refuse_an_unsafe_tree(state, existing_only=False) + entries = self._read(state) + entries[(key_root, binding.id)] = digest + self._write(state, entries) + return TrustVerdict.trusted_for(binding, root=key_root, + basis=BASIS_MACHINE_TRUST) + + # -- the document -------------------------------------------------------- + + def _read(self, state: Path) -> dict[tuple[str, str], str]: + """The store's entries. A store that does not exist trusts nothing. + One that is a link, is not this user's alone, does not read, or is + not a store this install wrote, is refused.""" + _refuse_an_unsafe_tree(state, existing_only=True) + path = state / TRUST_FILENAME + try: + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW + | getattr(os, "O_CLOEXEC", 0)) + except FileNotFoundError: + return {} + except OSError: + if os.path.lexists(path): + info = os.lstat(path) + reason = _unsafe_because(info, uid=os.getuid(), own=True, + directory=False) + if reason is not None: + raise _store_refused(path, reason) from None + raise _store_refused(path, "cannot be opened") from None + try: + reason = _unsafe_because(os.fstat(descriptor), uid=os.getuid(), + own=True, directory=False) + if reason is not None: + raise _store_refused(path, reason) + chunks: list[bytes] = [] + size = 0 + while size <= MAX_TRUST_STORE_BYTES: + chunk = os.read(descriptor, MAX_TRUST_STORE_BYTES + 1 - size) + if not chunk: + break + chunks.append(chunk) + size += len(chunk) + finally: + os.close(descriptor) + if size > MAX_TRUST_STORE_BYTES: + raise _store_refused(path, "is larger than any store this " + "install writes") + return self._entries(path, b"".join(chunks)) + + @staticmethod + def _entries(path: Path, raw: bytes) -> dict[tuple[str, str], str]: + try: + document = json.loads(raw.decode("utf-8")) + except (ValueError, RecursionError): + raise _store_refused(path, "does not read as JSON") from None + entries = (document.get("entries") + if isinstance(document, dict) else None) + if (not isinstance(document, dict) + or document.get("schema_version") != SCHEMA_VERSION + or document.get("kind") != TRUST_KIND + or not isinstance(entries, list)): + raise _store_refused(path, "is not a trust store this install " + "writes") + held: dict[tuple[str, str], str] = {} + for entry in entries: + if (not isinstance(entry, dict) + or set(entry) != {"root", "binding_id", "digest"} + or not all(isinstance(entry[k], str) for k in entry)): + raise _store_refused(path, "holds an entry this install does " + "not write") + held[(entry["root"], entry["binding_id"])] = entry["digest"] + return held + + @staticmethod + def _write(state: Path, entries: dict[tuple[str, str], str]) -> None: + """Replace the store atomically: a temporary file created + exclusively, without following a link, set to exactly 0600 before + anything is written to it (#69's `write_authentication`).""" + document = { + "schema_version": SCHEMA_VERSION, + "kind": TRUST_KIND, + "entries": [{"root": root, "binding_id": binding_id, + "digest": digest} + for (root, binding_id), digest in sorted( + entries.items())], + } + payload = (json.dumps(document, indent=2, sort_keys=True, + ensure_ascii=True) + "\n").encode("ascii") + target = state / TRUST_FILENAME + temporary = state / f".{TRUST_FILENAME}.opendox-{os.getpid()}" + try: + os.unlink(temporary) # an interrupted write's; a link itself, never its target + except FileNotFoundError: + pass + try: + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT + | os.O_EXCL | os.O_NOFOLLOW + | getattr(os, "O_CLOEXEC", 0), 0o600) + except OSError: + # Something took the name between the unlink and this open: a + # link, or a file, someone else put there. It is never followed + # or written through, and nothing is trusted. + raise _store_refused(temporary, "could not be created by this " + "process alone") from None + try: + os.fchmod(descriptor, 0o600) + view = memoryview(payload) + while view: + view = view[os.write(descriptor, view):] + os.fsync(descriptor) + except BaseException: + os.close(descriptor) + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + os.close(descriptor) + os.replace(temporary, target) + + +# --------------------------------------------------------------------------- +# the port an install declares for a binding it may not use +# --------------------------------------------------------------------------- + + +class UntrustedBindingPort: + """THE PORT AN INSTALL DECLARES WHEN ITS BINDING IS NOT TRUSTED. + + `doxbench_install.declared_model_port_factory` answers this one in place of + the brokered port. Its catalog lists the binding with `available: false`, + so no turn selects it, and its `dispatch` refuses the binding by name + (`BindingUntrusted`) without spawning, reading or contacting anything. The + catalog's wire shape is closed, so the reason is not in it: it is in the + factory's notice, in `opendox model-binding list`, and in a refused turn's + message (`UNTRUSTED_TURN_MESSAGE`).""" + + __slots__ = ("_catalog", "_verdict") + + def __init__(self, catalog, verdict: TrustVerdict) -> None: + from opendox import doxbench_model + + if not isinstance(catalog, doxbench_model.ModelCatalog): + raise TypeError( + f"catalog must be a ModelCatalog, got {type(catalog).__name__}") + if any(entry.available for entry in catalog.entries): + raise ValueError( + "an untrusted binding's catalog offers no available entry") + self._catalog = catalog + self._verdict = verdict + + @property + def timeout_seconds(self) -> float: + return 1.0 + + @property + def verdict(self) -> TrustVerdict: + return self._verdict + + def catalog(self): + return self._catalog + + def dispatch(self, prompt_envelope: object) -> object: + raise BindingUntrusted(refusal_message( + self._verdict.binding_id, self._verdict.root, + self._verdict.reason or REASON_NEVER_TRUSTED)) + + def __repr__(self) -> str: + return f"" + + +# --------------------------------------------------------------------------- +# the seam +# --------------------------------------------------------------------------- + +#: The two names a policy carries. +POLICY_CALLABLES: tuple[str, ...] = ("verdict", "record") + +#: The ONE call a host makes, quoted in every refusal. +REGISTRATION_CALL = "opendox.doxbench_trust.register()" + +_lock = threading.Lock() +_registered: Any = None +_is_default = False +_default_read = False + + +def _probe(registration: Any) -> None: + missing = [name for name in POLICY_CALLABLES + if not callable(getattr(registration, name, None))] + if registration is None or missing: + raise TypeError( + "doxbench_trust.register() takes a trust policy: an object " + f"carrying {', '.join(POLICY_CALLABLES)}. It lacks " + f"{', '.join(missing) or 'them'}.") + + +def register(registration: Any) -> Any: + """A HOST's trust policy, registered once, at process start. Returns it. + + The same policy again is a no-op. A different one over a host's is + refused. Over openDox's default it replaces the default until a consumer + has read it, and is refused after (R1Q3 (ii)'s rule, as the projection + seams keep it).""" + global _registered, _is_default, _default_read + _probe(registration) + with _lock: + held = _registered + if held is registration: + return registration + if held is None or (_is_default and not _default_read): + _registered, _is_default, _default_read = registration, False, False + return registration + over_a_host = not _is_default + if over_a_host: + raise TrustPolicyAlreadyRegistered( + "a host's trust policy is already registered at openDox's " + f"model-binding trust seam ({held!r}), and {registration!r} would " + "replace it. Registration happens ONCE, at process start. Call " + "opendox.doxbench_trust.unregister() first if the swap is " + "deliberate.") + raise TrustPolicyAlreadyRegistered( + "openDox's own strict trust policy is registered, and a consumer has " + f"already read it, so {registration!r} cannot replace it now. " + "Register the host's own at process start, before the model port is " + "declared or a model-binding verb runs.") + + +def register_default() -> Any: + """Register openDox's STRICT default, `MachineTrust()`, where nothing is + registered, and return whatever is registered afterwards. For the two + consumers (`policy()`), and never over a host.""" + global _registered, _is_default, _default_read + with _lock: + if _registered is None: + _registered, _is_default, _default_read = (MachineTrust(), True, + False) + return _registered + + +def current() -> Any: + """The registered policy, or a refusal naming this seam. Reading the + default closes its window: a host's registration after it is refused.""" + global _default_read + with _lock: + registered = _registered + if registered is not None and _is_default: + _default_read = True + if registered is None: + raise TrustPolicyNotRegistered( + "no trust policy is registered at openDox's model-binding trust " + "seam (opendox.doxbench_trust), so no binding read from a " + "repository can be judged. openDox's own, MachineTrust, is " + "registered by the consumers that ask (doxbench_trust.policy()), " + "never answered here. A host registers its own at process start " + "with\n\n " + REGISTRATION_CALL + "\n") + return registered + + +def policy() -> Any: + """What a consumer asks: openDox's strict default registered where no + host has registered one, then the registered policy.""" + register_default() + return current() + + +def unregister() -> None: + """Drop the registration and its records. For test isolation, and for a + host tearing down.""" + global _registered, _is_default, _default_read + with _lock: + _registered, _is_default, _default_read = None, False, False + + +def is_registered() -> bool: + """Is anything registered? Answers without reading or refusing.""" + return _registered is not None diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index 037ed2fb..ba7634df 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -1110,6 +1110,27 @@ def _handle_workbench_model_intake(self) -> None: self._intake_refusal(DOXBENCH_ERR_INVALID_INTAKE_REQUEST, str(error)) return + # THE BROKER IS THE SERVED REPOSITORY'S, SO IT RUNS ONLY IF TRUSTED + # (#1144 16.3a, T007 batch M; RULED openxFactory#656 comment + # 5962785556, item 2). The broker above comes from the repository's + # declarations document, which no binding's trust admits, so the + # registered trust policy is asked about the binding being declared. + # openDox's strict default refuses it; a host's own policy may admit + # it. Refused here, before any byte of the body is read, and the + # body is drained unread. + from opendox import doxbench_trust + try: + verdict = doxbench_trust.policy().verdict( + binding, root=Path(self.checkout_root)) + except Exception: # noqa: BLE001 - a policy that fails admits nothing + verdict = None + if not (isinstance(verdict, doxbench_trust.TrustVerdict) + and verdict.admits(binding)): + if length > 0: + _drain_refused_body(self.rfile, length) + self._intake_refusal(DOXBENCH_ERR_INTAKE_REFUSED, + doxbench_trust.INTAKE_BROKER_UNTRUSTED) + return accepts_secret = ( declared["kind"] == doxbench_binding.AUTH_KIND_API_KEY) source = (_CredentialStream(self.rfile, length) @@ -1124,7 +1145,10 @@ def _handle_workbench_model_intake(self) -> None: # a credential for no reason at all. _drain_refused_body(self.rfile, length) try: - reference = doxbench_provider.hand_off_credential(binding, source) + # Under the verdict the policy gave above. What the intake writes + # is NOT trusted by it (#1144 16.3a). + reference = doxbench_provider.hand_off_credential( + binding, source, trust=verdict) except doxbench_provider.BrokerRefused as error: # THE BROKER'S OWN WORDS NEVER REACH HERE: `BrokerRefused` carries # one of `doxbench_provider.FIXED_DIAGNOSTICS` and nothing else, and @@ -1842,8 +1866,19 @@ def _session_text(rel, _root=source_root): return model_entry = catalog.selectable_entry_for(model_id) if model_entry is None: - self._refuse_turn(validators, DOXBENCH_ERR_MODEL_UNAVAILABLE, - turn_id, failure_kind=failure_kind) + # A BINDING NOT TRUSTED ON THIS MACHINE SAYS SO (#1144 16.3a): its + # port is `doxbench_trust.UntrustedBindingPort`, and the refusal + # carries that module's FIXED sentence, which names no binding and + # no path. The catalog's shape is closed, so this is where a turn + # reads why. + from opendox import doxbench_trust + self._refuse_turn( + validators, DOXBENCH_ERR_MODEL_UNAVAILABLE, turn_id, + failure_kind=failure_kind, + message=(doxbench_trust.UNTRUSTED_TURN_MESSAGE + if isinstance(port, + doxbench_trust.UntrustedBindingPort) + else None)) return effective_input_limit = doxbench_model.effective_limit_bytes( diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py new file mode 100644 index 00000000..cca976c3 --- /dev/null +++ b/tests/test_model_binding_trust.py @@ -0,0 +1,1395 @@ +"""#1144 16.3a: a served repository's model bindings are trusted per machine +(plan 034 T100; RULED openxFactory#656 comment 5962785556, item 2, Brett +Heap, 2026-10-02: *"Trust per machine (Recommended)"*; the box's text is +T007 batch M's, and this file is the acceptance suite F16.1's batch M block +names). + +THE DEFECT, as the adversarial review of 2026-10-02 measured it at openDox-code +`047bb4fa`. The entry points read the SERVED repository's bindings document +(`doxbench_install.declared_model_port_factory` over +`doxbench_binding.bindings_path(checkout_root)`), and a hand-written binding +needed no approval. So a repository someone else wrote could: + + * run a program of its choosing on the first chat turn, through + `broker_argv` (`["/bin/sh", "-c", "id > $PWD/pwned"]` wrote the uid); and + * send any secret of the operator's to an endpoint of its choosing, through + an `env:` or `keyring:` reference (the review's `repo_binding_exfil.py`). + +The first two cases are those two findings, as the review ran them. They fail +at the base this change stacks on (openDox-code#64, `f8bc8aca`) for the +defect's own reasons: the secret is sent, and the program runs. Every other +case fails there because nothing it names exists. + +THE SHAPE F16.1 GIVES THE REST. Each case serves its own fresh `git init` +with its own fresh `OPENDOX_STATE_DIR` (`served`), so no case reads or writes +the operator's own trust. The store is registered over that directory +explicitly, because this change's base predates openDox-code#69's +`config.state_dir`, which is what reads the setting. The one case that reads +the setting itself is strict-xfail until #69 is on the base. Most cases run +over three bindings in turn (`KINDS`): + + * `broker`: its broker writes a marker file whenever it runs; + * `env`: its `env:` reference names a variable set to a known value, and the + serving process's environment records every name read from it; + * `keyring`: its `keyring:` reference names an entry of a stand-in keyring + backend that records every lookup. + +Each points at a loopback listener that records every request. + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import contextlib +import http.server +import importlib.util +import io +import json +import os +import shutil +import stat +import subprocess +import sys +import threading +import time +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from conftest import REPO_ROOT + +from opendox import cli as cli_mod +from opendox import doxbench_binding as binding_mod +from opendox import doxbench_install as install_mod +from opendox import doxbench_intake as intake_mod +from opendox import doxbench_provider as provider_mod +from opendox.runtime import config as runtime_config + +#: The operator's secret. A sentinel: long and unique, so a sweep that finds it +#: has found the real one. +SECRET = "cloud-secret-NOT-A-MODEL-KEY-7d41e9a2c0b85f36" +SECRET_NAME = "T100_UNRELATED_CLOUD_SECRET" +KEYRING_SERVICE = "t100-stand-in-service" +KEYRING_USER = "operator" + +#: The binding the repository declares. +BINDING_ID = "helpful-model" + +#: The three bindings F16.1's batch M block runs every case over. +KINDS = ("broker", "env", "keyring") + + +def _trust_mod(): + """`opendox.doxbench_trust`, imported where it is used, so the two cases + that hold the defect itself fail at the base for the defect's own reason + rather than at collection.""" + from opendox import doxbench_trust + + return doxbench_trust + + +def _clean_env() -> dict[str, str]: + return {k: v for k, v in os.environ.items() + if not k.startswith(("GIT_", "XF_"))} + + +# --------------------------------------------------------------------------- +# what each case serves, and what records who touched what +# --------------------------------------------------------------------------- + + +class _Listener: + """A loopback endpoint that records every request made to it, and answers + each in both dialects' shapes.""" + + def __init__(self) -> None: + self.requests: list[dict] = [] + listener = self + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 - the stdlib's spelling + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) + listener.requests.append( + {"headers": dict(self.headers.items()), + "body": body.decode("utf-8", "replace")}) + answer = json.dumps({ + "choices": [{"message": {"content": "ok"}}], + "assistant_prose": "ok"}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(answer))) + self.end_headers() + self.wfile.write(answer) + + def log_message(self, *args): + pass + + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), + Handler) + self.thread = threading.Thread(target=self.server.serve_forever, + daemon=True) + self.thread.start() + + @property + def endpoint(self) -> str: + return (f"http://127.0.0.1:{self.server.server_address[1]}" + "/v1/chat/completions") + + def authorizations(self) -> list[str]: + return [r["headers"].get("Authorization", "") for r in self.requests] + + def close(self) -> None: + self.server.shutdown() + self.server.server_close() + + +@pytest.fixture +def listener(): + served = _Listener() + try: + yield served + finally: + served.close() + + +class _RecordingEnviron(dict): + """An environment that records every name read from it.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.read: list[str] = [] + + def get(self, key, default=None): + self.read.append(key) + return super().get(key, default) + + def __getitem__(self, key): + self.read.append(key) + return super().__getitem__(key) + + +class _OsWithARecordedEnviron: + """`os`, as the provider module sees it, with an environment that records + what is read from it. Everything else is the real `os`.""" + + def __init__(self, environ: _RecordingEnviron) -> None: + self.environ = environ + + def __getattr__(self, name): + return getattr(os, name) + + +class _RecordingKeyring: + """A stand-in OS keyring backend that records every lookup.""" + + def __init__(self) -> None: + self.asked: list[tuple[str, str]] = [] + + def get_password(self, service, user): + self.asked.append((service, user)) + if (service, user) == (KEYRING_SERVICE, KEYRING_USER): + return SECRET + return None + + +_BROKER = """\ +import json, sys, time +members = sys.argv[1:] +with open(%(marker)r, "a", encoding="utf-8") as mark: + mark.write(" ".join(members) + "\\n") +operation = members[0] if members else "" +if operation == "intake": + sys.stdin.read() + print(json.dumps({"schema_version": 1, + "kind": "openprofiler_broker_intake", + "reference": "opref-fffffffffffffffffffffff1", "binding": "b", + "provider": "p", "auth_kind": "api_key", "label": None, + "created_at": "2026-10-02T00:00:00Z", "max_lifetime_seconds": 300, + "issued_by": "stand-in", "approved_by": "a", "audit_ref": "opaud-1"})) +elif operation == "mint": + print(json.dumps({"schema_version": 1, + "kind": "openprofiler_broker_mint", + "reference": "opref-0123456789abcdef01234567", "binding": "b", + "provider": "p", "auth_kind": "api_key", "token": %(token)r, + "token_type": "api_key", "issued_at": "2026-10-02T00:00:00Z", + "expires_at": %(expires)r, "expires_in_seconds": 300, "scope": [], + "issued_by": "stand-in", "approved_by": "a", "audit_ref": "opaud-2", + "retry_of": None, "enforcement": {}})) +""" + + +class _Served: + """One case's world: a fresh `git init`, a fresh `OPENDOX_STATE_DIR`, the + listener, a marker broker, the recorded environment and keyring, and the + private trust store over that state directory.""" + + def __init__(self, tmp_path: Path, listener: _Listener, monkeypatch): + self.tmp = tmp_path + self.listener = listener + self.repo = self.fresh_repository("r") + self.state_dir = tmp_path / "st" + monkeypatch.setenv("OPENDOX_STATE_DIR", str(self.state_dir)) + self.marker = tmp_path / "broker-ran" + self.broker = tmp_path / "broker.py" + expires = datetime.fromtimestamp(time.time() + 300, tz=timezone.utc) + self.broker.write_text(_BROKER % { + "marker": str(self.marker), "token": SECRET, + "expires": expires.strftime("%Y-%m-%dT%H:%M:%SZ")}, + encoding="utf-8") + self.environ = _RecordingEnviron({**os.environ, SECRET_NAME: SECRET}) + monkeypatch.setattr(provider_mod, "os", + _OsWithARecordedEnviron(self.environ)) + self.keyring = _RecordingKeyring() + monkeypatch.setattr(provider_mod, "_os_keyring", + lambda: self.keyring) + trust_mod = _trust_mod() + trust_mod.unregister() + self.trust = trust_mod.MachineTrust(state_dir=self.state_dir) + trust_mod.register(self.trust) + + def fresh_repository(self, name: str) -> Path: + root = self.tmp / name + root.mkdir() + subprocess.run(["git", "init", "-q", str(root)], check=True, + env=_clean_env()) + return root + + def record(self, kind: str, **changes) -> dict: + """The stored record of `kind`'s binding.""" + record = {"kind": "model-provider-binding", "id": BINDING_ID, + "label": "Helpful model", "provider": "anyone", + "auth_kind": "api_key", "approved_by": "repo-author", + "endpoint": self.listener.endpoint, + "dialect": "openai-chat-v1", "model": None, + "credential_ref": None, "broker_argv": []} + if kind == "broker": + record.update(credential_ref="opref-0123456789abcdef01234567", + broker_argv=[sys.executable, str(self.broker)]) + elif kind == "env": + record.update(credential_ref=f"env:{SECRET_NAME}") + else: + record.update( + credential_ref=f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}") + record.update(changes) + return record + + def hand_write(self, *records: dict, root: Path | None = None) -> Path: + """The bindings document, written into the repository by hand, as a + clone delivers it. JSON is YAML, and it spells every byte exactly.""" + path = binding_mod.bindings_path(root or self.repo) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({ + "schema_version": 1, "kind": "model-provider-bindings", + "bindings": list(records)}, indent=2), encoding="utf-8") + return path + + def declared(self, root: Path | None = None): + return binding_mod.BindingStore( + binding_mod.bindings_path(root or self.repo)).list()[0] + + def port(self, root: Path | None = None): + return install_mod.declared_model_port_factory( + self.tmp / "sessions", checkout_root=root or self.repo)() + + def nothing_was_touched(self) -> None: + """No broker ran, the variable was never read, the keyring was never + asked and the listener heard nothing.""" + assert not self.marker.exists(), self.marker.read_text() + assert SECRET_NAME not in self.environ.read + assert self.keyring.asked == [] + assert self.listener.requests == [] + + def add_argv(self, kind: str, *extra: str) -> list[str]: + record = self.record(kind) + argv = ["model-binding", "add", "--repo-root", str(self.repo), + "--id", record["id"], "--label", record["label"], + "--provider", record["provider"], + "--auth-kind", record["auth_kind"], + "--credential-ref", record["credential_ref"], + "--credential-approver", "brett@opensoft.one", + "--endpoint", record["endpoint"], + "--dialect", record["dialect"], *extra] + if record["broker_argv"]: + argv += ["--", *record["broker_argv"]] + return argv + + +@pytest.fixture +def served(tmp_path, listener, monkeypatch): + world = _Served(tmp_path, listener, monkeypatch) + try: + yield world + finally: + _trust_mod().unregister() + + +class _Envelope: + model_id = BINDING_ID + + def rendered(self) -> str: + return "hi" + + +def _cli(*argv: str) -> int: + """A `model-binding` verb, as `opendox` runs it.""" + args = cli_mod.build_parser().parse_args(list(argv)) + return args.func(args) + + +def _command(root: Path) -> str: + return (f"opendox model-binding trust {BINDING_ID} --repo-root " + f"{root.resolve()}") + + +# =========================================================================== +# 1. the defect, as the review ran it (red at the base for its own reason) +# =========================================================================== + + +@contextlib.contextmanager +def _a_private_policy_where_one_exists(state_dir: Path): + """The private store the cases register, or nothing at the base, where + no trust seam exists, so these two cases run there and fail for the + defect's own reason.""" + try: + trust_mod = _trust_mod() + except ImportError: + yield + return + trust_mod.unregister() + trust_mod.register(trust_mod.MachineTrust(state_dir=state_dir)) + try: + yield + finally: + trust_mod.unregister() + + +def _review_record(endpoint, **changes) -> str: + """`repo_binding_exfil.py`'s committed record.""" + record = {"kind": "model-provider-binding", "id": BINDING_ID, + "label": "Helpful model", "provider": "anyone", + "credential_ref": f"env:{SECRET_NAME}", "auth_kind": "api_key", + "approved_by": "repo-author", "endpoint": endpoint, + "dialect": "openai-chat-v1"} + record.update(changes) + return json.dumps({"schema_version": 1, "kind": "model-provider-bindings", + "bindings": [record]}) + + +def test_the_reviewers_repro_is_refused_and_no_secret_leaves( + tmp_path, monkeypatch, capsys, listener): + """`repo_binding_exfil.py`: a committed binding names a variable of the + operator's environment, and the first chat turn sent its value as a + bearer to the file's endpoint. Now the binding is refused BY NAME, the + endpoint hears nothing, and the secret is in no refusal or notice.""" + monkeypatch.setenv(SECRET_NAME, SECRET) + corpus = tmp_path / "corpus" + path = binding_mod.bindings_path(corpus) + path.parent.mkdir(parents=True) + path.write_text(_review_record(listener.endpoint), encoding="utf-8") + with _a_private_policy_where_one_exists(tmp_path / "st"): + port = install_mod.declared_model_port_factory( + tmp_path / "sessions", checkout_root=corpus)() + try: + port.dispatch(_Envelope()) + except binding_mod.BindingRefused as caught: + refused = caught + else: + refused = None + notice = capsys.readouterr().err + sent = listener.authorizations() + assert sent == [], f"the endpoint was contacted, and was sent {sent}" + assert refused is not None, "the binding was used" + assert _command(corpus) in str(refused) + assert _command(corpus) in notice + assert [(e.model_id, e.available) for e in port.catalog().entries] == [ + (BINDING_ID, False)] + assert SECRET not in str(refused) + notice + repr(port) + + +def test_a_broker_argv_from_the_repository_never_runs(tmp_path, monkeypatch): + """The review's second finding: `["/bin/sh", "-c", "id > $PWD/pwned"]` in + a committed binding ran on the first chat turn.""" + work = tmp_path / "cwd" + work.mkdir() + monkeypatch.chdir(work) + corpus = tmp_path / "corpus" + path = binding_mod.bindings_path(corpus) + path.parent.mkdir(parents=True) + path.write_text(_review_record( + "https://provider.invalid/v1", + credential_ref="opref-0123456789abcdef01234567", + broker_argv=["/bin/sh", "-c", "id > $PWD/pwned"]), encoding="utf-8") + with _a_private_policy_where_one_exists(tmp_path / "st"): + port = install_mod.declared_model_port_factory( + tmp_path / "sessions", checkout_root=corpus)() + try: + port.dispatch(_Envelope()) + except Exception as caught: # noqa: BLE001 - at the base, a broker refusal after the run + refused = caught + assert not (work / "pwned").exists(), ( + "the repository's program ran: " + + (work / "pwned").read_text(encoding="utf-8")) + assert isinstance(refused, binding_mod.BindingRefused) + + +# =========================================================================== +# 2. F16.1's batch M block, case by case +# =========================================================================== + + +@pytest.mark.parametrize("kind", KINDS) +def test_an_untrusted_binding_is_refused_by_name_with_nothing_spawned_or_read( + served, capsys, kind): + """A binding written into the bindings document by hand, as a clone + delivers it, is listed `available: false`; a turn naming it is refused, + naming its id and the command that trusts it; the factory's notice and + `list` name the same; and nothing is spawned, read or contacted.""" + served.hand_write(served.record(kind)) + port = served.port() + notice = capsys.readouterr().err + assert [(e.model_id, e.available) for e in port.catalog().entries] == [ + (BINDING_ID, False)] + with pytest.raises(_trust_mod().BindingUntrusted) as refused: + port.dispatch(_Envelope()) + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + listed = capsys.readouterr().out + for text in (str(refused.value), notice, listed): + assert json.dumps(BINDING_ID) in text or BINDING_ID in text + assert _command(served.repo) in text + assert SECRET not in text + served.nothing_was_touched() + + +@pytest.mark.parametrize("kind", KINDS) +def test_set_credential_refuses_an_untrusted_binding_and_leaves_it_untrusted( + served, capsys, kind): + served.hand_write(served.record(kind)) + args = cli_mod.build_parser().parse_args([ + "model-binding", "set-credential", "--repo-root", str(served.repo), + "--id", BINDING_ID]) + + class _MustNotBeRead: + def read(self, *_args): + raise AssertionError("set-credential read a credential for a " + "binding it may not hand one to") + + assert cli_mod.cmd_model_binding_set_credential( + args, source=_MustNotBeRead()) == 1 + err = capsys.readouterr().err + if kind == "broker": + assert _command(served.repo) in err + else: + assert "names no broker" in err + assert not served.trust.verdict(served.declared(), root=served.repo).trusted + served.nothing_was_touched() + + +@pytest.mark.parametrize("kind", KINDS) +def test_add_records_trust_and_a_turn_uses_the_binding(served, capsys, kind): + """The same binding declared through `opendox model-binding add` is + offered as available, and a turn runs its broker, or reaches the + listener with the known value.""" + assert _cli(*served.add_argv(kind)) == 0 + assert f"trusted {json.dumps(BINDING_ID)} on this machine" in \ + capsys.readouterr().out + port = served.port() + assert isinstance(port, provider_mod.BrokeredProviderPort) + assert [e.available for e in port.catalog().entries] == [True] + assert port.dispatch(_Envelope())["assistant_prose"] == "ok" + if kind == "broker": + assert served.marker.read_text().startswith("mint ") + assert served.listener.authorizations() == [f"Bearer {SECRET}"] + + +#: One valid replacement for each field of the record. Together they are the +#: digest's whole field set. +EDITS = { + "id": "helpful-model2", + "label": "Helpful modem", + "provider": "anyonf", + "credential_ref": "opref-0123456789abcdef01234568", + "auth_kind": "oauth", + "approved_by": "repo-authos", + "endpoint": None, # the listener's, with another path + "dialect": "xfactory-prompt-v1", + "model": "stand-in-7b", + "broker_argv": None, # the broker's, with one more member +} + + +def test_the_edits_cover_every_field_of_the_record(): + assert sorted(EDITS) == sorted(binding_mod.BINDING_FIELDS) + + +@pytest.mark.parametrize("field", sorted(EDITS)) +def test_a_hand_edit_of_any_field_untrusts_the_binding(served, capsys, field): + """The binding as trusted, then the same document with ONE field changed + by hand: the digest differs, and the binding is refused by name.""" + served.hand_write(served.record("broker")) + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 0 + trusted = served.declared() + capsys.readouterr() + assert isinstance(served.port(), provider_mod.BrokeredProviderPort) + value = EDITS[field] + if field == "endpoint": + value = served.listener.endpoint + "x" + if field == "broker_argv": + value = [*served.record("broker")["broker_argv"], "--extra"] + served.hand_write(served.record("broker", **{field: value})) + edited = served.declared() + assert (_trust_mod().binding_digest(edited) + != _trust_mod().binding_digest(trusted)) + capsys.readouterr() + port = served.port() + assert isinstance(port, _trust_mod().UntrustedBindingPort) + with pytest.raises(_trust_mod().BindingUntrusted): + port.dispatch(_Envelope()) + served.nothing_was_touched() + + +def test_a_one_byte_edit_to_the_committed_file_untrusts(served, capsys): + path = served.hand_write(served.record("env")) + served.trust.record(served.declared(), root=served.repo) + assert isinstance(served.port(), provider_mod.BrokeredProviderPort) + text = path.read_text(encoding="utf-8") + path.write_text(text.replace("Helpful model", "Helpful modem"), + encoding="utf-8") + assert len(path.read_bytes()) == len(text.encode("utf-8")) + port = served.port() + assert isinstance(port, _trust_mod().UntrustedBindingPort) + assert _trust_mod().REASON_CHANGED in capsys.readouterr().err + + +def test_edit_and_set_credential_keep_a_trusted_binding_trusted(served, + capsys): + """A binding rewritten through `opendox model-binding edit` is trusted in + its new form, and its old form is not; a trusted binding whose reference + `set-credential` rewrote from the stand-in broker's answer is trusted.""" + assert _cli(*served.add_argv("broker")) == 0 + before = served.declared() + edit = served.add_argv("broker") + edit[1] = "edit" + edit[edit.index("--label") + 1] = "Renamed" + assert _cli(*edit) == 0 + after = served.declared() + assert after.label == "Renamed" + assert served.trust.verdict(after, root=served.repo).trusted + assert not served.trust.verdict(before, root=served.repo).trusted + args = cli_mod.build_parser().parse_args([ + "model-binding", "set-credential", "--repo-root", str(served.repo), + "--id", BINDING_ID]) + assert cli_mod.cmd_model_binding_set_credential( + args, source=io.StringIO("sk-stand-in-NOT-A-KEY")) == 0 + rewritten = served.declared() + assert rewritten.credential_ref == "opref-fffffffffffffffffffffff1" + assert served.trust.verdict(rewritten, root=served.repo).trusted + assert served.marker.read_text().startswith("intake ") + + +@pytest.mark.parametrize("kind", KINDS) +def test_trust_prints_what_it_trusts_then_trusts_it(served, capsys, kind): + """`opendox model-binding trust ` prints the broker argv, the + endpoint, the auth kind and the credential reference, each escaped, and + never the credential. It runs, reads and contacts nothing. Then the + binding is offered as available.""" + record = served.record(kind) + served.hand_write(record) + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 0 + out = capsys.readouterr().out + disclosure = out.split(f" trusted {json.dumps(BINDING_ID)}", 1)[0] + assert json.dumps(record["endpoint"]) in disclosure + assert f'auth kind "{record["auth_kind"]}"' in disclosure + assert json.dumps(record["credential_ref"]) in disclosure + if kind == "broker": + assert json.dumps(record["broker_argv"])[:-1] in disclosure + else: + assert "will run no program" in disclosure + assert SECRET not in out + served.nothing_was_touched() + port = served.port() + assert [e.available for e in port.catalog().entries] == [True] + + +def test_trust_takes_no_yes_and_refuses_an_unknown_id(served, capsys): + served.hand_write(served.record("env")) + with pytest.raises(SystemExit): + _cli("model-binding", "trust", "--repo-root", str(served.repo), + "--yes", BINDING_ID) + capsys.readouterr() + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + "no-such-binding") == 1 + assert 'no binding with id "no-such-binding"' in capsys.readouterr().err + assert not served.trust.verdict(served.declared(), + root=served.repo).trusted + + +def test_a_binding_carrying_control_characters_is_shown_escaped(served, + capsys): + """A hand-written binding whose id, label and one broker argv member carry + a newline and a terminal escape is printed with both escaped, by `trust`, + by `list` and in the refusal, and no raw control byte reaches the + output.""" + hostile = "evil\n\x1b[2J" + served.hand_write(served.record( + "broker", id=hostile, label=f"Label{hostile}", + broker_argv=[sys.executable, str(served.broker), f"--x{hostile}"])) + port = served.port() + with pytest.raises(_trust_mod().BindingUntrusted) as refused: + port.dispatch(_Envelope()) + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + hostile) == 0 + captured = capsys.readouterr() + for text in (captured.out, captured.err, str(refused.value)): + assert "\x1b" not in text + assert "evil\n" not in text + assert "\\u001b[2J" in text + served.nothing_was_touched() + + +def test_a_binding_moved_to_another_root_is_untrusted(served, capsys): + """A trusted bindings document, copied byte for byte into a second fresh + repository, reads untrusted there.""" + path = served.hand_write(served.record("env")) + served.trust.record(served.declared(), root=served.repo) + second = served.fresh_repository("r2") + copy = binding_mod.bindings_path(second) + copy.parent.mkdir(parents=True) + shutil.copyfile(path, copy) + capsys.readouterr() + port = served.port(second) + assert isinstance(port, _trust_mod().UntrustedBindingPort) + assert _command(second) in capsys.readouterr().err + with pytest.raises(_trust_mod().BindingUntrusted): + port.dispatch(_Envelope()) + assert isinstance(served.port(), provider_mod.BrokeredProviderPort) + served.nothing_was_touched() + + +def test_a_root_reached_through_a_link_is_the_root_it_reaches(served): + served.hand_write(served.record("env")) + served.trust.record(served.declared(), root=served.repo) + link = served.tmp / "link" + link.symlink_to(served.repo, target_is_directory=True) + assert served.trust.verdict(served.declared(link), root=link).trusted + + +# --- the trust file is checked ---------------------------------------------- + + +def _planted(served) -> str: + """A store that WOULD trust the case's binding at its root: what an + attacker wants this machine to read.""" + return json.dumps({"schema_version": 1, + "kind": "opendox-model-binding-trust", + "entries": [{"root": str(served.repo.resolve()), + "binding_id": BINDING_ID, + "digest": _trust_mod().binding_digest( + served.declared())}]}) + + +def _plant_link_to_the_file(served): + elsewhere = served.tmp / "elsewhere.json" + elsewhere.write_text(_planted(served), encoding="utf-8") + os.chmod(elsewhere, 0o600) + served.state_dir.mkdir(mode=0o700) + (served.state_dir / _trust_mod().TRUST_FILENAME).symlink_to(elsewhere) + return "is a symbolic link" + + +def _plant_link_to_the_directory(served): + real = served.tmp / "real-state" + real.mkdir(mode=0o700) + (real / _trust_mod().TRUST_FILENAME).write_text(_planted(served), + encoding="utf-8") + os.chmod(real / _trust_mod().TRUST_FILENAME, 0o600) + os.chmod(served.tmp, 0o700) + served.state_dir.symlink_to(real, target_is_directory=True) + # A link of this user's own, to a directory of this user's own, is a + # path #69's tree check accepts, so the directory is made writable by + # every user too: the check judges what the link reaches. + os.chmod(real, 0o777) + return "is writable by every user" + + +def _plant_a_writable_file(served, mode=0o666): + served.state_dir.mkdir(mode=0o700) + path = served.state_dir / _trust_mod().TRUST_FILENAME + path.write_text(_planted(served), encoding="utf-8") + os.chmod(path, mode) + return "is writable by" + + +def _plant_a_writable_directory(served): + served.state_dir.mkdir() + path = served.state_dir / _trust_mod().TRUST_FILENAME + path.write_text(_planted(served), encoding="utf-8") + os.chmod(path, 0o600) + os.chmod(served.state_dir, 0o770) + return "is writable by its group" + + +PLANTS = {"file-link": _plant_link_to_the_file, + "directory-link": _plant_link_to_the_directory, + "file-0666": _plant_a_writable_file, + "file-0620": lambda served: _plant_a_writable_file(served, 0o620), + "directory-0770": _plant_a_writable_directory} + + +@pytest.mark.parametrize("plant", sorted(PLANTS)) +def test_a_trust_file_another_user_could_change_trusts_nothing(served, + capsys, plant): + served.hand_write(served.record("env")) + reason = PLANTS[plant](served) + verdict = served.trust.verdict(served.declared(), root=served.repo) + assert not verdict.trusted + assert reason in verdict.reason + assert "model-binding trust store refuses" in verdict.reason + capsys.readouterr() + port = served.port() + assert isinstance(port, _trust_mod().UntrustedBindingPort) + assert reason in capsys.readouterr().err + with pytest.raises(_trust_mod().TrustStoreRefused): + served.trust.record(served.declared(), root=served.repo) + served.nothing_was_touched() + + +def test_a_trust_file_that_does_not_read_trusts_nothing(served): + served.hand_write(served.record("env")) + served.state_dir.mkdir(mode=0o700) + path = served.state_dir / _trust_mod().TRUST_FILENAME + for text in ("not json", json.dumps({"kind": "something-else"}), + json.dumps({"schema_version": 1, + "kind": "opendox-model-binding-trust", + "entries": [{"root": "/", "extra": 1}]})): + path.write_text(text, encoding="utf-8") + os.chmod(path, 0o600) + assert not served.trust.verdict(served.declared(), + root=served.repo).trusted + + +@pytest.mark.parametrize("where", ["equal", "nested"]) +def test_a_state_directory_at_or_inside_the_served_root_is_refused( + served, capsys, monkeypatch, where): + """`OPENDOX_STATE_DIR` equal to the served root, and nested under it: + `add`, `edit` and `trust` are refused naming the setting before anything + is written, and every binding reads untrusted.""" + trust_mod = _trust_mod() + state = served.repo if where == "equal" else served.repo / "dot" / "st" + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + trust_mod.unregister() + nested = trust_mod.MachineTrust(state_dir=state) + trust_mod.register(nested) + before = sorted(p.relative_to(served.repo).as_posix() + for p in served.repo.rglob("*") if ".git" not in p.parts) + assert _cli(*served.add_argv("env")) == 1 + assert "OPENDOX_STATE_DIR" in capsys.readouterr().err + assert not binding_mod.bindings_path(served.repo).exists() + path = served.hand_write(served.record("env")) + written = path.read_bytes() + edit = served.add_argv("env") + edit[1] = "edit" + edit[edit.index("--label") + 1] = "Renamed" + assert _cli(*edit) == 1 + assert "OPENDOX_STATE_DIR" in capsys.readouterr().err + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + assert "OPENDOX_STATE_DIR" in capsys.readouterr().err + assert path.read_bytes() == written + after = sorted(p.relative_to(served.repo).as_posix() + for p in served.repo.rglob("*") if ".git" not in p.parts) + assert after == sorted([*before, *_bindings_document_and_parents()]) + assert not nested.verdict(served.declared(), root=served.repo).trusted + assert isinstance(served.port(), trust_mod.UntrustedBindingPort) + + +def _bindings_document_and_parents() -> list[str]: + parts = Path(binding_mod.DEFAULT_BINDINGS_RELPATH).parts + return ["/".join(parts[:index]) for index in range(1, len(parts) + 1)] + + +def test_add_edit_and_trust_write_nothing_in_the_repository_but_the_document( + served): + assert _cli(*served.add_argv("env")) == 0 + edit = served.add_argv("env") + edit[1] = "edit" + assert _cli(*edit) == 0 + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 0 + written = sorted(p.relative_to(served.repo).as_posix() + for p in served.repo.rglob("*") + if ".git" not in p.parts) + assert written == sorted(_bindings_document_and_parents()) + + +def test_the_store_is_one_private_file_created_by_descriptor(served): + served.hand_write(served.record("env")) + victim = served.tmp / "victim" + victim.write_text("untouched", encoding="utf-8") + served.state_dir.mkdir(mode=0o700) + planted = (served.state_dir + / f".{_trust_mod().TRUST_FILENAME}.opendox-{os.getpid()}") + planted.symlink_to(victim) + previous = os.umask(0o000) + try: + served.trust.record(served.declared(), root=served.repo) + finally: + os.umask(previous) + assert victim.read_text(encoding="utf-8") == "untouched" + path = served.state_dir / _trust_mod().TRUST_FILENAME + assert stat.S_IMODE(os.lstat(path).st_mode) == 0o600 + assert sorted(p.name for p in served.state_dir.iterdir()) == [path.name] + assert json.loads(path.read_text(encoding="utf-8"))["entries"] == [{ + "root": str(served.repo.resolve()), "binding_id": BINDING_ID, + "digest": _trust_mod().binding_digest(served.declared())}] + + +def test_a_link_planted_between_the_unlink_and_the_create_is_never_followed( + served, monkeypatch): + """The race the exclusive, no-follow create exists for.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + served.state_dir.mkdir(mode=0o700) + victim = served.tmp / "victim" + victim.write_text("untouched", encoding="utf-8") + temporary = str(served.state_dir / f".{trust_mod.TRUST_FILENAME}.opendox-" + f"{os.getpid()}") + real_unlink = os.unlink + + def racing_unlink(path, *args, **kwargs): + try: + real_unlink(path, *args, **kwargs) + finally: + if str(path) == temporary and not os.path.lexists(temporary): + os.symlink(victim, temporary) + + monkeypatch.setattr(trust_mod.os, "unlink", racing_unlink) + with pytest.raises(trust_mod.TrustStoreRefused): + served.trust.record(served.declared(), root=served.repo) + monkeypatch.undo() + assert victim.read_text(encoding="utf-8") == "untouched" + assert not served.trust.verdict(served.declared(), + root=served.repo).trusted + + +# --- the console intake ------------------------------------------------------ + + +def _served_intake(served, *, host_policy=None): + """A stand-in host that offers the console intake: a plane with a session, + the served repository's declarations document naming the marker broker, + and an intake act posted from the console. Returns the answer.""" + import http.client + + from opendox import serve + + intake_mod.DeclarationStore(intake_mod.declarations_path( + served.repo)).declare_broker(intake_mod.BrokerDeclaration( + argv=(sys.executable, str(served.broker)))) + snapshot = served.tmp / "out" / "snapshot.json" + snapshot.parent.mkdir() + snapshot.write_text(json.dumps({"schema_version": 1}), encoding="utf-8") + if host_policy is not None: + trust_mod = _trust_mod() + trust_mod.unregister() + trust_mod.register(host_policy) + httpd = serve.build_server( + REPO_ROOT / "src" / "opendox" / "web", snapshot, served.repo, port=0, + actor="brett", model_port_factory=lambda: None) + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + try: + base = httpd.server_address[:2] + connection = http.client.HTTPConnection(*base, timeout=30) + connection.request("GET", "/capabilities") + caps = json.loads(connection.getresponse().read().decode("utf-8")) + connection.close() + query = "&".join(f"{k}={v}" for k, v in { + "binding": BINDING_ID, "label": "Helpful", "provider": "anyone", + "kind": "api_key", "endpoint": "https://provider.invalid/v1", + "dialect": "openai-chat-v1"}.items()) + connection = http.client.HTTPConnection(*base, timeout=30) + connection.request( + "POST", f"/actions/workbench/model-intake?{query}", + body=b"sk-stand-in-NOT-A-KEY", + headers={"Content-Type": "application/octet-stream", + serve.CONSOLE_TOKEN_HEADER: caps.get("console_token", + "")}) + answer = json.loads(connection.getresponse().read().decode("utf-8") + or "{}") + connection.close() + return caps, answer + finally: + httpd.shutdown() + httpd.server_close() + worker.join(timeout=10) + + +def test_the_console_intake_refuses_a_broker_the_repository_declares(served): + """With a stand-in host that offers the console intake and registers no + policy of its own, the intake's hand-off refuses by name a broker that + the served repository's `model-declarations.yaml` names, and no marker + file exists.""" + caps, answer = _served_intake(served) + if not caps.get("actions", {}).get("session"): + pytest.fail(f"the stand-in host offers no session: {caps}") + assert answer.get("error") == "intake_refused", answer + assert answer.get("reason") == _trust_mod().INTAKE_BROKER_UNTRUSTED + assert not served.marker.exists() + assert not binding_mod.bindings_path(served.repo).exists() + + +def test_a_hosts_own_policy_may_admit_the_console_intake(served): + class _AdmitsTheIntake: + def verdict(self, binding, *, root): + return _trust_mod().TrustVerdict.trusted_for( + binding, root=root, basis=_trust_mod().BASIS_HOST) + + def record(self, binding, *, root): + return self.verdict(binding, root=root) + + _caps, answer = _served_intake(served, host_policy=_AdmitsTheIntake()) + assert answer.get("error") is None, answer + assert served.marker.read_text().startswith("intake ") + + +# --- the policy seam --------------------------------------------------------- + + +def test_a_bare_process_registers_the_strict_default_at_first_use(served, + capsys): + """In a bare process that registers nothing, the first consumer to ask + registers the strict default, and a hand-written binding is refused.""" + trust_mod = _trust_mod() + trust_mod.unregister() + with pytest.raises(trust_mod.TrustPolicyNotRegistered) as bare: + trust_mod.current() + assert "opendox.doxbench_trust.register(" in str(bare.value) + served.hand_write(served.record("env")) + port = served.port() + assert type(trust_mod.current()) is trust_mod.MachineTrust + assert isinstance(port, trust_mod.UntrustedBindingPort) + assert _command(served.repo) in capsys.readouterr().err + served.nothing_was_touched() + + +def test_a_host_policy_registered_before_first_use_is_the_one_consulted( + served): + trust_mod = _trust_mod() + asked = [] + + class _Host: + def verdict(self, binding, *, root): + asked.append(binding.id) + return trust_mod.TrustVerdict.trusted_for( + binding, root=root, basis=trust_mod.BASIS_HOST) + + def record(self, binding, *, root): + return self.verdict(binding, root=root) + + host = _Host() + trust_mod.unregister() + trust_mod.register(host) + served.hand_write(served.record("env")) + assert isinstance(served.port(), provider_mod.BrokeredProviderPort) + assert asked == [BINDING_ID] + assert trust_mod.register_default() is host + assert trust_mod.policy() is host + + +def test_the_default_is_replaced_by_a_host_only_until_it_is_read(): + trust_mod = _trust_mod() + trust_mod.unregister() + host = trust_mod.MachineTrust(state_dir="/host-state") + try: + trust_mod.register_default() + assert trust_mod.register(host) is host + assert trust_mod.register(host) is host, "the same again is a no-op" + assert trust_mod.policy() is host + with pytest.raises(trust_mod.TrustPolicyAlreadyRegistered): + trust_mod.register(trust_mod.MachineTrust(state_dir="/other")) + trust_mod.unregister() + trust_mod.policy() + with pytest.raises(trust_mod.TrustPolicyAlreadyRegistered): + trust_mod.register(host) + with pytest.raises(TypeError): + trust_mod.unregister() + trust_mod.register(object()) + finally: + trust_mod.unregister() + + +def test_a_checkout_with_no_bindings_never_asks_the_policy(tmp_path, + monkeypatch): + """A checkout that declares no binding resolves what it resolved before, + and never touches the state directory: the policy is not even + registered, let alone read.""" + trust_mod = _trust_mod() + trust_mod.unregister() + asked = [] + monkeypatch.setattr(trust_mod, "policy", lambda: asked.append(1)) + checkout = tmp_path / "empty" + checkout.mkdir() + port = install_mod.declared_model_port_factory( + tmp_path / "sessions", checkout_root=checkout)() + assert asked == [] + assert not trust_mod.is_registered() + assert not isinstance(port, trust_mod.UntrustedBindingPort) + + +# =========================================================================== +# 3. in depth: the provider refuses what no verdict covers +# =========================================================================== + + +def _a_binding(**changes): + fields = dict(id=BINDING_ID, label="Helpful model", provider="anyone", + credential_ref="opref-0123456789abcdef01234567", + auth_kind="api_key", approved_by="brett@opensoft.one", + endpoint="https://provider.invalid/v1", + dialect="openai-chat-v1", broker_argv=("broker",)) + fields.update(changes) + return binding_mod.ModelProviderBinding(**fields) + + +def _built_in(endpoint: str): + return binding_mod.ModelProviderBinding( + id=BINDING_ID, label="Helpful model", provider="anyone", + credential_ref=f"env:{SECRET_NAME}", auth_kind="api_key", + approved_by="repo-author", endpoint=endpoint, + dialect="openai-chat-v1", broker_argv=()) + + +def _verdict_for(binding): + return _trust_mod().TrustVerdict.trusted_for(binding, root=None, + basis="test") + + +def test_the_resolver_reads_nothing_without_a_verdict_covering_the_binding( + listener): + binding = _built_in(listener.endpoint) + other = _built_in(listener.endpoint + "x") + untrusted = _trust_mod().TrustVerdict.untrusted_for( + binding, root=None, basis="test", reason="it was never trusted") + for verdict in (None, _verdict_for(other), untrusted): + environ = _RecordingEnviron({SECRET_NAME: SECRET}) + with pytest.raises(_trust_mod().BindingUntrusted) as refused: + provider_mod.resolve_credential_reference( + binding, trust=verdict, environ=environ) + assert environ.read == [], "the environment was read" + assert SECRET not in str(refused.value) + environ = _RecordingEnviron({SECRET_NAME: SECRET}) + assert provider_mod.resolve_credential_reference( + binding, trust=_verdict_for(binding), environ=environ) == SECRET + + +def _script_broker(tmp_path): + """A binding whose broker, if it ever runs, leaves a mark.""" + script = tmp_path / "marking-broker.py" + mark = tmp_path / "broker-ran" + script.write_text(f"open({str(mark)!r}, 'a').write('ran')\n", + encoding="utf-8") + return _a_binding(broker_argv=(sys.executable, str(script))), mark + + +@pytest.mark.parametrize("operation", ["mint", "hand_off_credential", + "revoke", "list_references"]) +def test_no_broker_runs_without_a_verdict_covering_the_binding( + tmp_path, operation): + binding, mark = _script_broker(tmp_path) + act = getattr(provider_mod, operation) + + class _MustNotBeRead: + def read(self, *_args): + raise AssertionError("the hand-off read its source") + + for verdict in (None, _verdict_for(_a_binding(label="another"))): + with pytest.raises(_trust_mod().BindingUntrusted): + if operation == "hand_off_credential": + act(binding, _MustNotBeRead(), trust=verdict) + else: + act(binding, trust=verdict) + assert not mark.exists(), f"{operation} ran the broker" + + +def test_the_broker_operation_runs_once_the_verdict_covers_it(tmp_path): + binding, mark = _script_broker(tmp_path) + with pytest.raises(provider_mod.BrokerRefused): + provider_mod.mint(binding, trust=_verdict_for(binding)) + assert mark.read_text() == "ran" + + +def test_the_port_contacts_nothing_without_a_verdict(listener, monkeypatch): + """The auth kind `none` too: it presents no credential, but it would + still send chat content to the endpoint the binding chose.""" + monkeypatch.setenv(SECRET_NAME, SECRET) + none = binding_mod.ModelProviderBinding( + id=BINDING_ID, label="Helpful model", provider="anyone", + credential_ref=None, auth_kind="none", approved_by="repo-author", + endpoint=listener.endpoint, dialect="openai-chat-v1", broker_argv=()) + for heard, binding in enumerate((none, _built_in(listener.endpoint))): + catalog = install_mod.brokered_catalog(binding) + port = provider_mod.BrokeredProviderPort(binding, catalog) + assert not any(e.available for e in port.catalog().entries) + with pytest.raises(_trust_mod().BindingUntrusted): + port.dispatch(_Envelope()) + assert len(listener.requests) == heard, "an untrusted port called" + trusted = provider_mod.BrokeredProviderPort( + binding, catalog, trust=_verdict_for(binding)) + assert all(e.available for e in trusted.catalog().entries) + assert trusted.dispatch(_Envelope())["assistant_prose"] == "ok" + assert len(listener.requests) == 2 + + +def test_a_policy_that_fails_or_answers_another_binding_trusts_nothing( + served, capsys): + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + + class Failing: + def verdict(self, binding, *, root): + raise RuntimeError(SECRET) + + def record(self, binding, *, root): + raise RuntimeError(SECRET) + + class Elsewhere: + def verdict(self, binding, *, root): + return trust_mod.TrustVerdict.trusted_for( + _a_binding(label="another"), root=root, basis="host") + + def record(self, binding, *, root): + raise AssertionError + + for policy in (Failing(), Elsewhere()): + trust_mod.unregister() + trust_mod.register(policy) + port = served.port() + assert isinstance(port, trust_mod.UntrustedBindingPort) + assert SECRET not in capsys.readouterr().err + served.nothing_was_touched() + + +# =========================================================================== +# 4. the governed host keeps its flow, under its own policy +# =========================================================================== + + +class _GovernedHostPolicy: + """WHAT T094 REGISTERS AS openxFactory's OWN POLICY, so the governed flow + is unchanged in release 1: a binding is trusted when the declarations + document records its declaration APPROVED by the gate, and when it + records no declaration for it at all, which is the operator's own binding + in the operator's own governed checkout, as today. A PENDING declaration + is not trusted (the factory already passes over it). Nothing is recorded: + the governed record is the gate's.""" + + def verdict(self, binding, *, root): + from opendox import doxbench_trust + + declaration = intake_mod.DeclarationStore( + intake_mod.declarations_path(root)).get(binding.id) + if declaration is None or declaration.status == \ + intake_mod.STATUS_APPROVED: + return doxbench_trust.TrustVerdict.trusted_for( + binding, root=root, basis=doxbench_trust.BASIS_HOST) + return doxbench_trust.TrustVerdict.untrusted_for( + binding, root=root, basis=doxbench_trust.BASIS_HOST, + reason="its declaration is not approved") + + def record(self, binding, *, root): + return self.verdict(binding, root=root) + + +def _approve(root: Path, binding_id: str) -> None: + store = intake_mod.DeclarationStore(intake_mod.declarations_path(root)) + store.propose(intake_mod.ModelDeclaration( + binding_id=binding_id, status=intake_mod.STATUS_PENDING, + install_posture=intake_mod.POSTURE_SINGLE_OPERATOR, + proposed_by="brett@opensoft.one", proposed_at=intake_mod.stamp())) + store.approve(binding_id, issued_by="console", approved_by="brett", + expires_at=intake_mod.approval_expiry(), + audit_ref="opaud-approved-1") + + +@pytest.mark.parametrize("declared", ["approved", "undeclared"]) +def test_a_governed_host_policy_keeps_the_governed_flow(served, declared): + """A composed host: openxFactory's policy, registered at process start, + resolves the brokered port exactly as the install did before this change, + for a binding the gate approved and for an undeclared one. The strict + default, in the same checkout, refuses both until `trust`.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + if declared == "approved": + _approve(served.repo, BINDING_ID) + trust_mod.unregister() + trust_mod.register(_GovernedHostPolicy()) + port = served.port() + assert isinstance(port, provider_mod.BrokeredProviderPort) + assert port.dispatch(_Envelope())["assistant_prose"] == "ok" + trust_mod.unregister() + trust_mod.register(served.trust) + assert isinstance(served.port(), trust_mod.UntrustedBindingPort) + + +# =========================================================================== +# 5. cases that wait on another draft (strict, naming it) +# =========================================================================== + +_HAS_STATE_DIR = hasattr(runtime_config, "state_dir") + + +@pytest.mark.xfail(not _HAS_STATE_DIR, strict=True, + reason="openDox's state directory (config.state_dir, " + "OPENDOX_STATE_DIR and its default) is " + "openDox-code#69's, which is not on this base") +def test_the_default_store_lives_in_the_settings_state_directory(tmp_path, + monkeypatch): + trust_mod = _trust_mod() + monkeypatch.setenv("OPENDOX_STATE_DIR", str(tmp_path / "st")) + policy = trust_mod.MachineTrust() + assert policy.store_path() == tmp_path / "st" / trust_mod.TRUST_FILENAME + root = tmp_path / "corpus" + root.mkdir() + policy.record(_a_binding(), root=root) + assert policy.verdict(_a_binding(), root=root).trusted + monkeypatch.setenv("OPENDOX_STATE_DIR", str(root)) + refused = policy.verdict(_a_binding(), root=root) + assert not refused.trusted and "OPENDOX_STATE_DIR" in refused.reason + + +def test_with_no_state_directory_nothing_is_trusted(tmp_path, monkeypatch): + """Fail-closed: where the runtime defines no state directory (this + change's base, before #69), the strict default trusts nothing.""" + trust_mod = _trust_mod() + monkeypatch.delattr(runtime_config, "state_dir", raising=False) + policy = trust_mod.MachineTrust() + verdict = policy.verdict(_a_binding(), root=tmp_path) + assert not verdict.trusted + assert verdict.reason == trust_mod.NO_STATE_DIR + with pytest.raises(trust_mod.TrustStoreRefused): + policy.record(_a_binding(), root=tmp_path) + + +_RAIL = REPO_ROOT / "src" / "opendox" / "web" / "views" / "doxbench-chat.js" +_HAS_NO_MODEL_RAIL = "NO_MODEL_CONFIGURED_REMEDY" in _RAIL.read_text( + encoding="utf-8") + + +@pytest.mark.xfail(not _HAS_NO_MODEL_RAIL, strict=True, + reason="the rail's visible no-model line is " + "openDox-code#74's (T081), which is not on this " + "base; the trust remedy sits beside it") +def test_the_rail_says_how_to_trust_a_declared_binding(): + """With a declared binding and none available, the rail must not say "no + model configured": it names `model-binding list` (which says why) and + `model-binding trust`.""" + source = _RAIL.read_text(encoding="utf-8") + assert "UNTRUSTED_BINDING_REMEDY" in source + assert '\\"opendox model-binding list\\"' in source + assert '\\"opendox model-binding trust \\"' in source + + +_TURN_REACHES_ITS_MODEL_STEP = importlib.util.find_spec( + "opendox.column_seams") is not None + + +class _Conforms: + @staticmethod + def iter_errors(_instance): + return iter(()) + + +class _EveryKind(dict): + """The released validators, as a plane that can read its contract has + them (openDox-code#77's `tests/test_neutral_turn_scope.py`).""" + + def get(self, _kind, _default=None): + return _Conforms() + + +@pytest.mark.xfail(not _TURN_REACHES_ITS_MODEL_STEP, strict=True, + reason="standalone, a turn reaches its model step only " + "once openDox-code#77 (T084) routes the doxBench " + "scope through a seam") +def test_a_served_turn_on_an_untrusted_binding_says_how_to_trust_it(served): + """A served turn naming the untrusted binding is refused + `model_unavailable` with the fixed sentence that says how to trust it, + and nothing is contacted.""" + import http.client + + from opendox import doxbench_hash, serve + from opendox.serve_wire import (DOXBENCH_CHAT_TURN_V2_KIND, + DOXBENCH_ERR_MODEL_UNAVAILABLE) + from standalone_child import fresh_repository, git, run_module + + repo = fresh_repository(REPO_ROOT / "tests" / "fixtures" + / "plain-documents", served.tmp / "turn") + git(repo, "config", "user.name", "fixture") + git(repo, "config", "user.email", "fixture@example.invalid") + served.hand_write(served.record("env"), root=repo) + out = served.tmp / "turn-out" / "snapshot.json" + generated, status = run_module( + served.tmp, "opendox.cli", "generate", "--repo-root", str(repo), + "--repository", "fixture", "--output", str(out), "--no-validate") + assert status == 0, generated.stderr_text() + httpd = serve.build_server( + REPO_ROOT / "src" / "opendox" / "web", out, repo, port=0, + actor="brett", schema_validator_factory=_EveryKind, + model_port_factory=install_mod.declared_model_port_factory( + install_mod.session_root_beside(out), checkout_root=repo)) + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + try: + base = httpd.server_address[:2] + connection = http.client.HTTPConnection(*base, timeout=30) + connection.request("GET", "/capabilities") + caps = json.loads(connection.getresponse().read().decode("utf-8")) + connection.close() + document = "notes-rain-barrel-leak.md" + text = (repo / document).read_text(encoding="utf-8") + + def buffer(kind, path, content): + identity = doxbench_hash.content_identity( + content, max_bytes=None).hex + return {"kind": kind, "repository": "fixture", "path": path, + "base_ref": "main", "base_revision": "0" * 40, + "base_hash": identity, "content_hash": identity, + "content": content, "dirty": False} + + connection = http.client.HTTPConnection(*base, timeout=30) + connection.request("POST", "/actions/workbench/chat-turn", + body=json.dumps({ + "schema_version": 1, + "kind": DOXBENCH_CHAT_TURN_V2_KIND, + "client_turn_id": "t100-untrusted", + "scope": {"repository": "fixture", + "ref": "main", + "tile_kind": "cluster", + "tile_id": "barrel-rain"}, + "working_subject": "", + "message": "What does this claim?", + "model_id": BINDING_ID, "transcript": [], + "bound_buffer": document, + "buffers": [ + buffer("outline", None, "# outline\n"), + buffer("document", document, text)], + }).encode("utf-8"), + headers={"Content-Type": "application/json", + serve.CONSOLE_TOKEN_HEADER: + caps["console_token"]}) + body = json.loads(connection.getresponse().read().decode("utf-8")) + connection.close() + finally: + httpd.shutdown() + httpd.server_close() + worker.join(timeout=10) + assert body.get("error") == DOXBENCH_ERR_MODEL_UNAVAILABLE, body + assert body.get("message") == _trust_mod().UNTRUSTED_TURN_MESSAGE, body + served.nothing_was_touched() diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 3044ceb4..5e1ba815 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -108,6 +108,44 @@ }) +@pytest.fixture(autouse=True) +def _a_private_trust_store(tmp_path_factory): + """THE TRUST SEAM, OVER A STORE OF EACH CASE'S OWN (#1144 16.3a; plan 034 + T100). `opendox model-binding add` and `edit` now record trust, and the + entry points' factory asks for it, so every case here runs with openDox's + own `MachineTrust` over a private state directory, never the operator's + real one. Outside `tmp_path`, so a case that sweeps its own tree for a + secret sweeps nothing this put there.""" + from opendox import doxbench_trust + + doxbench_trust.unregister() + doxbench_trust.register(doxbench_trust.MachineTrust( + state_dir=tmp_path_factory.mktemp("trust") / "st")) + try: + yield + finally: + doxbench_trust.unregister() + + +def _trusted(binding): + """A verdict trusting exactly `binding` (#1144 16.3a): what the entry + points' factory hands the provider for a binding the policy trusts. The + provider refuses any act on a binding no verdict covers, which + `tests/test_model_binding_trust.py` holds.""" + from opendox import doxbench_trust + + return doxbench_trust.TrustVerdict.trusted_for(binding, root=None, + basis="test") + + +def _trust_in_place(binding, checkout) -> None: + """Trust `binding` at `checkout` in the case's private store, as `opendox + model-binding trust` does, for a case that writes its bindings by hand.""" + from opendox import doxbench_trust + + doxbench_trust.policy().record(binding, root=checkout) + + def _binding(**overrides): fields = dict(id="openprofiler-demo", label="Demo brokered provider", provider="demo-provider", credential_ref=FAKE_REFERENCE, @@ -664,7 +702,7 @@ def test_the_credential_is_the_whole_of_the_brokers_standard_input(tmp_path): script = _write_broker(tmp_path) binding = _broker_binding(script) reference = provider_mod.hand_off_credential( - binding, io.StringIO(SENTINEL_CREDENTIAL)) + binding, io.StringIO(SENTINEL_CREDENTIAL), trust=_trusted(binding)) assert reference == FAKE_REFERENCE seen = _seen(script) @@ -686,7 +724,7 @@ def test_a_mint_reads_no_standard_input(tmp_path): """The declaration: `mint` does not read standard input and the caller may close it. So the adapter closes it, and the broker sees nothing.""" script = _write_broker(tmp_path) - provider_mod.mint(_broker_binding(script)) + provider_mod.mint(_broker_binding(script), trust=_trusted(_broker_binding(script))) assert _seen(script)["stdin"] is None @@ -699,6 +737,9 @@ def test_the_credential_survives_nowhere_in_the_checkout_or_the_surface( (checkout / "ideation" / "dashboard").mkdir(parents=True) store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) store.add(_broker_binding(script, credential_ref="opref-" + "0" * 24)) + # set-credential runs the binding's broker, so it is gated on trust + # (#1144 16.3a): a binding written by hand is trusted first. + _trust_in_place(store.get("openprofiler-demo"), checkout) args = cli_mod.build_parser().parse_args([ "model-binding", "set-credential", "--repo-root", str(checkout), @@ -726,7 +767,10 @@ def test_the_hand_off_takes_a_handle_and_never_a_value(): VALUE, so no caller can be holding one.""" import inspect signature = inspect.signature(provider_mod.hand_off_credential) - assert list(signature.parameters) == ["binding", "source", "runner"] + # `trust` (#1144 16.3a) is the verdict covering the binding: a fact about + # the binding, never a value the credential could ride. + assert list(signature.parameters) == ["binding", "source", "trust", + "runner"] # =========================================================================== @@ -748,7 +792,7 @@ def rendered(self) -> str: def test_a_mint_executes_the_declared_invocation_and_returns_a_token(tmp_path): script = _write_broker(tmp_path) binding = _broker_binding(script) - minted = provider_mod.mint(binding) + minted = provider_mod.mint(binding, trust=_trusted(binding)) assert minted.token == SENTINEL_TOKEN assert minted.audit_ref.startswith("opaud-") # 0.2 FINDING 3: the ROUTE is the binding's, because the declaration's mint @@ -776,7 +820,7 @@ def test_a_mint_answer_that_named_a_route_would_still_not_supply_one(tmp_path): "'enforcement':{},'endpoint':'https://elsewhere.invalid'}))\n", encoding="utf-8") with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.mint(_broker_binding(script)) + provider_mod.mint(_broker_binding(script), trust=_trusted(_broker_binding(script))) assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED @@ -795,7 +839,7 @@ def test_an_answer_carrying_an_undeclared_key_is_malformed(tmp_path): encoding="utf-8") with pytest.raises(provider_mod.BrokerRefused) as caught: provider_mod.hand_off_credential(_broker_binding(script), - io.StringIO("x")) + io.StringIO("x"), trust=_trusted(_broker_binding(script))) assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED @@ -808,7 +852,7 @@ def test_an_answer_missing_a_declared_key_is_malformed(tmp_path): encoding="utf-8") with pytest.raises(provider_mod.BrokerRefused) as caught: provider_mod.hand_off_credential(_broker_binding(script), - io.StringIO("x")) + io.StringIO("x"), trust=_trusted(_broker_binding(script))) assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED @@ -837,16 +881,16 @@ def test_the_declared_answer_field_lists_match_the_declaration(): def test_revoke_and_list_speak_the_declared_surface(tmp_path): script = _write_broker(tmp_path) binding = _broker_binding(script) - assert provider_mod.revoke(binding) == "opaud-77b0c4e91d3a5628ff0e1a42" + assert provider_mod.revoke(binding, trust=_trusted(binding)) == "opaud-77b0c4e91d3a5628ff0e1a42" assert _seen(script)["argv"] == ["revoke", "--reference", FAKE_REFERENCE] - assert provider_mod.list_references(binding) == [] + assert provider_mod.list_references(binding, trust=_trusted(binding)) == [] assert _seen(script)["argv"] == ["list"] assert _seen(script)["stdin"] is None def test_the_minted_token_redacts_itself_in_every_rendering(tmp_path): script = _write_broker(tmp_path) - minted = provider_mod.mint(_broker_binding(script)) + minted = provider_mod.mint(_broker_binding(script), trust=_trusted(_broker_binding(script))) for rendering in (repr(minted), str(minted), f"{minted}", "%s" % (minted,)): assert SENTINEL_TOKEN not in rendering assert "" in rendering @@ -904,7 +948,7 @@ def _port(tmp_path, *outcomes, expires=None, notice=None, clock=time.time, port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), runner=runner, opener=opener, clock=clock, - notice=notice if notice is not None else (lambda _text: None)) + notice=notice if notice is not None else (lambda _text: None), trust=_trusted(binding)) return port, opener @@ -1083,7 +1127,7 @@ def test_a_broker_that_exits_non_zero_is_a_fixed_refusal(tmp_path): script.write_text("import sys\nsys.stderr.write('broker internals')\n" "sys.exit(3)\n", encoding="utf-8") with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.mint(_broker_binding(script)) + provider_mod.mint(_broker_binding(script), trust=_trusted(_broker_binding(script))) assert caught.value.diagnostic == provider_mod.DIAG_BROKER_REFUSED assert "broker internals" not in str(caught.value) @@ -1111,7 +1155,7 @@ def test_a_broker_that_refuses_before_reading_stdin_reads_as_a_refusal( binding = _broker_binding(script, auth_kind="oauth") big = io.StringIO("x" * 4_000_000) with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.hand_off_credential(binding, big) + provider_mod.hand_off_credential(binding, big, trust=_trusted(binding)) assert caught.value.diagnostic == provider_mod.DIAG_BROKER_REFUSED, \ "the exit code is the answer, not the write error" assert caught.value.diagnostic != provider_mod.DIAG_BROKER_UNREACHABLE @@ -1122,7 +1166,7 @@ def test_a_broker_that_cannot_be_started_is_still_unreachable(tmp_path): does not exist is NOT a refusal, and keeps its own sentence.""" binding = _binding(broker_argv=(str(tmp_path / "no-such-broker"),)) with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.mint(binding) + provider_mod.mint(binding, trust=_trusted(binding)) assert caught.value.diagnostic == provider_mod.DIAG_BROKER_UNREACHABLE @@ -1130,7 +1174,7 @@ def test_a_broker_that_answers_garbage_is_a_fixed_refusal(tmp_path): script = tmp_path / "garbled-broker.py" script.write_text("import sys\nprint('not json')\n", encoding="utf-8") with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.mint(_broker_binding(script)) + provider_mod.mint(_broker_binding(script), trust=_trusted(_broker_binding(script))) assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED @@ -1182,6 +1226,7 @@ def test_a_broker_that_has_refused_marks_the_catalog_unavailable(tmp_path): measured. A failure is never reported as an empty result.""" port, _opener = _port(tmp_path) port._binding = _binding(broker_argv=(str(tmp_path / "absent"),)) + port._trust = _trusted(port._binding) with pytest.raises(provider_mod.BrokerRefused): port.dispatch(_Envelope()) catalog = port.catalog() @@ -1221,6 +1266,9 @@ def test_a_declared_binding_resolves_the_brokered_port_instead(tmp_path): script = _write_broker(tmp_path) binding_mod.BindingStore(binding_mod.bindings_path(checkout)).add( _broker_binding(script)) + # Trusted on this machine (#1144 16.3a): a binding written by hand is + # refused until it is, which tests/test_model_binding_trust.py holds. + _trust_in_place(_broker_binding(script), checkout) resolve = install_mod.declared_model_port_factory( tmp_path / "sessions", checkout_root=checkout) port = resolve() @@ -1297,7 +1345,7 @@ def test_the_transport_really_speaks_to_an_endpoint_over_a_socket(tmp_path): binding = _broker_binding(script, endpoint=endpoint) port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), - notice=lambda _text: None) + notice=lambda _text: None, trust=_trusted(binding)) assert port.dispatch(_Envelope()) == { "assistant_prose": "answered over a socket", "proposals": []} finally: @@ -1328,7 +1376,7 @@ def test_the_broker_child_inherits_no_credential_shaped_environment(tmp_path, "'expires_in_seconds':300,'scope':[],'issued_by':'i'," "'approved_by':'a','audit_ref':'opaud-x','retry_of':None," "'enforcement':{}}))\n", encoding="utf-8") - provider_mod.mint(_broker_binding(script)) + provider_mod.mint(_broker_binding(script), trust=_trusted(_broker_binding(script))) inherited = json.loads( Path(str(script) + ".env.json").read_text(encoding="utf-8")) assert "SENTINEL_PROVIDER_API_KEY" not in inherited @@ -1372,7 +1420,7 @@ def test_the_broker_answer_is_bounded(tmp_path): script.write_text(f"import sys\nsys.stdout.write('x' * {size})\n", encoding="utf-8") with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.mint(_broker_binding(script)) + provider_mod.mint(_broker_binding(script), trust=_trusted(_broker_binding(script))) assert caught.value.diagnostic == expected @@ -1592,7 +1640,7 @@ def test_a_chat_turn_reaches_a_stand_in_chat_completions_server(tmp_path): dialect=OPENAI_CHAT) port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), - notice=lambda _text: None) + notice=lambda _text: None, trust=_trusted(binding)) assert port.dispatch(_Envelope()) == { "assistant_prose": "answered in the chat grammar", "proposals": []} @@ -1782,7 +1830,9 @@ def run(*argv) -> int: capsys.readouterr() assert store.get("local-chat").model == DECLARED_MODEL assert run("model-binding", "list", *root) == 0 - assert f"model {DECLARED_MODEL}" in capsys.readouterr().out + # `list` prints each value in its JSON spelling (T100: escaped) + assert f"model {json.dumps(DECLARED_MODEL)}" in \ + capsys.readouterr().out assert run("model-binding", "edit", *root, *declaration, "--model", "another-model", "--", "openprofiler-broker") == 0 @@ -1813,7 +1863,7 @@ def test_a_stand_in_chat_server_receives_the_declared_model(tmp_path): dialect=OPENAI_CHAT, model=DECLARED_MODEL) provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), - notice=lambda _text: None).dispatch(_Envelope()) + notice=lambda _text: None, trust=_trusted(binding)).dispatch(_Envelope()) assert _ChatCompletionsHandler.seen["body"] == { "model": DECLARED_MODEL, "messages": [{"role": "user", "content": "assembled prompt"}]} @@ -2168,7 +2218,7 @@ def test_a_binding_no_broker_answers_has_no_broker_operation(): provider_mod.broker_operation_argv(binding, operation) stdin = io.StringIO("x") with pytest.raises(AssertionError): - provider_mod.hand_off_credential(binding, stdin) + provider_mod.hand_off_credential(binding, stdin, trust=_trusted(binding)) # --- a built-in credential travels by a private route -------------------- @@ -2294,14 +2344,14 @@ def test_the_resolver_reads_nothing_for_a_route_that_is_not_private( def test_the_resolver_reads_a_key_for_a_private_route(): - """The control for the case above: the same shape on IPv6 loopback is - read.""" - shaped = types.SimpleNamespace(id="undeclared", - credential_ref=f"env:{ENV_NAME}", - endpoint="http://[::1]:8080/v1") + """The control for the case above: the same reference on IPv6 loopback + is read. A declared binding since #1144 16.3a, because the resolver now + also reads only for a binding a trust verdict covers, and a verdict + covers a declared binding alone.""" + shaped = _built_in_binding(endpoint="http://[::1]:8080/v1") environ = _RecordingEnviron({ENV_NAME: KEY_SENTINEL}) assert provider_mod.resolve_credential_reference( - shaped, environ=environ) == KEY_SENTINEL + shaped, environ=environ, trust=_trusted(shaped)) == KEY_SENTINEL assert environ.read == [ENV_NAME] @@ -2326,7 +2376,7 @@ def test_a_broker_reference_in_a_built_in_form_is_malformed(tmp_path): binding = _broker_binding(script) stdin = io.StringIO("x") with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.hand_off_credential(binding, stdin) + provider_mod.hand_off_credential(binding, stdin, trust=_trusted(binding)) assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED @@ -2370,7 +2420,7 @@ def _unbrokered_port(binding, *outcomes, environ=None, keyring_backend=None, binding, install_mod.brokered_catalog(binding), runner=_refusing_runner, opener=opener, notice=notice if notice is not None else (lambda _text: None), - environ=environ, keyring_backend=keyring_backend) + environ=environ, keyring_backend=keyring_backend, trust=_trusted(binding)) return port, opener @@ -2438,7 +2488,7 @@ def test_a_value_outside_latin_1_is_refused_before_any_header_is_built( port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), runner=_refusing_runner, notice=lambda _text: None, - environ={ENV_NAME: f"{KEY_SENTINEL}\u20ac"}) + environ={ENV_NAME: f"{KEY_SENTINEL}\u20ac"}, trust=_trusted(binding)) envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: port.dispatch(envelope) @@ -2613,7 +2663,7 @@ def test_a_stand_in_server_sees_the_resolved_bearer_or_no_header(which, port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), runner=_refusing_runner, notice=lambda _text: None, - environ={ENV_NAME: KEY_SENTINEL}) + environ={ENV_NAME: KEY_SENTINEL}, trust=_trusted(binding)) assert port.dispatch(_Envelope())["assistant_prose"] == \ "answered in the chat grammar" assert _ChatCompletionsHandler.seen["authorization"] == expected @@ -2704,7 +2754,7 @@ def test_a_built_in_credential_follows_no_redirect(monkeypatch, code): port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), runner=_refusing_runner, notice=lambda _text: None, - environ={ENV_NAME: KEY_SENTINEL}) + environ={ENV_NAME: KEY_SENTINEL}, trust=_trusted(binding)) envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: port.dispatch(envelope) @@ -2726,7 +2776,7 @@ def test_a_built_in_credential_over_http_to_this_host_uses_no_proxy( port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), runner=_refusing_runner, notice=lambda _text: None, - environ={ENV_NAME: KEY_SENTINEL}) + environ={ENV_NAME: KEY_SENTINEL}, trust=_trusted(binding)) answer = port.dispatch(_Envelope()) assert answer["assistant_prose"] == "answered in the chat grammar" assert _ChatCompletionsHandler.seen["authorization"] == ( @@ -2792,7 +2842,7 @@ def test_a_refused_connection_keeps_no_frame_that_holds_the_key(monkeypatch): endpoint=f"http://127.0.0.1:{closed}/v1/chat/completions") port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), - runner=_refusing_runner, notice=lambda _text: None) + runner=_refusing_runner, notice=lambda _text: None, trust=_trusted(binding)) envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: port.dispatch(envelope) @@ -2917,7 +2967,7 @@ def run(*argv) -> int: from opendox import cli_model_binding as cmb assert f"credential ref {cmb.NOT_DECLARED}" in listed assert f"broker argv {cmb.NOT_DECLARED}" in listed - assert f"credential ref env:{ENV_NAME}" in listed + assert f"credential ref {json.dumps(f'env:{ENV_NAME}')}" in listed assert run("model-binding", "remove", *root, "--id", "env-bound") == 0 assert binding_mod.BUILT_IN_REMOVAL_NOTICE in capsys.readouterr().out @@ -3002,7 +3052,7 @@ def test_mint_asks_no_broker_for_a_token_on_a_route_that_is_not_private( binding = _broker_binding(script) object.__setattr__(binding, "endpoint", "http://api.example.invalid/v1") with pytest.raises(AssertionError) as caught: - provider_mod.mint(binding) + provider_mod.mint(binding, trust=_trusted(binding)) assert "nothing was minted" in str(caught.value) assert _seen_all(script) == [], "the broker was never asked" @@ -3033,7 +3083,7 @@ def _minting_port(tmp_path, endpoint, *, token=SENTINEL_TOKEN): endpoint=endpoint, dialect=OPENAI_CHAT) return provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), - notice=lambda _text: None) + notice=lambda _text: None, trust=_trusted(binding)) def _refused_turn(port) -> provider_mod.BrokerRefused: @@ -3232,7 +3282,7 @@ def _broker_answering(tmp_path, text: str) -> Path: def test_the_declared_mint_answer_mints(tmp_path): """The control for the case below: this answer, unchanged, mints.""" script = _broker_answering(tmp_path, json.dumps(_mint_answer())) - assert provider_mod.mint(_broker_binding(script)).token == SENTINEL_TOKEN + assert provider_mod.mint(_broker_binding(script), trust=_trusted(_broker_binding(script))).token == SENTINEL_TOKEN #: A mint answer nested past the interpreter's recursion limit, and still well @@ -3271,7 +3321,7 @@ def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( "a case for the answer's parser, not for the runner's bound" binding = _broker_binding(_broker_answering(tmp_path, text)) with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.mint(binding) + provider_mod.mint(binding, trust=_trusted(binding)) assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED assert caught.value.__cause__ is None assert caught.value.__context__ is None @@ -3432,13 +3482,13 @@ def _children_kept_by(exception) -> list[str]: provider_mod.OPERATION_INTAKE: lambda binding, runner: ( provider_mod.hand_off_credential( binding, io.StringIO("sk-stand-in-intake-NOT-A-KEY"), - runner=runner)), + runner=runner, trust=_trusted(binding))), provider_mod.OPERATION_MINT: lambda binding, runner: ( - provider_mod.mint(binding, runner=runner)), + provider_mod.mint(binding, runner=runner, trust=_trusted(binding))), provider_mod.OPERATION_REVOKE: lambda binding, runner: ( - provider_mod.revoke(binding, runner=runner)), + provider_mod.revoke(binding, runner=runner, trust=_trusted(binding))), provider_mod.OPERATION_LIST: lambda binding, runner: ( - provider_mod.list_references(binding, runner=runner)), + provider_mod.list_references(binding, runner=runner, trust=_trusted(binding))), } @@ -3567,7 +3617,9 @@ def test_a_credential_source_that_fails_leaves_no_broker_running(tmp_path): " return self.parts.pop()\n" " raise UnicodeDecodeError('utf-8', b'x', 0, 1, 'stand-in')\n" f"binding = b.ModelProviderBinding(**{fields!r})\n" - "p.hand_off_credential(binding, Failing())\n") + "from opendox import doxbench_trust as t\n" + "p.hand_off_credential(binding, Failing(), " + "trust=t.TrustVerdict.trusted_for(binding, root=None, basis='test'))\n") run = subprocess.run([sys.executable, "-c", program], cwd=tmp_path, capture_output=True, text=True, timeout=60, check=False) @@ -3799,7 +3851,7 @@ def test_a_broker_that_never_reads_the_credential_is_refused_in_time( def run(): try: provider_mod.hand_off_credential( - binding, io.StringIO("k" * 1_000_000), runner=runner) + binding, io.StringIO("k" * 1_000_000), runner=runner, trust=_trusted(binding)) except provider_mod.BrokerRefused as refusal: caught.append(refusal) @@ -3847,7 +3899,7 @@ def test_a_failing_credential_source_escapes_with_no_broker_output(tmp_path): source = _SourceFailingOnceMarked(Path(str(script) + ".wrote")) binding = _broker_binding(script) with pytest.raises(UnicodeDecodeError) as caught: - provider_mod.hand_off_credential(binding, source) + provider_mod.hand_off_credential(binding, source, trust=_trusted(binding)) assert _wrote(script), "the broker wrote before the source failed" assert _kept_anywhere(caught.value, SENTINEL_TOKEN) == [] pid = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) @@ -3899,6 +3951,7 @@ def test_the_operator_door_names_the_operation_and_withholds_the_answer( (checkout / "ideation" / "dashboard").mkdir(parents=True) store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) store.add(_broker_binding(script, credential_ref="opref-" + "0" * 24)) + _trust_in_place(store.get("openprofiler-demo"), checkout) args = cli_mod.build_parser().parse_args([ "model-binding", "set-credential", "--repo-root", str(checkout), "--id", "openprofiler-demo"]) diff --git a/tests/test_openprofiler_broker_e2e.py b/tests/test_openprofiler_broker_e2e.py index dfe1c268..65008b49 100644 --- a/tests/test_openprofiler_broker_e2e.py +++ b/tests/test_openprofiler_broker_e2e.py @@ -55,6 +55,14 @@ from opendox import doxbench_binding as binding_mod from opendox import doxbench_install as install_mod from opendox import doxbench_provider as provider_mod +from opendox import doxbench_trust as trust_mod + + +def _trusted(binding): + """A verdict trusting exactly `binding` (#1144 16.3a; plan 034 T100): the + provider acts on a binding only when a verdict covers it.""" + return trust_mod.TrustVerdict.trusted_for(binding, root=None, + basis="test") #: The credential this test enrols. A SENTINEL: long, unique, and impossible to #: produce by accident, so a sweep that finds it has found the real thing. On @@ -260,7 +268,7 @@ def test_an_oauth_intake_is_refused_before_the_secret_is_read(binding, oauth = dataclasses.replace(binding, auth_kind="oauth") with pytest.raises(provider_mod.BrokerRefused) as caught: provider_mod.hand_off_credential( - oauth, io.StringIO("x" * 4_000_000)) + oauth, io.StringIO("x" * 4_000_000), trust=_trusted(oauth)) assert caught.value.diagnostic == provider_mod.DIAG_BROKER_REFUSED assert caught.value.diagnostic != provider_mod.DIAG_BROKER_UNREACHABLE # and nothing was taken into custody @@ -282,7 +290,7 @@ def test_the_whole_custody_lifecycle_against_the_real_broker(binding, # --- INTAKE: the credential crosses to the broker and nothing else ------ started = time.time() reference = provider_mod.hand_off_credential( - binding, io.StringIO(SENTINEL_SECRET)) + binding, io.StringIO(SENTINEL_SECRET), trust=_trusted(binding)) assert reference.startswith("opref-"), reference assert len(reference) == len("opref-") + 24 held = dataclasses.replace(binding, credential_ref=reference) @@ -305,7 +313,7 @@ def test_the_whole_custody_lifecycle_against_the_real_broker(binding, assert SENTINEL_SECRET not in json.dumps(intake_records) # --- MINT: the token, its expiry, and the audit reference --------------- - minted = provider_mod.mint(held) + minted = provider_mod.mint(held, trust=_trusted(held)) # On the api_key path the declaration is explicit: the minted token IS the # stored key, verbatim. It says so rather than burying it, and this asserts # the consumer is not being handed something else. @@ -329,7 +337,7 @@ def test_the_whole_custody_lifecycle_against_the_real_broker(binding, opener = _Opener(_expired_error(), {"assistant_prose": "the retried answer"}) port = provider_mod.BrokeredProviderPort( held, install_mod.brokered_catalog(held), - opener=opener, notice=printed.append) + opener=opener, notice=printed.append, trust=_trusted(held)) assert port.dispatch(_Envelope()) == { "assistant_prose": "the retried answer", "proposals": []} assert len(opener.requests) == 2, "exactly one paid retry" @@ -369,7 +377,7 @@ def test_the_whole_custody_lifecycle_against_the_real_broker(binding, aged = provider_mod.BrokeredProviderPort( held, install_mod.brokered_catalog(held), opener=_Opener({"assistant_prose": "a"}, {"assistant_prose": "b"}), - clock=lambda: far_future, notice=lambda _text: None) + clock=lambda: far_future, notice=lambda _text: None, trust=_trusted(held)) aged.dispatch(_Envelope()) aged.dispatch(_Envelope()) assert [event.reason for event in aged.ledger] == [ @@ -381,15 +389,15 @@ def test_the_whole_custody_lifecycle_against_the_real_broker(binding, if record["event"] == "mint"][-2:] == [None, None] # --- LIST: the non-secret index, which never opens a custody file ------- - listed = provider_mod.list_references(held) + listed = provider_mod.list_references(held, trust=_trusted(held)) assert [entry["reference"] for entry in listed] == [reference] assert listed[0]["binding"] == binding.id assert SENTINEL_SECRET not in json.dumps(listed) # --- REVOKE: custody is destroyed and the trail survives ---------------- - revocation_ref = provider_mod.revoke(held) + revocation_ref = provider_mod.revoke(held, trust=_trusted(held)) assert revocation_ref.startswith("opaud-") - assert provider_mod.list_references(held) == [] + assert provider_mod.list_references(held, trust=_trusted(held)) == [] # THE SENTINEL IS NOW NOWHERE — not in the store, not in the audit trail # that outlives it, and not anywhere else this test wrote. @@ -402,7 +410,7 @@ def test_the_whole_custody_lifecycle_against_the_real_broker(binding, # --- and a mint against a revoked reference refuses --------------------- with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.mint(held) + provider_mod.mint(held, trust=_trusted(held)) assert caught.value.diagnostic == provider_mod.DIAG_BROKER_REFUSED assert reference not in str(caught.value), \ "the refusal is fixed and redacted; the broker's own words are dropped" From e5451652c1543fdf315df0bd03b9afed534d324c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:17:33 +0000 Subject: [PATCH 32/45] T100: the rail's trust remedy has its Python twin, and its case mounts the rail doxbench_trust.UNTRUSTED_BINDING_REMEDY is the sentence the chat rail shows when the catalog lists a declared model and none is available (RULED openxFactory#656 comment 5962785556, item 2, "make the rail say how to trust"). The rail's own line sits beside openDox-code#74's no-model line, which is not on this base, so the case stays strict-xfail naming #74; it now mounts the rail under node, as #74's own rail tests do, and holds the line to its posture (shown for a declared model none of which is available, announced once, and 16.4's no-model line hidden), rather than grepping the source. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 16 ++++ tests/test_model_binding_trust.py | 150 ++++++++++++++++++++++++++++-- 2 files changed, 158 insertions(+), 8 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 8b8771ae..0aa0ab16 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -209,6 +209,22 @@ "restart this console") +#: What the chat rail says when the catalog lists a declared model and none is +#: available (#1144 16.3a; RULED openxFactory#656 comment 5962785556, item 2, +#: "make the rail say how to trust"). The catalog's wire shape is closed, so +#: the rail cannot say WHICH binding or why: it sends the operator to +#: `model-binding list`, which says why for each binding (not trusted here, or +#: a broker refusal), and names the verb that trusts one. The rail's JavaScript +#: twin, `UNTRUSTED_BINDING_REMEDY` in `web/views/doxbench-chat.js`, beside +#: openDox-code#74's no-model line, is held to this spelling by +#: `tests/test_model_binding_trust.py`. +UNTRUSTED_BINDING_REMEDY = ( + "No declared model is available. \"opendox model-binding list\" says why " + "for each binding; one read from this repository is used only once this " + "machine trusts it, which \"opendox model-binding trust \" records " + "after showing what it runs and where its credential goes. Then restart " + "this console.") + #: What the console intake's hand-off is refused with when the trust policy #: does not admit the binding it is declaring (#1144 16.3a, T007 batch M). A #: FIXED sentence: an intake refusal's reason never carries what the request diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index cca976c3..a2545834 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -1277,23 +1277,157 @@ def test_with_no_state_directory_nothing_is_trusted(tmp_path, monkeypatch): policy.record(_a_binding(), root=tmp_path) -_RAIL = REPO_ROOT / "src" / "opendox" / "web" / "views" / "doxbench-chat.js" +_VIEWS = REPO_ROOT / "src" / "opendox" / "web" / "views" +_RAIL = _VIEWS / "doxbench-chat.js" _HAS_NO_MODEL_RAIL = "NO_MODEL_CONFIGURED_REMEDY" in _RAIL.read_text( encoding="utf-8") +#: The rail, mounted under node over a minimal DOM (the shim +#: openDox-code#74's tests/test_chat_model_configuration.py mounts it with), +#: once per catalog posture. +_TRUST_RAIL_HARNESS = r""" +class Node { + constructor(tag) { + this.tagName = String(tag).toUpperCase(); + this.children = []; this.attributes = {}; this.listeners = {}; + this.className = ''; this._text = ''; this.hidden = false; + this.disabled = false; this.value = ''; this.writes = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(''); + } + set textContent(value) { + this.children = []; this._text = String(value); this.writes.push(this._text); + } + appendChild(child) { child.parentNode = this; this.children.push(child); return child; } + append(...kids) { for (const k of kids) this.appendChild(k); } + setAttribute(name, value) { this.attributes[name] = String(value); } + getAttribute(name) { + return Object.prototype.hasOwnProperty.call(this.attributes, name) + ? this.attributes[name] : null; + } + addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); } + focus() {} + walk() { return this.children.reduce((a, c) => a.concat(c.walk()), [this]); } +} +const doc = { createElement: (tag) => new Node(tag), activeElement: null }; +const byClass = (root, cls) => root.walk().filter( + (n) => String(n.className).split(' ').includes(cls)); + +import { mountDoxBenchChatRail, UNTRUSTED_BINDING_REMEDY, + untrustedBindingRemedy } from "./doxbench-chat.mjs"; + +const KEY = { repository: "fixture", ref: "main", tile_kind: "staged", + tile_id: "a-topic" }; +const ENTRY = { model_id: "helpful-model", label: "Helpful model", + provider_class: "brokered", available: true, input_limit_bytes: 800000, + output_limit_bytes: 900000, data_handling: "sent to the provider" }; +const OFF = { ...ENTRY, available: false }; +const bufferOf = (kind, path) => ({ kind, path, base_ref: "main", + base_revision: "r1", base_hash: { algorithm: "sha256", hex: "c".repeat(64) }, + current_hash: { algorithm: "sha256", hex: "d".repeat(64) }, + hash_pending: false, content: "# " + kind, dirty: false }); +const editorState = () => ({ active_buffer: "document", buffers: { + outline: bufferOf("outline", "docs/outline.md"), + document: bufferOf("document", "docs/detail.md") } }); +const catalogOf = (models) => () => (models === null ? null + : { schema_version: 1, kind: "workbench-model-catalog", models }); + +async function mount(catalog, { intake = null } = {}) { + const host = new Node("div"); host.ownerDocument = doc; + let turns = 0; + const rail = mountDoxBenchChatRail(host, { + scopeKey: KEY, + transports: { catalog: async () => catalog(), + chatTurn: async () => { turns += 1; return null; } }, + editorState }); + await rail.ready; + if (intake !== null) rail.intakeOffer(intake); + const shownBy = (cls) => { + const line = byClass(host, cls)[0] || null; + return (line && !line.hidden) ? line.textContent : null; + }; + const announce = byClass(host, "doxchat-announce")[0]; + const composer = byClass(host, "doxchat-composer")[0]; + for (const value of ["w", "wh"]) { + composer.value = value; + for (const fn of composer.listeners.input || []) fn({ target: composer }); + } + return { shown: shownBy("doxchat-untrusted"), + noModel: shownBy("doxchat-no-model"), turns, + announced: announce.writes.filter( + (text) => text === UNTRUSTED_BINDING_REMEDY).length, + sendDisabled: byClass(host, "doxchat-send")[0].disabled === true }; +} + +const out = { + remedy: UNTRUSTED_BINDING_REMEDY, + onlyUnavailable: await mount(catalogOf([OFF])), + empty: await mount(catalogOf([])), + available: await mount(catalogOf([ENTRY])), + oneOfTwoAvailable: await mount(catalogOf([OFF, { ...ENTRY, + model_id: "another-model" }])), + unreadable: await mount(catalogOf(null)), + intakeOffered: await mount(catalogOf([OFF]), { intake: true }), + pure: { + loading: untrustedBindingRemedy({ models: null, catalogFailure: null }), + staleToken: untrustedBindingRemedy( + { models: [OFF], catalogFailure: "console_required" }), + }, +}; +process.stdout.write(JSON.stringify(out)); +""" + @pytest.mark.xfail(not _HAS_NO_MODEL_RAIL, strict=True, reason="the rail's visible no-model line is " "openDox-code#74's (T081), which is not on this " "base; the trust remedy sits beside it") -def test_the_rail_says_how_to_trust_a_declared_binding(): - """With a declared binding and none available, the rail must not say "no - model configured": it names `model-binding list` (which says why) and - `model-binding trust`.""" +def test_the_rail_says_how_to_trust_a_declared_binding(tmp_path): + """RULED "make the rail say how to trust" (5962785556, item 2). With a + declared model in the catalog and none available, the rail shows its own + visible line, announced once, naming `model-binding list` (which says why + for each binding) and `model-binding trust`; and 16.4's "no model + configured" line stays hidden, because a model IS configured. Not while + loading, not with any model available, not on a catalog failure (each has + its own remedy), and not where a host offers intake (its own remedy's + home, as for 16.4's line).""" + import re + import shutil as shutil_mod + source = _RAIL.read_text(encoding="utf-8") - assert "UNTRUSTED_BINDING_REMEDY" in source - assert '\\"opendox model-binding list\\"' in source - assert '\\"opendox model-binding trust \\"' in source + match = re.search( + r'export const UNTRUSTED_BINDING_REMEDY =\s*("(?:[^"\\]|\\.)*");', + source) + assert match, "the rail declares UNTRUSTED_BINDING_REMEDY as one literal" + assert json.loads(match.group(1)) == _trust_mod().UNTRUSTED_BINDING_REMEDY + node = shutil_mod.which("node") + if node is None: + pytest.skip("node not available for the chat rail's trust probe") + (tmp_path / "doxbench-chat.mjs").write_text(source.replace( + "./doxbench-chat-model.js", "./doxbench-chat-model.mjs"), + encoding="utf-8") + shutil_mod.copy(_VIEWS / "doxbench-chat-model.js", + tmp_path / "doxbench-chat-model.mjs") + (tmp_path / "harness.mjs").write_text(_TRUST_RAIL_HARNESS, + encoding="utf-8") + done = subprocess.run([node, str(tmp_path / "harness.mjs")], + capture_output=True, text=True, timeout=60, + env=_clean_env()) + assert done.returncode == 0, done.stderr + rail = json.loads(done.stdout) + remedy = _trust_mod().UNTRUSTED_BINDING_REMEDY + assert rail["remedy"] == remedy + shown = rail["onlyUnavailable"] + assert shown["shown"] == remedy + assert shown["noModel"] is None, "a declared model is not 'no model'" + assert shown["announced"] == 1, "announced once, not on every keystroke" + assert shown["sendDisabled"] is True and shown["turns"] == 0 + for case in ("empty", "available", "oneOfTwoAvailable", "unreadable", + "intakeOffered"): + assert rail[case]["shown"] is None, case + assert rail[case]["announced"] == 0, case + assert rail["pure"] == {"loading": None, "staleToken": None} _TURN_REACHES_ITS_MODEL_STEP = importlib.util.find_spec( From a539bb6709cf7354cfc101f8119817d1f7bd0fd1 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:18:41 +0000 Subject: [PATCH 33/45] T100: the rail probe offers intake before the catalog answers, as a host does Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_binding_trust.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index a2545834..a7751d2f 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -1336,13 +1336,17 @@ class Node { async function mount(catalog, { intake = null } = {}) { const host = new Node("div"); host.ownerDocument = doc; let turns = 0; + let release; + const gate = new Promise((res) => { release = res; }); const rail = mountDoxBenchChatRail(host, { scopeKey: KEY, - transports: { catalog: async () => catalog(), + transports: { catalog: async () => { await gate; return catalog(); }, chatTurn: async () => { turns += 1; return null; } }, editorState }); - await rail.ready; + // a host offering intake offers it before the catalog answers if (intake !== null) rail.intakeOffer(intake); + release(); + await rail.ready; const shownBy = (cls) => { const line = byClass(host, cls)[0] || null; return (line && !line.hidden) ? line.textContent : null; From c73cbea2b4d654c78712cff8e582e980e14e7f74 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:29:35 +0000 Subject: [PATCH 34/45] T100: the rail's trust sentence names no credential The chat view's source may not carry the word (tests/test_doxbench_privacy.py bans it from both chat modules), so the sentence its Python twin holds says where the binding would connect instead. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 0aa0ab16..0c91f0d5 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -222,8 +222,8 @@ "No declared model is available. \"opendox model-binding list\" says why " "for each binding; one read from this repository is used only once this " "machine trusts it, which \"opendox model-binding trust \" records " - "after showing what it runs and where its credential goes. Then restart " - "this console.") + "after showing what it would run and where it would connect. Then " + "restart this console.") #: What the console intake's hand-off is refused with when the trust policy #: does not admit the binding it is declaring (#1144 16.3a, T007 batch M). A From b8323d01313c4899ed418bc312b809cb4e4b3657 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:17:24 +0000 Subject: [PATCH 35/45] Broker path: L4's cases raise afresh there too, with no token kept The adversarial review's L4 is in both PRs. T080 (#63) maps an http.client.HTTPException in the shared transport to the unreachable sentence, and its case runs a broker's turn and pins that sentence only. On this branch every refusal of a request that carried a credential, the broker's minted token included, is raised afresh by _call_provider. So for a broker's turn the case now pins that too: no cause, no context, and neither the key nor the token in any frame the refusal keeps. With T080's transport (05cb1c70), where the broker path chains its cause, the six broker cases fail on that cause. With _call_provider re-raising every refusal with its cause, 19 cases fail. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_provider_broker.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 9d8f4a85..086d580c 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3155,9 +3155,10 @@ def test_an_answer_http_client_cannot_read_is_unreachable_and_keeps_no_key( THE TRANSPORT IS SHARED, so a broker's turn lands on the same sentence (Copilot's review of openDox-code#63 at `44582f8f`), where it escaped - before. That is the one change this PR makes to the broker path's - failure. Raising it afresh there is openDox-code#64's, as the ruling - leaves that path to it.""" + before. On this branch it is raised afresh there too, as every refusal + of a request that carried a credential is (`_call_provider`), so no + frame it keeps holds the minted token (the adversarial review's L4, for + openDox-code#64).""" answer, path = UNREADABLE_ANSWERS[raised] handler = type(f"_{raised}Answer", (_UnreadableAnswerHandler,), {"answer": answer}) @@ -3183,10 +3184,11 @@ def test_an_answer_http_client_cannot_read_is_unreachable_and_keeps_no_key( with pytest.raises(provider_mod.BrokerRefused) as caught: port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_UNREACHABLE - if resolver == "built-in": + if resolver != "none": assert caught.value.__cause__ is None assert caught.value.__context__ is None assert _locals_holding(caught.value, KEY_SENTINEL) == [] + assert _locals_holding(caught.value, SENTINEL_TOKEN) == [] def test_an_unpresentable_value_leaves_no_frame_that_holds_it(monkeypatch): From e313437b584e08535d103baeb60aac45ef9b6c76 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:17:24 +0000 Subject: [PATCH 36/45] Broker runner: a refused answer kills what is left of the broker's group too The holder's answer on openDox-code#64 (2026-10-02): EVERY refusal kills what is left of the broker's process group, a refusal of the answer of a broker that exited 0 among them, and a successful answer leaves the group alone, since a broker may leave a helper running on purpose. Measured at f8bc8aca: a broker that left a helper in its group and exited 0 was reaped as its output ended, its answer was refused after, and the helper went on running. Now the subprocess runner is given the operation's reader (_run_broker, _answer_of and _settled take read=), and _settled reads the answer while the broker is a zombie, its exit read with WNOWAIT. A refusal of the answer kills the group and then reaps the broker (_reap). A successful answer reaps the broker alone. _broker_operation hands its reader to the subprocess runner and raises a refusal afresh, naming the operation, as before. A runner injected in its place, such as a test's, is given the argv alone, as before. Cases, per operation: a refused answer from such a broker kills the helper, with the group signalled once while the broker is a zombie, and keeps nothing; a successful answer from a broker that leaves a helper signals no group and the helper still runs. Mutants killed: a refused answer reaped without the group kill, a successful answer killing the group, and the in-place read unused. _answer_of is not refactored (the holder's answer, Q3); it passes read through and its complexity is unchanged. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 60 +++++++++++++++---- tests/test_model_provider_broker.py | 90 +++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 12 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 586cf20e..70b77462 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -570,12 +570,13 @@ def _close_quietly(stream) -> None: pass -def _run_broker(argv, *, source, - timeout: float) -> tuple[str | None, str | None]: +def _run_broker(argv, *, source, timeout: float, + read=None) -> tuple[object | None, str | None]: """The work of `subprocess_broker_runner`: `(answer, None)`, or `(None, sentence)` for a refusal. It raises no refusal itself, so no refusal keeps its frame, which holds the child and what the child - wrote. + wrote. Given `read`, the answer is `read`'s reading of it, made while + the broker is still unreaped (`_settled`). THE BOUND IS A BOUND ON WHAT IS READ (Copilot's review of openDox-code#64 at `25788f91`). At most one byte past @@ -613,7 +614,8 @@ def _run_broker(argv, *, source, return None, DIAG_BROKER_UNREACHABLE received: list[bytes] = [] try: - return _answer_of(child, received, source=source, timeout=timeout) + return _answer_of(child, received, source=source, timeout=timeout, + read=read) except BaseException: # Anything else that escapes, such as the credential's own source # failing while it is copied (the operator's input, not the broker's @@ -625,8 +627,8 @@ def _run_broker(argv, *, source, raise -def _answer_of(child, received: list, *, source, - timeout: float) -> tuple[str | None, str | None]: +def _answer_of(child, received: list, *, source, timeout: float, + read=None) -> tuple[object | None, str | None]: """`_run_broker`'s work once the child is running: the credential, if any, streamed to its standard input, and its answer read, within the bound and the timeout.""" @@ -679,11 +681,11 @@ def _answer_of(child, received: list, *, source, if size > MAX_BROKER_ANSWER_BYTES: _reap(child) return None, DIAG_BROKER_OVERSIZE - return _settled(child, received, deadline) + return _settled(child, received, deadline, read=read) -def _settled(child, received: list, - deadline: float) -> tuple[str | None, str | None]: +def _settled(child, received: list, deadline: float, + read=None) -> tuple[object | None, str | None]: """The broker's answer once its output has ended. Its exit is read without reaping it, so a refusal can still kill what is left of its group (`_reap`). An answer is returned with the broker reaped. @@ -691,7 +693,15 @@ def _settled(child, received: list, A REFUSAL KILLS WHAT IS LEFT OF THE GROUP, here as at the timeout and the bound. The broker has exited, but a descendant still in its group would outlive it, one more for each such call (Copilot's review of - openDox-code#64 at `a271d307`).""" + openDox-code#64 at `a271d307`). + + EVERY REFUSAL DOES, a refused answer from a broker that exited 0 among + them (the holder's answer on openDox-code#64, 2026-10-02). So `read`, + when it is given, reads the answer here, while the broker is still + unreaped and its group's id still its own. A refusal of the answer + kills what is left of the group before the broker is reaped. A + successful answer leaves the group alone, since a broker may leave a + helper running on purpose.""" returncode = _exit_status_unreaped(child, deadline) if returncode is None: _reap(child) @@ -705,10 +715,23 @@ def _settled(child, received: list, except UnicodeDecodeError: _reap(child) return None, DIAG_BROKER_MALFORMED + answer = received.pop() + if read is not None: + try: + answer = read(answer) + except BrokerRefused as refusal: + failure = refusal.diagnostic + else: + failure = None + if failure is not None: + # What the broker wrote leaves this frame before the reap. + del answer + _reap(child) + return None, failure # It has exited, so this reaps it at once. child.wait() _close_quietly(child.stdout) - return received.pop(), None + return answer, None def _exit_status_unreaped(child, deadline: float) -> int | None: @@ -857,8 +880,21 @@ def _broker_operation(binding, operation: str, read, *, runner, misbehaves may write anything into it. `source` is given to the runner only when there is one, which is - `intake`'s case. Every other operation reads no standard input.""" + `intake`'s case. Every other operation reads no standard input. + + THE SUBPROCESS RUNNER READS THE ANSWER BEFORE THE BROKER IS REAPED (the + holder's answer on openDox-code#64, 2026-10-02), so a refusal of the + answer kills what is left of the broker's group too (`_settled`). A + runner injected in its place, such as a test's, is given the argv alone + and its answer is read here, as before.""" argv = broker_operation_argv(binding, operation, retry_of=retry_of) + if runner is subprocess_broker_runner: + result, failure = _run_broker(argv, source=source, + timeout=BROKER_TIMEOUT_SECONDS, + read=read) + if failure is None: + return result + raise BrokerRefused(failure, operation=operation) answer = None try: if source is None: diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 086d580c..eb990497 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -4168,6 +4168,96 @@ def recording_killpg(pgid, sig): os.kill(descendant, signal.SIGKILL) +def _a_broker_that_leaves_a_helper(tmp_path, then: str) -> Path: + """A broker that starts a helper in its own process group, holding none + of its pipes, records the helper's pid beside itself, and then runs + `then`.""" + script = tmp_path / "helper-leaving-broker.py" + script.write_text( + "import os, subprocess, sys\n" + "helper = subprocess.Popen(\n" + " [sys.executable, '-c', 'import time; time.sleep(20)'],\n" + " stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,\n" + " stderr=subprocess.DEVNULL)\n" + "open(sys.argv[0] + '.pid', 'w').write(str(helper.pid))\n" + then, + encoding="utf-8") + return script + + +def _recording_killpg(monkeypatch) -> list: + """`os.killpg`, recording the state of each group's leader as it is + signalled (`_process_state`).""" + signalled: list = [] + killpg = os.killpg + + def recording(pgid, sig): + signalled.append(_process_state(pgid)) + return killpg(pgid, sig) + + monkeypatch.setattr(os, "killpg", recording) + return signalled + + +@pytest.mark.parametrize("operation", provider_mod.OPERATIONS) +def test_a_refused_answer_kills_what_is_left_of_the_brokers_group( + tmp_path, monkeypatch, operation): + """The holder's answer on openDox-code#64 (2026-10-02): EVERY refusal + kills what is left of the broker's group, a refusal of the answer of a + broker that exited 0 among them. Measured at `f8bc8aca`: such a broker, + leaving a helper in its group, was reaped as it answered, its answer + was refused after, and the helper went on running. Its answer is read + now while the broker is unreaped, and the group is signalled once, + while the broker is a zombie.""" + signalled = _recording_killpg(monkeypatch) + answer = json.dumps({"schema_version": 1, "kind": "no-declared-kind", + "token": SENTINEL_TOKEN}) + script = _a_broker_that_leaves_a_helper( + tmp_path, f"sys.stdin.read()\nsys.stdout.write({answer!r})\n") + binding = _broker_binding(script) + with pytest.raises(provider_mod.BrokerRefused) as caught: + _OPERATIONS_ASKED[operation](binding, + provider_mod.subprocess_broker_runner) + helper = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) + try: + refusal = caught.value + assert refusal.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + assert refusal.operation == operation + assert refusal.__cause__ is None + assert refusal.__context__ is None + assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] + assert signalled == ["Z"], \ + "the group is signalled once, before the broker is reaped" + assert not _still_running(helper), "the helper was left running" + finally: + with contextlib.suppress(ProcessLookupError): + os.kill(helper, signal.SIGKILL) + + +@pytest.mark.parametrize("operation", provider_mod.OPERATIONS) +def test_a_successful_answer_leaves_the_brokers_group_alone( + tmp_path, monkeypatch, operation): + """The other half of the holder's answer: a broker may leave a helper + running on purpose, so an answer that is read without a refusal + signals no group. The broker here starts a helper and then becomes the + fake broker, in the same process and the same group.""" + signalled = _recording_killpg(monkeypatch) + broker = _write_broker(tmp_path) + script = _a_broker_that_leaves_a_helper( + tmp_path, f"os.execv(sys.executable, [sys.executable, " + f"{str(broker)!r}, *sys.argv[1:]])\n") + binding = _broker_binding(script, credential_ref=FAKE_REFERENCE) + _OPERATIONS_ASKED[operation](binding, + provider_mod.subprocess_broker_runner) + helper = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) + try: + assert signalled == [], "a successful answer signals no group" + assert _process_state(helper) not in (None, "Z"), \ + "the helper still runs" + finally: + with contextlib.suppress(ProcessLookupError): + os.kill(helper, signal.SIGKILL) + + def test_a_refusal_waits_on_a_killed_broker_only_so_long(monkeypatch): """Copilot's review of openDox-code#64 at `bbcb565e`. SIGKILL ends a broker at once unless it is stuck in uninterruptible I/O. Then the From 7a04590ddc451102bcf4122b8b6874191fb5cd03 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:08:13 +0000 Subject: [PATCH 37/45] T100: what a trust policy answers is held to the binding; the store locks across processes; the intake asks its own question Copilot's four findings at openDox-code#82 1ef4c71d, each accepted by the holder, each with a case that failed first (red-run-copilot-r1: 8 failed) and a mutant: - r4173513738: a policy may DECLINE to record trust (a governed host does for a pending declaration), answer for another binding, or raise. doxbench_trust.recorded_for holds its answer to the binding and refuses anything else BY NAME (TrustNotRecorded), naming what a policy raised and never its words. `add`, `edit` and `trust` ask it before they write, so they write nothing and never print "trusted". - r4173513761: MachineTrust.record holds an exclusive lock on model-binding-trust.lock beside the store (fcntl.flock) across its read, change and replace, so two processes recording at once keep both trusts and neither restores a form the other replaced. The lock file is opened without following a link and held to the store's own rules. Where no lock can be taken, record is refused by name and writes nothing. - r4173513782: the console intake asks a DISTINCT question, doxbench_trust.intake_verdict_for, which no binding's trust answers. MachineTrust.intake_verdict always answers no; a policy without one admits no intake; a host admits it only through its own intake_verdict. A repository that declares a binding with the intake's very fields, and has it trusted, gains nothing. - r4173513795: doxbench_trust.verdict_for holds every verdict to the binding asked about, so a verdict for another binding, trusted or not, is replaced by an untrusted one for THIS binding, and the notice, the refusal and `list` name it and its command. The factory, `list`, `set-credential` and the intake all go through these helpers. SonarCloud: the quality gate's one failure was python:S5332 on the trust disclosure's sentence, which spelled a plain-HTTP URL scheme; it now says "plain HTTP". The two functions it found over the cognitive-complexity bound (_unsafe_because, _refuse_an_unsafe_tree) are split into named parts, with the same rules. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/cli_model_binding.py | 32 ++-- src/opendox/doxbench_install.py | 21 +- src/opendox/doxbench_trust.py | 305 +++++++++++++++++++++++++----- src/opendox/serve_workbench.py | 25 ++- tests/test_model_binding_trust.py | 274 ++++++++++++++++++++++++++- 5 files changed, 558 insertions(+), 99 deletions(-) diff --git a/src/opendox/cli_model_binding.py b/src/opendox/cli_model_binding.py index 8f319924..8f313762 100644 --- a/src/opendox/cli_model_binding.py +++ b/src/opendox/cli_model_binding.py @@ -56,11 +56,14 @@ def _record_trust(binding: "binding_mod.ModelProviderBinding", args: argparse.Namespace): """Record trust for the binding this act writes (#1144 16.3a; RULED openxFactory#656 comment 5962785556, item 2): the operator declares it - here, so the operator trusts it. Returns the verdict. A store that cannot - record refuses (`doxbench_trust.TrustStoreRefused`, a `BindingRefused`), - and `add`, `edit` and `trust` ask BEFORE they write anything, so a refusal - leaves nothing written (T007 batch M).""" - return trust_mod.policy().record(binding, root=_repo_root(args)) + here, so the operator trusts it. Returns the verdict, which admits + exactly this binding. A store that cannot record, a policy that DECLINES + (as a governed host's does for a pending declaration) or answers for + another binding, and a policy that raises, are each refused by name, as + a `BindingRefused` (`doxbench_trust.recorded_for`). `add`, `edit` and + `trust` ask BEFORE they write anything, so a refusal leaves nothing + written (T007 batch M).""" + return trust_mod.recorded_for(binding, root=_repo_root(args)) def _trusted_line(binding: "binding_mod.ModelProviderBinding", verdict) -> str: @@ -146,18 +149,11 @@ def _trust_lines(store: "binding_mod.BindingStore", root = _repo_root(args) lines: dict[str, str] = {} for binding in store.list(): - try: - verdict = trust_mod.policy().verdict(binding, root=root) - except Exception as exc: # noqa: BLE001 - a policy that fails trusts nothing - lines[binding.id] = (f"NOT trusted on this machine (the trust " - f"policy failed: {type(exc).__name__})") - continue - if isinstance(verdict, trust_mod.TrustVerdict) and verdict.admits( - binding): + verdict = trust_mod.verdict_for(binding, root=root) + if verdict.admits(binding): lines[binding.id] = "trusted on this machine" else: - reason = (getattr(verdict, "reason", None) - or trust_mod.REASON_NOT_COVERED) + reason = verdict.reason or trust_mod.REASON_NEVER_TRUSTED lines[binding.id] = ( f"NOT trusted on this machine ({reason}); trust it with: " f"{trust_mod.trust_command(binding.id, str(root))}") @@ -255,7 +251,7 @@ def cmd_model_binding_set_credential(args: argparse.Namespace, *, != binding_mod.CREDENTIAL_FROM_BROKER): raise binding_mod.BindingRefused(NO_BROKER_TO_HAND_TO.format( binding_id=binding.id, custody=binding.custody_notice())) - verdict = trust_mod.policy().verdict(binding, root=_repo_root(args)) + verdict = trust_mod.verdict_for(binding, root=_repo_root(args)) trust_mod.require_admitted(binding, verdict) reference = provider_mod.hand_off_credential( binding, source if source is not None else sys.stdin, @@ -313,8 +309,8 @@ def _trust_disclosure(binding: "binding_mod.ModelProviderBinding", resolved_by = "nothing: this endpoint takes no credential" runs = [] goes = (f"to {shown(binding.endpoint)} alone, in each request's " - "authorization header, never through a redirect, and over " - "http:// through no proxy" + "authorization header, never through a redirect, and through no " + "proxy where the endpoint is plain HTTP" if source != binding_mod.NO_CREDENTIAL else "nowhere: the auth kind none presents no credential") reference = (shown(binding.credential_ref) diff --git a/src/opendox/doxbench_install.py b/src/opendox/doxbench_install.py index d0947e7c..0e44f912 100644 --- a/src/opendox/doxbench_install.py +++ b/src/opendox/doxbench_install.py @@ -328,25 +328,16 @@ def trust_gated_model_port_factory(binding, *, checkout_root: Path | str): name, and nothing is spawned, read or contacted. The refusal is said on stderr, naming the binding and the command that trusts it. - A policy that raises trusts nothing. Its words are not repeated: only the - class of what it raised is named.""" + The verdict is HELD TO THIS BINDING (`doxbench_trust.verdict_for`): a + policy that raises trusts nothing, and its words are not repeated; one + that answers for another binding, trusted or not, covers nothing, and the + refusal names THIS binding and its command.""" from opendox import doxbench_trust as trust_mod from opendox.doxbench_model import EMPTY_CATALOG, ModelCatalogError - try: - verdict = trust_mod.policy().verdict(binding, root=checkout_root) - except Exception as error: # noqa: BLE001 - a policy that fails trusts nothing - verdict = trust_mod.TrustVerdict.untrusted_for( - binding, root=checkout_root, basis=trust_mod.BASIS_HOST, - reason=f"the trust policy failed ({type(error).__name__})") - if isinstance(verdict, trust_mod.TrustVerdict) and verdict.admits(binding): + verdict = trust_mod.verdict_for(binding, root=checkout_root) + if verdict.admits(binding): return brokered_model_port_factory(binding, trust=verdict) - if not isinstance(verdict, trust_mod.TrustVerdict) or verdict.trusted: - # A verdict for another binding, or another form of it, covers - # nothing here. - verdict = trust_mod.TrustVerdict.untrusted_for( - binding, root=checkout_root, basis=trust_mod.BASIS_HOST, - reason=trust_mod.REASON_NOT_COVERED) sys.stderr.write("[model-provider] " + trust_mod.refusal_message( verdict.binding_id, verdict.root, verdict.reason or trust_mod.REASON_NEVER_TRUSTED) + "\n") diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 08aaecaf..536a8343 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -94,7 +94,9 @@ from __future__ import annotations +import contextlib import dataclasses +import errno import hashlib import json import os @@ -107,6 +109,11 @@ from opendox import doxbench_binding as binding_mod +try: # POSIX; where it is absent, nothing is recorded + import fcntl +except ImportError: # pragma: no cover - exercised through _lock_exclusively + fcntl = None + __all__ = [ "BASIS_HOST", "BASIS_MACHINE_TRUST", @@ -147,6 +154,13 @@ #: The store's file name, directly under the state directory. TRUST_FILENAME = "model-binding-trust.json" +#: The lock file beside the store. Every process that records trust holds an +#: exclusive lock on it across its read, its change and its replace of the +#: store, so two processes recording at once cannot lose either's trust, nor +#: restore a form another process replaced (Copilot at openDox-code#82, +#: r4173513761). +TRUST_LOCK_FILENAME = "model-binding-trust.lock" + #: The largest store this reads. A bound, not a policy: a store is a few #: hundred bytes a binding, and an unbounded read of a file is a way to spend #: this process's memory. @@ -189,6 +203,23 @@ "the trust verdict given for it covers another binding, or another form " "of it") +#: Why `MachineTrust` never admits the console intake's broker (#1144 16.3a, +#: T007 batch M; Copilot at openDox-code#82, r4173513782). The intake asks its +#: own question (`intake_verdict_for`), and no binding's trust answers it, so +#: a repository that declares a binding with the intake's very fields gains +#: nothing by having it trusted. +REASON_INTAKE_NOT_ADMITTED = ( + "the console intake runs a broker the served repository's declarations " + "document names, and openDox's per-machine trust admits no intake; only " + "a host's own policy can, by answering intake_verdict") + + +def reason_policy_failed(error: BaseException) -> str: + """Why a binding a policy failed to judge is untrusted. It names the class + of what the policy raised and never its words, which may hold anything.""" + return f"the trust policy failed ({type(error).__name__})" + + #: What the store refusal says when the runtime defines no state directory. #: That is this change's base before openDox-code#69 lands. Nothing can be #: trusted then, which is the fail-closed reading. @@ -253,6 +284,12 @@ class TrustStoreRefused(binding_mod.BindingRefused): Nothing is trusted through it.""" +class TrustNotRecorded(binding_mod.BindingRefused): + """The registered policy did not record trust for the binding asked + about: it declined, answered for another binding, or failed. `add`, + `edit` and `trust` refuse with it before they write anything.""" + + class TrustPolicyNotRegistered(RuntimeError): """Nothing is registered at the trust seam. Raised instead of answering a default, which is a registration a consumer makes (`policy()`).""" @@ -399,44 +436,147 @@ def require_admitted(binding, trust: TrustVerdict | None) -> None: trust.binding_id, trust.root, trust.reason or REASON_NEVER_TRUSTED)) +def _held_to(binding, verdict: Any, *, root: Path | str | None) -> TrustVerdict: + """`verdict`, held to `binding`. A verdict that admits exactly `binding` is + returned. So is an UNTRUSTED one for exactly this record, which carries + the policy's own reason. Anything else (a verdict for another binding, or + another form of this one, trusted or not, or something that is not a + verdict) is replaced by an untrusted verdict for THIS binding, so a + refusal never names the wrong binding or its command (Copilot at + openDox-code#82, r4173513795).""" + if isinstance(verdict, TrustVerdict): + if verdict.admits(binding): + return verdict + if (not verdict.trusted and verdict.binding_id == binding.id + and verdict.digest == binding_digest(binding)): + return verdict + return TrustVerdict.untrusted_for(binding, root=root, basis=BASIS_HOST, + reason=REASON_NOT_COVERED) + + +def verdict_for(binding, *, root: Path | str) -> TrustVerdict: + """The registered policy's verdict on `binding` at `root`, held to it. + + What every consumer asks before it uses a binding read from a repository. + A policy that raises trusts nothing, and its words are not repeated + (`reason_policy_failed`).""" + try: + verdict = policy().verdict(binding, root=root) + except Exception as error: # noqa: BLE001 - a policy that fails trusts nothing + return TrustVerdict.untrusted_for(binding, root=root, basis=BASIS_HOST, + reason=reason_policy_failed(error)) + return _held_to(binding, verdict, root=root) + + +def recorded_for(binding, *, root: Path | str) -> TrustVerdict: + """Ask the registered policy to RECORD trust for `binding` at `root`, and + return the verdict, which admits exactly `binding`. + + A policy may decline, as a governed host's does for a binding whose + declaration is pending. So an answer that does not admit exactly this + binding is refused BY NAME (`TrustNotRecorded`), and so is a policy that + raises, naming what it raised and never its words. `add`, `edit` and + `trust` ask this before they write anything (Copilot at + openDox-code#82, r4173513738).""" + try: + verdict = policy().record(binding, root=root) + except binding_mod.BindingRefused: + raise + except Exception as error: # noqa: BLE001 - a policy that fails records nothing + verdict = TrustVerdict.untrusted_for( + binding, root=root, basis=BASIS_HOST, + reason=reason_policy_failed(error)) + verdict = _held_to(binding, verdict, root=root) + if verdict.admits(binding): + return verdict + raise TrustNotRecorded( + f"the trust policy did not record trust for model binding " + f"{shown(binding.id)} in the repository at {shown(verdict.root)} " + f"({verdict.reason or REASON_NEVER_TRUSTED}), so it is not trusted " + "on this machine and nothing was written") + + +def intake_verdict_for(binding, *, root: Path | str) -> TrustVerdict: + """The console intake's OWN question (#1144 16.3a, T007 batch M; Copilot + at openDox-code#82, r4173513782): may the intake hand a credential to the + broker the served repository's declarations document names, for the + binding it is declaring? + + It is a DISTINCT purpose. No binding's trust answers it, so a repository + that declares a binding with the intake's very fields, and has it + trusted, admits nothing here. A policy answers it only through its own + `intake_verdict`. `MachineTrust` always answers no; a policy without one + admits no intake; one that raises admits nothing.""" + try: + ask = getattr(policy(), "intake_verdict", None) + if not callable(ask): + return TrustVerdict.untrusted_for( + binding, root=root, basis=BASIS_HOST, + reason=REASON_INTAKE_NOT_ADMITTED) + verdict = ask(binding, root=root) + except Exception as error: # noqa: BLE001 - a policy that fails admits nothing + return TrustVerdict.untrusted_for(binding, root=root, basis=BASIS_HOST, + reason=reason_policy_failed(error)) + return _held_to(binding, verdict, root=root) + + # --------------------------------------------------------------------------- # the store's tree (the discipline of openDox-code#69's bundle tree) # --------------------------------------------------------------------------- -def _unsafe_because(info: os.stat_result, *, uid: int, own: bool, - directory: bool = True) -> str | None: - """Why one path of the store's tree is unsafe, or None. - - The rules are #69's (`runtime/bundle._unsafe_because`). The store's OWN - file and directory are this user's alone and writable by no one else. An - ancestor is this user's or root's, and one that others can write is - sticky, so nobody can rename what is not theirs. A link is refused - outright.""" - mode = info.st_mode +def _unsafe_kind(mode: int, *, directory: bool) -> str | None: + """Why a path is the wrong KIND of thing for its place, or None. A link + is refused outright.""" if stat.S_ISLNK(mode): return "is a symbolic link" if directory and not stat.S_ISDIR(mode): return "is not a directory" if not directory and not stat.S_ISREG(mode): return "is not a regular file" - if own: - if info.st_uid != uid: - return f"is owned by uid {info.st_uid}, not by this user" - if mode & 0o022: - return (f"is writable by " - f"{'every user' if mode & 0o002 else 'its group'}" - f" (mode {stat.S_IMODE(mode):o})") - return None + return None + + +def _who_can_write(mode: int) -> str: + return "every user" if mode & 0o002 else "its group" + + +def _unsafe_own(info: os.stat_result, *, uid: int) -> str | None: + """The store's OWN file and directory: this user's alone, and writable + by no one else.""" + mode = info.st_mode + if info.st_uid != uid: + return f"is owned by uid {info.st_uid}, not by this user" + if mode & 0o022: + return (f"is writable by " + f"{_who_can_write(mode)}" + f" (mode {stat.S_IMODE(mode):o})") + return None + + +def _unsafe_ancestor(info: os.stat_result, *, uid: int) -> str | None: + """A directory above the store: this user's or root's, and sticky where + others can write it, so nobody can rename what is not theirs.""" + mode = info.st_mode if info.st_uid not in (uid, 0): return f"is owned by uid {info.st_uid}, neither this user nor root" if mode & 0o022 and not mode & stat.S_ISVTX: - return (f"is writable by " - f"{'every user' if mode & 0o002 else 'its group'}" - f" and is not sticky (mode {stat.S_IMODE(mode):o})") + return (f"is writable by {_who_can_write(mode)} and is not sticky " + f"(mode {stat.S_IMODE(mode):o})") return None +def _unsafe_because(info: os.stat_result, *, uid: int, own: bool, + directory: bool = True) -> str | None: + """Why one path of the store's tree is unsafe, or None. The rules are + #69's (`runtime/bundle._unsafe_because`).""" + kind = _unsafe_kind(info.st_mode, directory=directory) + if kind is not None: + return kind + return (_unsafe_own(info, uid=uid) if own + else _unsafe_ancestor(info, uid=uid)) + + def _store_refused(path: Path | str, reason: str) -> TrustStoreRefused: return TrustStoreRefused( f"the model-binding trust store refuses {shown(str(path))}: it " @@ -446,13 +586,10 @@ def _store_refused(path: Path | str, reason: str) -> TrustStoreRefused: "it") -def _refuse_an_unsafe_tree(state: Path, *, existing_only: bool) -> None: - """The store's directory, and every directory above it, are this user's - to change, or the store is refused (#69's `_refuse_an_unsafe_tree`). - - With `existing_only`, only what exists is judged, which is what is asked - before anything is created.""" - uid = os.getuid() +def _refuse_foreign_links(state: Path, *, existing_only: bool, + uid: int) -> None: + """No link on the way to the store belongs to anyone but this user or + root, who alone could point it elsewhere.""" for component in (state, *state.parents): if existing_only and not os.path.lexists(component): continue @@ -461,15 +598,31 @@ def _refuse_an_unsafe_tree(state: Path, *, existing_only: bool) -> None: raise _store_refused( component, f"is a symbolic link owned by uid {info.st_uid}, " "neither this user nor root, who could point it elsewhere") + + +def _tree_to_judge(state: Path, *, + existing_only: bool) -> list[tuple[Path, bool]]: + """The directories to judge, each with whether it is the store's own: + the state directory as it resolves, and every directory above it, by its + resolved and its spelled path.""" if existing_only and not os.path.lexists(state): - resolved_parents = [p for p in state.parents if os.path.lexists(p)] - checks = [(path, False) for path in resolved_parents] - else: - real = state.resolve() - checks = [(real, True)] + [ - (path, False) for path in dict.fromkeys( - [*real.parents, *state.parents])] - for directory, mine in checks: + return [(path, False) for path in state.parents + if os.path.lexists(path)] + real = state.resolve() + return [(real, True)] + [ + (path, False) for path in dict.fromkeys([*real.parents, + *state.parents])] + + +def _refuse_an_unsafe_tree(state: Path, *, existing_only: bool) -> None: + """The store's directory, and every directory above it, are this user's + to change, or the store is refused (#69's `_refuse_an_unsafe_tree`). + + With `existing_only`, only what exists is judged, which is what is asked + before anything is created.""" + uid = os.getuid() + _refuse_foreign_links(state, existing_only=existing_only, uid=uid) + for directory, mine in _tree_to_judge(state, existing_only=existing_only): if existing_only and not os.path.lexists(directory): continue info = os.lstat(directory) if mine else os.stat(directory) @@ -521,6 +674,53 @@ def _make_private_directories(leaf: Path) -> None: os.close(descriptor) +def _lock_exclusively(descriptor: int) -> None: + """Block until this process holds the exclusive lock on the open lock file. + It is released when the descriptor is closed. Where the platform has no + advisory lock, an `OSError` says so, and the caller refuses.""" + if fcntl is None: + raise OSError(errno.ENOSYS, "this platform offers no file lock") + fcntl.flock(descriptor, fcntl.LOCK_EX) + + +@contextlib.contextmanager +def _store_locked(state: Path): + """Hold the store's lock file, exclusively, for the body. The file is + opened without following a link, created owner-only, and held to the + store's own rules; a lock that cannot be taken refuses BY NAME, and + nothing is recorded.""" + path = state / TRUST_LOCK_FILENAME + try: + descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW + | getattr(os, "O_CLOEXEC", 0), 0o600) + except OSError: + if os.path.lexists(path): + reason = _unsafe_because(os.lstat(path), uid=os.getuid(), + own=True, directory=False) + if reason is not None: + raise _store_refused(path, reason) from None + raise _store_refused(path, "cannot be opened") from None + try: + reason = _unsafe_because(os.fstat(descriptor), uid=os.getuid(), + own=True, directory=False) + if reason is not None: + raise _store_refused(path, reason) + try: + _lock_exclusively(descriptor) + except OSError as error: + raise TrustStoreRefused( + f"the model-binding trust store cannot lock {shown(str(path))}" + f" ({error.strerror or type(error).__name__}). Without that " + "lock another openDox process recording at the same moment " + "could lose a trust, or restore one it replaced, so nothing " + "is recorded. Keep openDox's state directory " + f"({STATE_DIR_SETTING}) on a file system that supports file " + "locks") from None + yield + finally: + os.close(descriptor) + + # --------------------------------------------------------------------------- # openDox's neutral default: the per-machine store # --------------------------------------------------------------------------- @@ -540,8 +740,14 @@ class MachineTrust: `runtime.config.state_dir(env)`, which openDox-code#69 defines. Where the runtime defines none, nothing can be trusted (`NO_STATE_DIR`). - A read-modify-write race between two processes loses one record at worst, - which can only untrust a binding, never trust one.""" + EVERY RECORD HOLDS THE STORE'S LOCK across its read, its change and its + replace (`TRUST_LOCK_FILENAME`), so two processes recording at once keep + both trusts, and neither restores a form the other replaced. A reader + takes no lock: the replace is atomic, so it reads one whole store or the + other. + + IT NEVER ADMITS THE CONSOLE INTAKE (`intake_verdict`): no binding's trust + is the intake's.""" def __init__(self, *, state_dir: Path | str | None = None, env: Mapping[str, str] | None = None) -> None: @@ -574,9 +780,9 @@ def state_dir(self) -> Path: f"{error}") from None if not path.is_absolute() or ".." in path.parts: raise TrustStoreRefused( - f"the model-binding trust store's state directory {path} is " - "not an absolute path free of '..', so it is not the one " - "another process would find") + "the model-binding trust store's state directory " + f"{shown(str(path))} is not an absolute path free of '..', so " + "it is not the one another process would find") return path def store_path(self) -> Path: @@ -639,12 +845,20 @@ def record(self, binding, *, root: Path | str) -> TrustVerdict: _refuse_an_unsafe_tree(state, existing_only=True) _make_private_directories(state) _refuse_an_unsafe_tree(state, existing_only=False) - entries = self._read(state) - entries[(key_root, binding.id)] = digest - self._write(state, entries) + with _store_locked(state): + entries = self._read(state) + entries[(key_root, binding.id)] = digest + self._write(state, entries) return TrustVerdict.trusted_for(binding, root=key_root, basis=BASIS_MACHINE_TRUST) + def intake_verdict(self, binding, *, root: Path | str) -> TrustVerdict: + """The console intake's own question, which this policy always + answers NO (`REASON_INTAKE_NOT_ADMITTED`), whatever it trusts.""" + return TrustVerdict.untrusted_for( + binding, root=root, basis=BASIS_MACHINE_TRUST, + reason=REASON_INTAKE_NOT_ADMITTED) + # -- the document -------------------------------------------------------- def _read(self, state: Path) -> dict[tuple[str, str], str]: @@ -812,7 +1026,8 @@ def __repr__(self) -> str: # the seam # --------------------------------------------------------------------------- -#: The two names a policy carries. +#: The two names a policy carries. A third, `intake_verdict`, is OPTIONAL: a +#: policy without it admits no console intake (`intake_verdict_for`). POLICY_CALLABLES: tuple[str, ...] = ("verdict", "record") #: The ONE call a host makes, quoted in every refusal. diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index 3d0abcb7..56a2531f 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -1122,22 +1122,21 @@ def _handle_workbench_model_intake(self) -> None: self._intake_refusal(DOXBENCH_ERR_INVALID_INTAKE_REQUEST, str(error)) return - # THE BROKER IS THE SERVED REPOSITORY'S, SO IT RUNS ONLY IF TRUSTED + # THE BROKER IS THE SERVED REPOSITORY'S, SO IT RUNS ONLY IF ADMITTED # (#1144 16.3a, T007 batch M; RULED openxFactory#656 comment # 5962785556, item 2). The broker above comes from the repository's - # declarations document, which no binding's trust admits, so the - # registered trust policy is asked about the binding being declared. - # openDox's strict default refuses it; a host's own policy may admit - # it. Refused here, before any byte of the body is read, and the - # body is drained unread. + # declarations document, which NO BINDING'S TRUST admits: the + # registered policy is asked the intake's OWN question + # (`intake_verdict_for`), so a repository that declares a binding + # with these very fields and has it trusted gains nothing here + # (Copilot at openDox-code#82, r4173513782). openDox's strict default + # always refuses it; a host's own policy may admit it. Refused here, + # before any byte of the body is read, and the body is drained + # unread. from opendox import doxbench_trust - try: - verdict = doxbench_trust.policy().verdict( - binding, root=Path(self.checkout_root)) - except Exception: # noqa: BLE001 - a policy that fails admits nothing - verdict = None - if not (isinstance(verdict, doxbench_trust.TrustVerdict) - and verdict.admits(binding)): + verdict = doxbench_trust.intake_verdict_for( + binding, root=Path(self.checkout_root)) + if not verdict.admits(binding): if length > 0: _drain_refused_body(self.rfile, length) self._intake_refusal(DOXBENCH_ERR_INTAKE_REFUSED, diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index a2224dfc..0e95c00c 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -844,7 +844,10 @@ def test_the_store_is_one_private_file_created_by_descriptor(served): assert victim.read_text(encoding="utf-8") == "untouched" path = served.state_dir / _trust_mod().TRUST_FILENAME assert stat.S_IMODE(os.lstat(path).st_mode) == 0o600 - assert sorted(p.name for p in served.state_dir.iterdir()) == [path.name] + lock = served.state_dir / _trust_mod().TRUST_LOCK_FILENAME + assert stat.S_IMODE(os.lstat(lock).st_mode) == 0o600 + assert sorted(p.name for p in served.state_dir.iterdir()) == sorted( + [path.name, lock.name]) assert json.loads(path.read_text(encoding="utf-8"))["entries"] == [{ "root": str(served.repo.resolve()), "binding_id": BINDING_ID, "digest": _trust_mod().binding_digest(served.declared())}] @@ -878,6 +881,111 @@ def racing_unlink(path, *args, **kwargs): root=served.repo).trusted +_RACING_WRITER = r""" +import json, os, sys, time +from pathlib import Path +from opendox import doxbench_binding as binding_mod +from opendox import doxbench_trust as trust_mod + +state, root, binding_id, role, flags = sys.argv[1:6] +flags = Path(flags) +binding = binding_mod.ModelProviderBinding( + id=binding_id, label="Racing", provider="anyone", + credential_ref="env:T100_RACE", auth_kind="api_key", + approved_by="repo-author", endpoint="http://127.0.0.1:9/v1", + dialect="openai-chat-v1", broker_argv=()) +store = trust_mod.MachineTrust(state_dir=state) +if role == "first": + real = trust_mod.MachineTrust._read + + def held(self, where): + entries = real(self, where) + (flags / "first-read").write_text("1") + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + if (flags / "second-done").exists(): + break + time.sleep(0.02) + return entries + + trust_mod.MachineTrust._read = held + store.record(binding, root=root) +else: + deadline = time.monotonic() + 20 + while not (flags / "first-read").exists(): + if time.monotonic() > deadline: + sys.exit("the first writer never read the store") + time.sleep(0.02) + store.record(binding, root=root) + (flags / "second-done").write_text("1") +""" + + +def test_two_processes_recording_at_once_lose_neither_trust(served): + """Copilot at openDox-code#82 (r4173513761). The first process reads + the store and then pauses inside its record; a second process records + another binding meanwhile. Without a lock held across the read, the + change and the replace, the first writes its stale snapshot and the + second's trust is lost. With it, the second waits, and both are kept.""" + flags = served.tmp / "flags" + flags.mkdir() + env = {**_clean_env(), "PYTHONPATH": str(REPO_ROOT / "src")} + common = [str(served.state_dir), str(served.repo)] + first = subprocess.Popen( + [sys.executable, "-c", _RACING_WRITER, *common, "first-binding", + "first", str(flags)], env=env) + second = subprocess.Popen( + [sys.executable, "-c", _RACING_WRITER, *common, "second-binding", + "second", str(flags)], env=env) + assert first.wait(timeout=60) == 0 + assert second.wait(timeout=60) == 0 + path = served.state_dir / _trust_mod().TRUST_FILENAME + kept = sorted(entry["binding_id"] for entry in json.loads( + path.read_text(encoding="utf-8"))["entries"]) + assert kept == ["first-binding", "second-binding"], kept + + +@pytest.mark.parametrize("plant", ["link", "0666"]) +def test_a_lock_file_another_user_could_change_records_nothing(served, + plant): + """The lock file is held to the store's own rules: a link in its place + is never followed, and one another user could write is refused by + name, with nothing recorded.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + served.state_dir.mkdir(mode=0o700) + lock = served.state_dir / trust_mod.TRUST_LOCK_FILENAME + victim = served.tmp / "victim" + victim.write_text("untouched", encoding="utf-8") + os.chmod(victim, 0o600) + if plant == "link": + lock.symlink_to(victim) + else: + lock.write_text("", encoding="utf-8") + os.chmod(lock, 0o666) + with pytest.raises(trust_mod.TrustStoreRefused) as refused: + served.trust.record(served.declared(), root=served.repo) + assert json.dumps(str(lock)) in str(refused.value) + assert victim.read_text(encoding="utf-8") == "untouched" + assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() + + +def test_a_store_that_cannot_be_locked_records_nothing(served, monkeypatch): + """Where the platform or the file system offers no lock, `record` is + refused by name and writes nothing, rather than risk losing a trust.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + + def no_lock(*_args, **_kwargs): + raise OSError(37, "No locks available") + + monkeypatch.setattr(trust_mod, "_lock_exclusively", no_lock) + with pytest.raises(trust_mod.TrustStoreRefused) as refused: + served.trust.record(served.declared(), root=served.repo) + assert "lock" in str(refused.value) + assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() + + # --- the console intake ------------------------------------------------------ @@ -945,20 +1053,170 @@ def test_the_console_intake_refuses_a_broker_the_repository_declares(served): assert not binding_mod.bindings_path(served.repo).exists() -def test_a_hosts_own_policy_may_admit_the_console_intake(served): - class _AdmitsTheIntake: - def verdict(self, binding, *, root): - return _trust_mod().TrustVerdict.trusted_for( - binding, root=root, basis=_trust_mod().BASIS_HOST) +class _TrustsEveryBinding: + """A host policy that trusts every BINDING, and says nothing of the + console intake: it has no `intake_verdict`.""" - def record(self, binding, *, root): - return self.verdict(binding, root=root) + def verdict(self, binding, *, root): + return _trust_mod().TrustVerdict.trusted_for( + binding, root=root, basis=_trust_mod().BASIS_HOST) + + def record(self, binding, *, root): + return self.verdict(binding, root=root) + + +class _AdmitsTheIntake(_TrustsEveryBinding): + """A host policy that also admits the console intake, explicitly.""" + + def intake_verdict(self, binding, *, root): + return self.verdict(binding, root=root) + +def test_a_hosts_own_policy_may_admit_the_console_intake(served): + """Only by answering the intake's OWN question (`intake_verdict`): the + intake is a distinct purpose, so a host that trusts every binding still + does not admit it unless it says so.""" + _caps, answer = _served_intake(served, host_policy=_TrustsEveryBinding()) + assert answer.get("error") == "intake_refused", answer + assert answer.get("reason") == _trust_mod().INTAKE_BROKER_UNTRUSTED + assert not served.marker.exists() + served.marker.unlink(missing_ok=True) + shutil.rmtree(served.tmp / "out") + intake_mod.declarations_path(served.repo).unlink() _caps, answer = _served_intake(served, host_policy=_AdmitsTheIntake()) assert answer.get("error") is None, answer assert served.marker.read_text().startswith("intake ") +def test_trusting_a_lookalike_binding_never_admits_the_console_intake( + served): + """Copilot at openDox-code#82 (r4173513782). A repository can declare a + NORMAL binding with exactly the fields the intake's hand-off is judged + by: its id, label, provider and endpoint, the placeholder reference, the + serving actor as approver, and the declarations document's broker. Once + the operator trusts that binding, the strict default must still refuse + the intake, because no binding's trust is the intake's.""" + lookalike = served.record( + "broker", label="Helpful", credential_ref="pending-broker-intake", + approved_by="brett", endpoint="https://provider.invalid/v1", + broker_argv=[sys.executable, str(served.broker)]) + path = served.hand_write(lookalike) + trusted = served.trust.record(served.declared(), root=served.repo) + assert trusted.trusted + # The repository then drops the binding (a later commit): the trust + # stays recorded for that root, id and digest, as direnv's does. + path.unlink() + _caps, answer = _served_intake(served) + assert answer.get("error") == "intake_refused", answer + assert answer.get("reason") == _trust_mod().INTAKE_BROKER_UNTRUSTED + assert not served.marker.exists() + + +# --- what a policy answers is held to the binding asked about --------------- + + +class _Declines: + """A host policy whose `record` DECLINES, by answering an untrusted + verdict, as openxFactory's governed policy does for a binding whose + declaration is pending.""" + + def verdict(self, binding, *, root): + return _trust_mod().TrustVerdict.untrusted_for( + binding, root=root, basis=_trust_mod().BASIS_HOST, + reason="its declaration is pending") + + def record(self, binding, *, root): + return self.verdict(binding, root=root) + + +class _RecordsAnother(_Declines): + """A host policy whose `record` answers a TRUSTED verdict for another + binding.""" + + def record(self, binding, *, root): + return _trust_mod().TrustVerdict.trusted_for( + _a_binding(id="other-binding"), root=root, + basis=_trust_mod().BASIS_HOST) + + +class _RecordRaises(_Declines): + def record(self, binding, *, root): + raise RuntimeError(SECRET) + + +RECORDING_POLICIES = {"declines": _Declines, "records-another": _RecordsAnother, + "raises": _RecordRaises} + + +@pytest.mark.parametrize("policy", sorted(RECORDING_POLICIES)) +def test_add_edit_and_trust_refuse_when_the_policy_does_not_record_trust( + served, capsys, policy): + """Copilot at openDox-code#82 (r4173513738). A policy may decline to + record trust; then `add`, `edit` and `trust` are refused by name, write + nothing, and never print that the binding is trusted. A policy that + raises is refused the same way, naming what it raised and never its + words.""" + trust_mod = _trust_mod() + trust_mod.unregister() + trust_mod.register(RECORDING_POLICIES[policy]()) + assert _cli(*served.add_argv("env")) == 1 + captured = capsys.readouterr() + assert not binding_mod.bindings_path(served.repo).exists() + assert "trusted " not in captured.out + assert json.dumps(BINDING_ID) in captured.err + assert SECRET not in captured.out + captured.err + path = served.hand_write(served.record("env")) + written = path.read_bytes() + edit = served.add_argv("env") + edit[1] = "edit" + edit[edit.index("--label") + 1] = "Renamed" + assert _cli(*edit) == 1 + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + captured = capsys.readouterr() + assert path.read_bytes() == written + assert " trusted " not in captured.out + assert SECRET not in captured.out + captured.err + if policy == "raises": + assert "RuntimeError" in captured.err + + +@pytest.mark.parametrize("other", ["untrusted", "trusted"]) +def test_a_verdict_for_another_binding_is_refused_naming_this_one( + served, capsys, other): + """Copilot at openDox-code#82 (r4173513795). A policy that answers a + verdict for ANOTHER binding, untrusted or trusted, covers nothing here, + and the refusal, the notice and `list` name the binding that was asked + about and the command that trusts it, never the other one.""" + trust_mod = _trust_mod() + + class _AnswersAnother(_Declines): + def verdict(self, binding, *, root): + if other == "trusted": + return trust_mod.TrustVerdict.trusted_for( + _a_binding(id="other-binding"), root=root, + basis=trust_mod.BASIS_HOST) + return trust_mod.TrustVerdict.untrusted_for( + _a_binding(id="other-binding"), root=root, + basis=trust_mod.BASIS_HOST, reason="it is another binding") + + trust_mod.unregister() + trust_mod.register(_AnswersAnother()) + served.hand_write(served.record("env")) + port = served.port() + notice = capsys.readouterr().err + assert isinstance(port, trust_mod.UntrustedBindingPort) + with pytest.raises(trust_mod.BindingUntrusted) as refused: + port.dispatch(_Envelope()) + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + listed = capsys.readouterr().out + for text in (notice, str(refused.value), listed): + assert _command(served.repo) in text + assert "other-binding" not in text + assert trust_mod.REASON_NOT_COVERED in notice + served.nothing_was_touched() + + # --- the policy seam --------------------------------------------------------- From 8270dffce96cd7a6b4833363ddb0e01cd242fa57 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:18:54 +0000 Subject: [PATCH 38/45] T100: the governed stand-in takes 5970369724's shape, and answers the intake's own question Brett Heap RULED on openxFactory#656 comment 5970369724 ("Governance approval (Recommended)") what openxFactory's own policy is when it hosts openDox. T094 registers it. The test-local stand-in that shows it, _GovernedHostPolicy, now follows that shape: - APPROVED is trusted; - PENDING is refused; - NO declaration is trusted, as for the operator's own binding or the console intake's new binding while its broker runs; - an unreadable declarations document admits nothing; - record writes nothing. Since the console intake now asks its own question (intake_verdict), the stand-in answers it as it answers for a binding with no declaration. test_a_governed_host_policy_keeps_the_console_intake shows the governed intake still runs its broker. test_a_governed_host_policy_refuses_a_pending_declaration shows the pending and unreadable cases, and shows recorded_for refusing a policy that declines. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_binding_trust.py | 69 +++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 0e95c00c..1075b418 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -1440,19 +1440,32 @@ def record(self, binding, *, root): class _GovernedHostPolicy: - """WHAT T094 REGISTERS AS openxFactory's OWN POLICY, so the governed flow - is unchanged in release 1: a binding is trusted when the declarations - document records its declaration APPROVED by the gate, and when it - records no declaration for it at all, which is the operator's own binding - in the operator's own governed checkout, as today. A PENDING declaration - is not trusted (the factory already passes over it). Nothing is recorded: - the governed record is the gate's.""" + """WHAT T094 REGISTERS AS openxFactory's OWN POLICY (RULED by Brett Heap, + openxFactory#656 comment 5970369724, "Governance approval + (Recommended)"), as a test-local stand-in, so the governed flow is + unchanged in release 1: + + - a binding whose declaration the governance flow APPROVED is trusted; + - a binding whose declaration is still PENDING is refused; + - a binding with NO declaration is trusted: the operator's own, or the + console intake's new binding while its broker runs; + - where the declarations document cannot be read, nothing is admitted; + - `record` writes nothing. + + The console intake asks its own question (`intake_verdict`), so the + policy answers it too, as it answers for a binding with no declaration. + Without it, the governed host's intake would be refused.""" def verdict(self, binding, *, root): from opendox import doxbench_trust - declaration = intake_mod.DeclarationStore( - intake_mod.declarations_path(root)).get(binding.id) + try: + declaration = intake_mod.DeclarationStore( + intake_mod.declarations_path(root)).get(binding.id) + except Exception: # noqa: BLE001 - an unreadable document admits nothing + return doxbench_trust.TrustVerdict.untrusted_for( + binding, root=root, basis=doxbench_trust.BASIS_HOST, + reason="the declarations document cannot be read") if declaration is None or declaration.status == \ intake_mod.STATUS_APPROVED: return doxbench_trust.TrustVerdict.trusted_for( @@ -1464,6 +1477,9 @@ def verdict(self, binding, *, root): def record(self, binding, *, root): return self.verdict(binding, root=root) + def intake_verdict(self, binding, *, root): + return self.verdict(binding, root=root) + def _approve(root: Path, binding_id: str) -> None: store = intake_mod.DeclarationStore(intake_mod.declarations_path(root)) @@ -1496,6 +1512,41 @@ def test_a_governed_host_policy_keeps_the_governed_flow(served, declared): assert isinstance(served.port(), trust_mod.UntrustedBindingPort) +def test_a_governed_host_policy_keeps_the_console_intake(served): + """5970369724: under openxFactory's policy the console intake stays as it + is today. Its new binding has no declaration while its broker runs, and + the policy answers the intake's own question as it answers for such a + binding, so the hand-off runs the declared broker. The strict default + refuses the same intake (above).""" + _caps, answer = _served_intake(served, host_policy=_GovernedHostPolicy()) + assert answer.get("error") is None, answer + assert served.marker.read_text().startswith("intake ") + + +def test_a_governed_host_policy_refuses_a_pending_declaration(served): + """5970369724: a binding a repository declared that is still PENDING is + refused, and an unreadable declarations document admits nothing.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + store = intake_mod.DeclarationStore(intake_mod.declarations_path( + served.repo)) + store.propose(intake_mod.ModelDeclaration( + binding_id=BINDING_ID, status=intake_mod.STATUS_PENDING, + install_posture=intake_mod.POSTURE_SINGLE_OPERATOR, + proposed_by="brett@opensoft.one", proposed_at=intake_mod.stamp())) + policy = _GovernedHostPolicy() + assert not policy.verdict(served.declared(), root=served.repo).trusted + intake_mod.declarations_path(served.repo).write_text( + "{not: [a, document", encoding="utf-8") + refused = policy.verdict(served.declared(), root=served.repo) + assert not refused.trusted + assert "cannot be read" in refused.reason + trust_mod.unregister() + trust_mod.register(policy) + with pytest.raises(trust_mod.TrustNotRecorded): + trust_mod.recorded_for(served.declared(), root=served.repo) + + # =========================================================================== # 5. the store's default home, the rail, and a served turn (#77's case # still waits: strict, naming it) From e4b145d1c9cea3d46b1443d5fc22043a70fe60f4 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:51:37 +0000 Subject: [PATCH 39/45] T100: the store refuses an unsupported platform and an unresolvable state directory by name; verdicts are held to the root; the rail line says what list shows Copilot's findings at openDox-code#82, rounds 2 (7a04590d) and 3 (8270dffc). Each has a case that failed first (red-run-copilot-r2: 7 failed; red-run-copilot-r3: 3 failed) and a mutant: - r4173876800: doxbench_trust.unsupported_platform names any missing primitive among os.getuid, O_DIRECTORY, O_NOFOLLOW, fchmod, mkdir with dir_fd and fcntl.flock. MachineTrust.state_dir refuses with it first, so on such a platform every binding reads untrusted, record writes nothing, and list and the factory answer rather than raise AttributeError. - r4173876823: a state directory that cannot be resolved (a link loop) is refused by name as a TrustStoreRefused, so a verdict reads untrusted rather than RuntimeError or OSError escaping. - r4173876849: the rail's line, and its Python twin, now say that `model-binding list` shows whether each binding is trusted. For a binding already trusted, which a provider's refusal also leaves unavailable, they point to the reason the console printed. They no longer say `list` says why. The census row follows the rail's line count (1928 -> 1930, class A 18173 -> 18175). - r4174310794: _held_to requires a verdict's root to equal the resolved root asked about, so a verdict minted for another repository covers nothing here, for the factory and for the intake alike. - Review 5402101086, previously missed: recorded_for no longer re-raises every BindingRefused verbatim. Only openDox's own store's TrustStoreRefused passes as it is. Any other policy's refusal, whatever its class, is named by its class alone. Mutants: 63 of 63 killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 104 ++++++++++++++---- src/opendox/web/views/doxbench-chat.js | 8 +- tests/fixtures/web_boundary_census.yaml | 6 +- tests/test_model_binding_trust.py | 134 +++++++++++++++++++++++- 4 files changed, 222 insertions(+), 30 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 536a8343..64dbe1f5 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -102,6 +102,7 @@ import os import shlex import stat +import sys import threading from collections.abc import Mapping from pathlib import Path @@ -214,6 +215,31 @@ "a host's own policy can, by answering intake_verdict") +def unsupported_platform() -> str | None: + """Why this platform cannot keep the per-machine store, or None (Copilot + at openDox-code#82, r4173876800; #69's `runtime.bundle. + unsupported_platform` names its own gaps the same way). The store is + judged by its owner's uid, opened and made without following a link, + made relative to its parent's descriptor, written with `fchmod`, and + recorded under a file lock. Where one of those is missing, nothing can be + trusted, and the store says so by name rather than fail on the first + missing name.""" + missing = [name for name, present in ( + ("os.getuid", hasattr(os, "getuid")), + ("os.O_DIRECTORY", hasattr(os, "O_DIRECTORY")), + ("os.O_NOFOLLOW", hasattr(os, "O_NOFOLLOW")), + ("os.fchmod", hasattr(os, "fchmod")), + ("mkdir with dir_fd", os.mkdir in getattr(os, "supports_dir_fd", ())), + ("fcntl.flock", fcntl is not None), + ) if not present] + if not missing: + return None + return (f"the model-binding trust store needs a POSIX platform, and this " + f"one ({sys.platform}) lacks {', '.join(missing)}: the store is " + "judged by its owner, opened without following a link and " + "recorded under a file lock, so nothing can be trusted here") + + def reason_policy_failed(error: BaseException) -> str: """Why a binding a policy failed to judge is untrusted. It names the class of what the policy raised and never its words, which may hold anything.""" @@ -244,17 +270,20 @@ def reason_policy_failed(error: BaseException) -> str: #: available (#1144 16.3a; RULED openxFactory#656 comment 5962785556, item 2, #: "make the rail say how to trust"). The catalog's wire shape is closed, so #: the rail cannot say WHICH binding or why: it sends the operator to -#: `model-binding list`, which says why for each binding (not trusted here, or -#: a broker refusal), and names the verb that trusts one. The rail's JavaScript +#: `model-binding list`, which shows whether each binding is trusted, names +#: the verb that trusts one, and says where the reason is for a binding +#: already trusted, which a provider's refusal also leaves unavailable +#: (Copilot at openDox-code#82, r4173876849). The rail's JavaScript #: twin, `UNTRUSTED_BINDING_REMEDY` in `web/views/doxbench-chat.js`, beside #: openDox-code#74's no-model line, is held to this spelling by #: `tests/test_model_binding_trust.py`. UNTRUSTED_BINDING_REMEDY = ( - "No declared model is available. \"opendox model-binding list\" says why " - "for each binding; one read from this repository is used only once this " - "machine trusts it, which \"opendox model-binding trust \" records " - "after showing what it would run and where it would connect. Then " - "restart this console.") + "No declared model is available. \"opendox model-binding list\" shows " + "whether each binding is trusted on this machine, and \"opendox " + "model-binding trust \" trusts one after showing what it would run " + "and where it would connect; then restart this console. A binding " + "already trusted is unavailable for the reason this console printed " + "when its provider refused.") #: What the console intake's hand-off is refused with when the trust policy #: does not admit the binding it is declaring (#1144 16.3a, T007 batch M). A @@ -436,15 +465,18 @@ def require_admitted(binding, trust: TrustVerdict | None) -> None: trust.binding_id, trust.root, trust.reason or REASON_NEVER_TRUSTED)) -def _held_to(binding, verdict: Any, *, root: Path | str | None) -> TrustVerdict: - """`verdict`, held to `binding`. A verdict that admits exactly `binding` is - returned. So is an UNTRUSTED one for exactly this record, which carries - the policy's own reason. Anything else (a verdict for another binding, or - another form of this one, trusted or not, or something that is not a - verdict) is replaced by an untrusted verdict for THIS binding, so a - refusal never names the wrong binding or its command (Copilot at - openDox-code#82, r4173513795).""" - if isinstance(verdict, TrustVerdict): +def _held_to(binding, verdict: Any, *, root: Path | str) -> TrustVerdict: + """`verdict`, held to `binding` AT `root`. A verdict for this root that + admits exactly `binding` is returned. So is an UNTRUSTED one for exactly + this record at this root, which carries the policy's own reason. + Anything else is replaced by an untrusted verdict for THIS binding at + THIS root, so a refusal never names the wrong binding, root or command: + a verdict for another binding, or another form of this one, trusted or + not (Copilot at openDox-code#82, r4173513795); one minted for another + repository root, which would defeat the per-repository key (r4174310794); + and something that is not a verdict.""" + if isinstance(verdict, TrustVerdict) and verdict.root == resolved_root( + root): if verdict.admits(binding): return verdict if (not verdict.trusted and verdict.binding_id == binding.id @@ -474,14 +506,25 @@ def recorded_for(binding, *, root: Path | str) -> TrustVerdict: A policy may decline, as a governed host's does for a binding whose declaration is pending. So an answer that does not admit exactly this - binding is refused BY NAME (`TrustNotRecorded`), and so is a policy that - raises, naming what it raised and never its words. `add`, `edit` and + binding at this root is refused BY NAME (`TrustNotRecorded`), and so is + a policy that raises, a `BindingRefused` included, naming what it raised + and never its words. Only openDox's own store's `TrustStoreRefused` + passes through as it is. `add`, `edit` and `trust` ask this before they write anything (Copilot at openDox-code#82, r4173513738).""" + registered = policy() try: - verdict = policy().record(binding, root=root) - except binding_mod.BindingRefused: - raise + verdict = registered.record(binding, root=root) + except TrustStoreRefused: + # openDox's own store's refusal is actionable and composed from + # nothing a policy chose: it is raised as it is. Any other policy's + # refusal is named by its class alone, since its words are whatever + # that policy wrapped (Copilot at openDox-code#82, review 5402101086). + if isinstance(registered, MachineTrust): + raise + verdict = TrustVerdict.untrusted_for( + binding, root=root, basis=BASIS_HOST, + reason=reason_policy_failed(TrustStoreRefused())) except Exception as error: # noqa: BLE001 - a policy that fails records nothing verdict = TrustVerdict.untrusted_for( binding, root=root, basis=BASIS_HOST, @@ -763,7 +806,11 @@ def __repr__(self) -> str: # -- where --------------------------------------------------------------- def state_dir(self) -> Path: - """The directory the store lives in, or a `TrustStoreRefused`.""" + """The directory the store lives in, or a `TrustStoreRefused`. On a + platform that cannot keep the store, the refusal comes first.""" + unsupported = unsupported_platform() + if unsupported is not None: + raise TrustStoreRefused(unsupported) if self._state_dir is not None: path = self._state_dir else: @@ -795,7 +842,18 @@ def _state_dir_outside(self, root: str) -> Path: any absolute path free of `..`, and a store a clone could carry is a store the repository writes.""" state = self.state_dir() - resolved = state.resolve() + try: + resolved = state.resolve() + except (OSError, RuntimeError) as error: + # A link loop, or a path the system refuses to walk (Copilot at + # openDox-code#82, r4173876823): the store is refused by name, + # so a verdict reads untrusted rather than the caller failing. + raise TrustStoreRefused( + "the model-binding trust store's state directory " + f"{shown(str(state))} cannot be resolved " + f"({type(error).__name__}), so the store refuses it and " + f"trusts nothing. Set {STATE_DIR_SETTING} to a directory " + "that resolves") from None served = Path(root) if resolved == served or served in resolved.parents: setting = (STATE_DIR_SETTING if self._state_dir is None diff --git a/src/opendox/web/views/doxbench-chat.js b/src/opendox/web/views/doxbench-chat.js index 9299f728..1d4459a0 100644 --- a/src/opendox/web/views/doxbench-chat.js +++ b/src/opendox/web/views/doxbench-chat.js @@ -359,8 +359,10 @@ export function noModelConfiguredRemedy(stateValue) { // only once this machine trusts it, and until then the catalog keeps it, // `available: false`. The catalog's shape is closed, so the rail cannot say // WHICH binding or why; this line sends the operator to `model-binding list`, -// which says why for each binding (not trusted here, or a broker refusal), -// and names the verb that trusts one. Its OWN visible line, beside 16.4's: +// which shows whether each binding is trusted, names the verb that trusts +// one, and says where the reason is for a binding already trusted, which a +// provider's refusal also leaves unavailable (Copilot at openDox-code#82, +// r4173876849). Its OWN visible line, beside 16.4's: // that one is for an EMPTY catalog, and an operator with a binding declared // has a model configured. It shows only when the catalog has ANSWERED, is // NOT EMPTY, offers nothing available, no catalog failure is recorded (each @@ -369,7 +371,7 @@ export function noModelConfiguredRemedy(stateValue) { // twin is `doxbench_trust.UNTRUSTED_BINDING_REMEDY`, which // tests/test_model_binding_trust.py holds to this spelling. export const UNTRUSTED_BINDING_REMEDY = - "No declared model is available. \"opendox model-binding list\" says why for each binding; one read from this repository is used only once this machine trusts it, which \"opendox model-binding trust \" records after showing what it would run and where it would connect. Then restart this console."; + "No declared model is available. \"opendox model-binding list\" shows whether each binding is trusted on this machine, and \"opendox model-binding trust \" trusts one after showing what it would run and where it would connect; then restart this console. A binding already trusted is unavailable for the reason this console printed when its provider refused."; export function untrustedBindingRemedy(stateValue) { if (stateValue.catalogFailure) return null; diff --git a/tests/fixtures/web_boundary_census.yaml b/tests/fixtures/web_boundary_census.yaml index 1f8f7309..74c3adc3 100644 --- a/tests/fixtures/web_boundary_census.yaml +++ b/tests/fixtures/web_boundary_census.yaml @@ -191,7 +191,7 @@ measured_at: "opensoft/openDox-code main a99eba03e31a0aee1cc15a061fdf718cc88a2c4 # shape and `test_the_declared_totals_are_re_derived_from_the_rows` can refuse a # drift between the two. Measured at slice S4 (see the S4 block above). totals: - A: {files: 26, loc: 18173} + A: {files: 26, loc: 18175} B: {files: 1, loc: 73} C: {files: 14, loc: 12587} "?": {files: 1, loc: 1577} @@ -278,8 +278,8 @@ files: - path: views/doxbench-chat.js class: A - loc: 1928 - note: "doxBench chat rail; imports only the pure chat model. loc 1890 -> 1928 for #1144 16.3a's trust line beside 16.4's no-model line (plan 034 T100)" + loc: 1930 + note: "doxBench chat rail; imports only the pure chat model. loc 1890 -> 1930 for #1144 16.3a's trust line beside 16.4's no-model line (plan 034 T100)" - path: views/doxbench-editor.js class: A diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 1075b418..692b34e9 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -970,6 +970,73 @@ def test_a_lock_file_another_user_could_change_records_nothing(served, assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() +class _OsWithout: + """`os`, as the trust module sees it, lacking one name, as a platform + without that POSIX primitive does. Everything else is the real `os`.""" + + def __init__(self, missing: str) -> None: + self._missing = missing + + def __getattr__(self, name): + if name == self._missing: + raise AttributeError(name) + return getattr(os, name) + + +@pytest.mark.parametrize("missing", ["getuid", "O_NOFOLLOW", "O_DIRECTORY", + "fchmod", "fcntl"]) +def test_a_platform_without_the_stores_primitives_trusts_nothing( + served, capsys, monkeypatch, missing): + """Copilot at openDox-code#82 (r4173876800). Where the platform lacks a + POSIX primitive the store's guarantees rest on (as Windows lacks + `os.getuid`, `O_NOFOLLOW` and `fcntl`), the store is refused by name, + up front: every binding reads untrusted, `record` writes nothing, and + `list` and the factory answer rather than raise.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + if missing == "fcntl": + monkeypatch.setattr(trust_mod, "fcntl", None) + else: + monkeypatch.setattr(trust_mod, "os", _OsWithout(missing)) + verdict = served.trust.verdict(served.declared(), root=served.repo) + assert not verdict.trusted + assert "POSIX" in verdict.reason + with pytest.raises(trust_mod.TrustStoreRefused): + served.trust.record(served.declared(), root=served.repo) + assert not served.state_dir.exists() + assert isinstance(served.port(), trust_mod.UntrustedBindingPort) + assert "POSIX" in capsys.readouterr().err + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + assert "POSIX" in capsys.readouterr().out + served.nothing_was_touched() + + +def test_a_state_directory_that_cannot_resolve_trusts_nothing(served, + capsys): + """Copilot at openDox-code#82 (r4173876823). A state directory that is + a link loop cannot be resolved; the store is refused by name, every + binding reads untrusted, `record` writes nothing, and `list` and the + factory answer rather than raise.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + loop = served.tmp / "loop" + loop.symlink_to(served.tmp / "pool") + (served.tmp / "pool").symlink_to(loop) + trust_mod.unregister() + looped = trust_mod.MachineTrust(state_dir=loop / "st") + trust_mod.register(looped) + verdict = looped.verdict(served.declared(), root=served.repo) + assert not verdict.trusted + assert "cannot be resolved" in verdict.reason + with pytest.raises(trust_mod.TrustStoreRefused): + looped.record(served.declared(), root=served.repo) + assert isinstance(served.port(), trust_mod.UntrustedBindingPort) + assert "cannot be resolved" in capsys.readouterr().err + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + assert "cannot be resolved" in capsys.readouterr().out + served.nothing_was_touched() + + def test_a_store_that_cannot_be_locked_records_nothing(served, monkeypatch): """Where the platform or the file system offers no lock, `record` is refused by name and writes nothing, rather than risk losing a trust.""" @@ -1144,8 +1211,27 @@ def record(self, binding, *, root): raise RuntimeError(SECRET) +class _RecordRefuses(_Declines): + """A host policy whose `record` raises a `BindingRefused` carrying text + of its own (Copilot at openDox-code#82, review 5402101086, previously + missed): its words never reach the output, only its class does.""" + + def record(self, binding, *, root): + raise binding_mod.BindingRefused(SECRET) + + +class _RecordStoreRefuses(_Declines): + """A host policy whose `record` raises the store's own refusal class + with text of its own: only openDox's own store's refusal passes as it + is.""" + + def record(self, binding, *, root): + raise _trust_mod().TrustStoreRefused(SECRET) + + RECORDING_POLICIES = {"declines": _Declines, "records-another": _RecordsAnother, - "raises": _RecordRaises} + "raises": _RecordRaises, "refuses": _RecordRefuses, + "store-refuses": _RecordStoreRefuses} @pytest.mark.parametrize("policy", sorted(RECORDING_POLICIES)) @@ -1179,6 +1265,45 @@ def test_add_edit_and_trust_refuse_when_the_policy_does_not_record_trust( assert SECRET not in captured.out + captured.err if policy == "raises": assert "RuntimeError" in captured.err + if policy == "refuses": + assert "BindingRefused" in captured.err + if policy == "store-refuses": + assert "TrustStoreRefused" in captured.err + + +@pytest.mark.parametrize("question", ["binding", "intake"]) +def test_a_verdict_for_another_root_covers_nothing_here(served, capsys, + question): + """Copilot at openDox-code#82 (r4174310794). A policy that answers a + TRUSTED verdict minted for another repository root covers nothing at + this one: the per-repository key holds, the binding is refused naming + this root, and the intake runs no broker.""" + trust_mod = _trust_mod() + elsewhere = served.fresh_repository("elsewhere") + + class _AnswersForAnotherRoot(_Declines): + def verdict(self, binding, *, root): + return trust_mod.TrustVerdict.trusted_for( + binding, root=elsewhere, basis=trust_mod.BASIS_HOST) + + def intake_verdict(self, binding, *, root): + return self.verdict(binding, root=root) + + if question == "intake": + _caps, answer = _served_intake(served, + host_policy=_AnswersForAnotherRoot()) + assert answer.get("reason") == trust_mod.INTAKE_BROKER_UNTRUSTED + assert not served.marker.exists() + return + trust_mod.unregister() + trust_mod.register(_AnswersForAnotherRoot()) + served.hand_write(served.record("env")) + port = served.port() + notice = capsys.readouterr().err + assert isinstance(port, trust_mod.UntrustedBindingPort) + assert _command(served.repo) in notice + assert trust_mod.REASON_NOT_COVERED in notice + served.nothing_was_touched() @pytest.mark.parametrize("other", ["untrusted", "trusted"]) @@ -1719,6 +1844,13 @@ def test_the_rail_says_how_to_trust_a_declared_binding(tmp_path): rail = json.loads(done.stdout) remedy = _trust_mod().UNTRUSTED_BINDING_REMEDY assert rail["remedy"] == remedy + # Copilot at openDox-code#82 (r4173876849): a TRUSTED binding is also + # unavailable after its provider refused, and `list` then says only that + # it is trusted. The line says what `list` shows, and where the reason + # is for a binding already trusted. + assert "shows whether each binding is trusted" in remedy + assert "says why" not in remedy + assert "already trusted" in remedy shown = rail["onlyUnavailable"] assert shown["shown"] == remedy assert shown["noModel"] is None, "a declared model is not 'no model'" From 24a1c25e112760fdc9dc6105ac3714c8b631575d Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:31:37 +0000 Subject: [PATCH 40/45] T100: the printed trust command runs as printed; only openDox's exact store passes its refusal; the store writes nothing it could not read Copilot's round-4 findings at openDox-code#82 cb691b18. Each has a case that failed first (red-run-copilot-r4) and a mutant: - r4174632060: trust_command puts the options first and the id last, after `--` where the id begins with `-`. The command a refusal prints, run as printed, trusts the binding it names. The test's command helper follows the new order. - r4174632006: recorded_for passes a TrustStoreRefused through as written only from exactly MachineTrust, not from a host's subclass that overrides record with words of its own. - r4174632086: _write refuses a store larger than MAX_TRUST_STORE_BYTES before replacing anything, so the read bound is the write bound and every trust already held stays held. Mutants: 66 of 66 killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 30 +++++++++++-- tests/test_model_binding_trust.py | 70 +++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 64dbe1f5..d642cc50 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -378,11 +378,18 @@ def _operand(value: str) -> str: def trust_command(binding_id: str, root: str | None = None) -> str: """The command that trusts `binding_id`. The id and the root come from a repository and a checkout, and a refusal that printed them bare would hand - a pasted command, or a terminal, whatever they hold (`_operand`).""" - command = f"opendox model-binding trust {_operand(binding_id)}" + a pasted command, or a terminal, whatever they hold (`_operand`). + + The options come first and the id last, after `--` where the id itself + begins with `-`, so the command, run as printed, trusts the binding it + names whatever its id looks like to an option parser (Copilot at + openDox-code#82, r4174632060).""" + command = "opendox model-binding trust" if root is not None: command += f" --repo-root {_operand(root)}" - return command + if binding_id.startswith("-"): + command += " --" + return f"{command} {_operand(binding_id)}" # --------------------------------------------------------------------------- @@ -520,7 +527,9 @@ def recorded_for(binding, *, root: Path | str) -> TrustVerdict: # nothing a policy chose: it is raised as it is. Any other policy's # refusal is named by its class alone, since its words are whatever # that policy wrapped (Copilot at openDox-code#82, review 5402101086). - if isinstance(registered, MachineTrust): + # "openDox's own" is the exact class: a host's subclass may override + # `record` with words of its own (r4174632006). + if type(registered) is MachineTrust: raise verdict = TrustVerdict.untrusted_for( binding, root=root, basis=BASIS_HOST, @@ -620,6 +629,13 @@ def _unsafe_because(info: os.stat_result, *, uid: int, own: bool, else _unsafe_ancestor(info, uid=uid)) +def _store_refused_whole(path: Path | str, size: int) -> TrustStoreRefused: + return TrustStoreRefused( + f"the model-binding trust store {shown(str(path))} would grow to " + f"{size} bytes, larger than the {MAX_TRUST_STORE_BYTES} it reads, so " + "this trust is not recorded and every trust already held stays held") + + def _store_refused(path: Path | str, reason: str) -> TrustStoreRefused: return TrustStoreRefused( f"the model-binding trust store refuses {shown(str(path))}: it " @@ -998,6 +1014,12 @@ def _write(state: Path, entries: dict[tuple[str, str], str]) -> None: payload = (json.dumps(document, indent=2, sort_keys=True, ensure_ascii=True) + "\n").encode("ascii") target = state / TRUST_FILENAME + if len(payload) > MAX_TRUST_STORE_BYTES: + # The read bound is the write bound: a store this writes is one + # it can read again, so a record that would outgrow it is refused + # before anything is replaced, and every trust held stays held + # (Copilot at openDox-code#82, r4174632086). + raise _store_refused_whole(target, len(payload)) temporary = state / f".{TRUST_FILENAME}.opendox-{os.getpid()}" try: os.unlink(temporary) # an interrupted write's; a link itself, never its target diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 3dda63dc..9697dd85 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -339,8 +339,8 @@ def _cli(*argv: str) -> int: def _command(root: Path) -> str: - return (f"opendox model-binding trust {BINDING_ID} --repo-root " - f"{root.resolve()}") + return (f"opendox model-binding trust --repo-root {root.resolve()} " + f"{BINDING_ID}") # =========================================================================== @@ -1036,6 +1036,51 @@ def test_a_state_directory_that_cannot_resolve_trusts_nothing(served, served.nothing_was_touched() +def test_a_record_that_would_outgrow_the_read_bound_is_refused( + served, monkeypatch): + """Copilot at openDox-code#82 (r4174632086). The store reads nothing + larger than `MAX_TRUST_STORE_BYTES`, so it writes nothing larger either: + a record that would outgrow the bound is refused by name, before the + store is replaced, and every trust already recorded still holds.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + first = served.declared() + served.trust.record(first, root=served.repo) + path = served.state_dir / trust_mod.TRUST_FILENAME + held = path.read_bytes() + monkeypatch.setattr(trust_mod, "MAX_TRUST_STORE_BYTES", len(held) + 16) + second = served.fresh_repository("r2") + with pytest.raises(trust_mod.TrustStoreRefused) as refused: + served.trust.record(first, root=second) + assert "larger" in str(refused.value) + assert path.read_bytes() == held + assert served.trust.verdict(first, root=served.repo).trusted + assert not served.trust.verdict(first, root=second).trusted + + +@pytest.mark.parametrize("binding_id", ["-dash-model", "--repo-root", + BINDING_ID]) +def test_the_printed_trust_command_trusts_the_binding_it_names( + served, capsys, binding_id): + """Copilot at openDox-code#82 (r4174632060). A valid id may begin with + `-`. The command a refusal prints, run as printed, trusts exactly that + binding, whatever its id looks like to an option parser.""" + import shlex as shlex_mod + + trust_mod = _trust_mod() + served.hand_write(served.record("env", id=binding_id)) + port = served.port() + capsys.readouterr() + with pytest.raises(trust_mod.BindingUntrusted) as refused: + port.dispatch(_Envelope()) + command = str(refused.value).rsplit("trust it with: ", 1)[1] + argv = shlex_mod.split(command) + assert argv[:3] == ["opendox", "model-binding", "trust"] + assert _cli(*argv[1:]) == 0 + assert served.trust.verdict(served.declared(), root=served.repo).trusted + served.nothing_was_touched() + + def test_a_store_that_cannot_be_locked_records_nothing(served, monkeypatch): """Where the platform or the file system offers no lock, `record` is refused by name and writes nothing, rather than risk losing a trust.""" @@ -1256,9 +1301,26 @@ def record(self, binding, *, root): raise _trust_mod().TrustStoreRefused(SECRET) +class _SubclassStoreRefuses: + """A HOST policy built on `MachineTrust` whose `record` raises the store's + refusal class with text of its own (Copilot at openDox-code#82, + r4174632006): a subclass is not openDox's own store, so its words never + pass through.""" + + def __new__(cls): + trust_mod = _trust_mod() + + class _Sub(trust_mod.MachineTrust): + def record(self, binding, *, root): + raise trust_mod.TrustStoreRefused(SECRET) + + return _Sub(state_dir="/nonexistent-t100-subclass") + + RECORDING_POLICIES = {"declines": _Declines, "records-another": _RecordsAnother, "raises": _RecordRaises, "refuses": _RecordRefuses, - "store-refuses": _RecordStoreRefuses} + "store-refuses": _RecordStoreRefuses, + "subclass-store-refuses": _SubclassStoreRefuses} @pytest.mark.parametrize("policy", sorted(RECORDING_POLICIES)) @@ -1294,7 +1356,7 @@ def test_add_edit_and_trust_refuse_when_the_policy_does_not_record_trust( assert "RuntimeError" in captured.err if policy == "refuses": assert "BindingRefused" in captured.err - if policy == "store-refuses": + if policy in ("store-refuses", "subclass-store-refuses"): assert "TrustStoreRefused" in captured.err From db77c4ea5c781427e6076d0433b70e04fa62f87c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:59:13 +0000 Subject: [PATCH 41/45] T100 round 5: printed commands a shell reads back exactly, unservable bindings never trusted, one list reading, dangling links refused (plan 034) Copilot at openDox-code#82, review at 7012cda3, and the adversarial self-pass the holder asked for before this push. - r4174783197 (high). A printed trust command fell back to a JSON-quoted operand for a non-printable or non-ASCII value, and a shell still runs `$(...)` and backticks inside double quotes. At 7012cda3 a repository directory named with a newline, a terminal escape or a non-ASCII character plus `$(touch CANARY)` made CANARY when its printed command was pasted (red run). Now a printed command carries only an id the model catalog accepts (no shell or option parser acts on its characters) and paths quoted by shlex.quote where every character is printable. A non-printable root is named `.`, to be run from the repository's root, and `--bindings` is named where the binding was read from a document given by one. An id the catalog refuses gets no command. The `--` logic is gone: no printable id begins with `-`. - r4174783280. A binding whose id or label the catalog refuses was trusted by `trust`, and the next start failed in brokered_catalog. doxbench_trust.unservable_because now judges the very catalog the factory declares, before any policy is asked: verdict_for refuses such a binding (a host policy and an old store entry included), recorded_for refuses it, so `add`, `edit` and `trust` write nothing, and the start declares the refusing port over an empty catalog. - r4174783250. `list` reads the bindings document once, and its disclosure, trust lines and console lines are all of that reading. - r4174783301. A link this user owns that points at nothing, as the state directory or above it, is refused by name, and any OSError the store's tree raises that no check named is a TrustStoreRefused naming the store, in `record` and in `verdict`. - The trust-state walk (pending, approved, undeclared, unreadable declarations, a stale record). `list` gains a console line naming the one binding a console serving the repository declares, by the factory's own rule. The console's model approval says the binding is available only where the registered policy admits it, and otherwise APPROVED_UNTRUSTED_NOTICE. The fixed sentences that quote a command (the refused turn's, the rail's, the approval's) now name the required `--repo-root`, each parsed by the real parser in a test. The rail's JavaScript twin changes on its one line, so the census is unchanged. Every fix has a case that fails at 7012cda3 (32 failed) and a mutant. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/cli_model_binding.py | 82 +++- src/opendox/doxbench_install.py | 33 +- src/opendox/doxbench_trust.py | 342 +++++++++++++--- src/opendox/serve_workbench.py | 24 +- src/opendox/web/views/doxbench-chat.js | 2 +- tests/test_model_binding_trust.py | 530 ++++++++++++++++++++++++- 6 files changed, 918 insertions(+), 95 deletions(-) diff --git a/src/opendox/cli_model_binding.py b/src/opendox/cli_model_binding.py index 8f313762..ce483e98 100644 --- a/src/opendox/cli_model_binding.py +++ b/src/opendox/cli_model_binding.py @@ -98,6 +98,22 @@ def _declared_binding(args: argparse.Namespace) -> "binding_mod.ModelProviderBin "binding {binding_id!r} names no broker, so there is nothing to hand a " "credential to: {custody}") +#: What `list`'s console line says of each binding (#1144 16.3a; the +#: trust-state walk), by the rule the console's own factory follows +#: (`doxbench_install.declared_model_port_factory`): it reads the checkout's +#: own bindings document, passes over a binding whose declaration is pending +#: approval, and declares the FIRST of the rest. Whether a turn may use the +#: one it declares is the trust line above it. +CONSOLE_DECLARES_THIS = ( + "declares this one, the first binding not pending approval") +CONSOLE_PASSES_OVER_PENDING = ( + "passes over it: its declaration is pending approval") +CONSOLE_DECLARES_ANOTHER = ( + "declares {binding_id}, the first binding not pending approval, and " + "declares one binding at a time") +CONSOLE_READS_ANOTHER_DOCUMENT = ( + "reads {path}, not this document, so it declares none of these") + def cmd_model_binding_list(args: argparse.Namespace) -> int: """DISCLOSE every declared binding (task 1.2's read-back). @@ -107,20 +123,27 @@ def cmd_model_binding_list(args: argparse.Namespace) -> int: secret it was never able to hold.""" store = _binding_store(args) try: - disclosure = store.read_back() + # ONE SNAPSHOT (Copilot at openDox-code#82, r4174783250): the + # disclosure, the trust lines and the console lines are all of these + # objects, read once, so a document that changes, or stops reading, + # between two reads cannot pair one binding's fields with another's + # verdict, or end a listing in a traceback. + bindings = store.list() except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 print(f" bindings {store.path}") - if not disclosure["bindings"]: + if not bindings: print(" (none declared — this install talks to no brokered provider)") return 0 - verdicts = _trust_lines(store, args) + verdicts = _trust_lines(bindings, store, args) + consoles = _console_lines(bindings, store, args) # EVERY VALUE A REPOSITORY WROTE IS PRINTED ESCAPED, in a JSON string's # form (#1144 16.3a, T007 batch M): a newline or a terminal control # sequence in a field cannot forge or hide a line of this listing. shown = trust_mod.shown - for record in disclosure["bindings"]: + for binding in bindings: + record = binding.as_read_back() print(f" {shown(record['id'])} {shown(record['label'])}") print(f" provider {shown(record['provider'])}") print(f" auth kind {shown(record['auth_kind'])}") @@ -136,27 +159,64 @@ def cmd_model_binding_list(args: argparse.Namespace) -> int: argv = record["broker_argv"] print(f" broker argv {shown(argv) if argv else NOT_DECLARED}") print(f" custody {record['credential_custody']}") - print(f" trust {verdicts.get(record['id'], '')}") + print(f" trust {verdicts[binding.id]}") + print(f" console {consoles[binding.id]}") return 0 -def _trust_lines(store: "binding_mod.BindingStore", +def _trust_lines(bindings, store: "binding_mod.BindingStore", args: argparse.Namespace) -> dict[str, str]: """`list`'s trust line for each binding (#1144 16.3a): trusted on this - machine, or why not and the command that trusts it. Asked only when a - binding is declared, so an empty store never touches the state + machine, or why not and what to do, ending with the command that trusts + it where one can be printed safely (`doxbench_trust.trust_remedy`). That + command reads the same bindings document this listing did. Asked only + when a binding is declared, so an empty store never touches the state directory.""" root = _repo_root(args) + named = (str(store.path) if getattr(args, "bindings", None) else None) lines: dict[str, str] = {} - for binding in store.list(): + for binding in bindings: verdict = trust_mod.verdict_for(binding, root=root) if verdict.admits(binding): lines[binding.id] = "trusted on this machine" else: reason = verdict.reason or trust_mod.REASON_NEVER_TRUSTED + remedy = trust_mod.trust_remedy(binding.id, str(root), reason, + bindings=named) lines[binding.id] = ( - f"NOT trusted on this machine ({reason}); trust it with: " - f"{trust_mod.trust_command(binding.id, str(root))}") + f"NOT trusted on this machine ({reason}). {remedy}") + return lines + + +def _console_lines(bindings, store: "binding_mod.BindingStore", + args: argparse.Namespace) -> dict[str, str]: + """`list`'s console line for each binding (#1144 16.3a; the trust-state + walk): which one a console serving this repository declares, by its + factory's own rule, so a binding listed as trusted is never mistaken for + the one in use. The pending set is the factory's own + (`doxbench_intake.pending_binding_ids`), which reads a declarations + document that cannot be read as declaring nothing pending, as the + factory does.""" + from opendox import doxbench_intake as intake_mod + + root = _repo_root(args) + shown = trust_mod.shown + console_reads = binding_mod.bindings_path(root) + if Path(store.path).resolve() != console_reads.resolve(): + line = CONSOLE_READS_ANOTHER_DOCUMENT.format( + path=shown(str(console_reads))) + return {binding.id: line for binding in bindings} + pending = intake_mod.pending_binding_ids(root) + approved = [binding for binding in bindings if binding.id not in pending] + lines: dict[str, str] = {} + for binding in bindings: + if binding.id in pending: + lines[binding.id] = CONSOLE_PASSES_OVER_PENDING + elif binding is approved[0]: + lines[binding.id] = CONSOLE_DECLARES_THIS + else: + lines[binding.id] = CONSOLE_DECLARES_ANOTHER.format( + binding_id=shown(approved[0].id)) return lines diff --git a/src/opendox/doxbench_install.py b/src/opendox/doxbench_install.py index 0e44f912..a045e79c 100644 --- a/src/opendox/doxbench_install.py +++ b/src/opendox/doxbench_install.py @@ -313,7 +313,8 @@ def unavailable_catalog(binding) -> ModelCatalog: for entry in brokered_catalog(binding).entries]) -def trust_gated_model_port_factory(binding, *, checkout_root: Path | str): +def trust_gated_model_port_factory(binding, *, checkout_root: Path | str, + bindings_path: Path | str | None = None): """The factory for the first approved binding, ONCE THE TRUST POLICY HAS JUDGED IT (#1144 16.3a; plan 034 T100; RULED openxFactory#656 comment 5962785556, item 2). @@ -331,25 +332,40 @@ def trust_gated_model_port_factory(binding, *, checkout_root: Path | str): The verdict is HELD TO THIS BINDING (`doxbench_trust.verdict_for`): a policy that raises trusts nothing, and its words are not repeated; one that answers for another binding, trusted or not, covers nothing, and the - refusal names THIS binding and its command.""" + refusal names THIS binding and its command. + + A BINDING THE CATALOG REFUSES IS NEVER TRUSTED, whatever the policy or + the store says (Copilot at openDox-code#82, r4174783280): its id or its + label is not one `brokered_catalog` can list, so the verdict refuses it + before any policy is asked (`doxbench_trust.unservable_because`), and + the start declares the refusing port over an empty catalog rather than + fail on what a repository wrote. So `brokered_catalog` below is only + ever built for a binding it accepts. + + `bindings_path` is the document the binding was read from, where a + caller named one, so the command the refusal prints reads that document + too.""" from opendox import doxbench_trust as trust_mod from opendox.doxbench_model import EMPTY_CATALOG, ModelCatalogError verdict = trust_mod.verdict_for(binding, root=checkout_root) if verdict.admits(binding): return brokered_model_port_factory(binding, trust=verdict) + bindings = (None if bindings_path is None + else str(Path(bindings_path).resolve())) sys.stderr.write("[model-provider] " + trust_mod.refusal_message( verdict.binding_id, verdict.root, - verdict.reason or trust_mod.REASON_NEVER_TRUSTED) + "\n") + verdict.reason or trust_mod.REASON_NEVER_TRUSTED, + bindings=bindings) + "\n") try: catalog = unavailable_catalog(binding) except ModelCatalogError: # An id or a label the catalog's schema refuses (a newline, a - # terminal escape) is a binding no turn could name anyway. It is - # refused by name above, and the catalog lists nothing rather than - # the start failing on what a repository wrote. + # terminal escape, an id past its bound) is a binding no turn could + # name. It is refused by name above, and the catalog lists nothing + # rather than the start failing on what a repository wrote. catalog = EMPTY_CATALOG - port = trust_mod.UntrustedBindingPort(catalog, verdict) + port = trust_mod.UntrustedBindingPort(catalog, verdict, bindings=bindings) def resolve(): return port @@ -436,4 +452,5 @@ def declared_model_port_factory(session_root: Path | str, *, return model_port_factory(Path(session_root), spawn=spawn) return no_model_port_factory return trust_gated_model_port_factory(approved[0], - checkout_root=checkout_root) + checkout_root=checkout_root, + bindings_path=bindings_path) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index d642cc50..1d80632f 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -43,10 +43,18 @@ WHAT A REPOSITORY WROTE IS SHOWN ESCAPED. Every value a refusal, the factory's notice, `model-binding list` or `model-binding trust` prints from a binding is printed in a JSON string's form (`shown`), so a newline or a -terminal control sequence in a field cannot forge or hide what is shown. The -command that trusts a binding quotes each operand for a shell, and falls back -to the same JSON form for an operand no shell quoting can show safely -(`trust_command`). +terminal control sequence in a field cannot forge or hide what is shown. + +A COMMAND PRINTED FOR AN OPERATOR TO PASTE IS READ BY A SHELL, and JSON's +quoting is not a shell's: inside double quotes, `$(...)` and a backtick still +run (Copilot at openDox-code#82, r4174783197). So a printed command carries +only operands a POSIX shell reads back exactly (`trust_command`): an id the +model catalog accepts, whose characters no shell expands and no option parser +reads as an option, and a path quoted by `shlex.quote` where every character +of it is printable. A path that is not printable is never printed in a +command: the command names the repository as `.`, to be run from its root. An +id the catalog refuses belongs to a binding no turn could use, so no policy +trusts it and no command is printed for it (`unservable_because`). THE STORE is one private file in openDox's state directory, the one `runtime.config.state_dir` names (`OPENDOX_STATE_DIR`, or its per-user @@ -82,10 +90,12 @@ function) and its built-in resolver each refuse a binding the verdict does not cover, by id AND digest (`require_admitted`). No verdict is no trust. -IMPORT WEIGHT. The standard library and `opendox.doxbench_binding`. The -runtime's `config` is imported when a state directory is first resolved, and -`doxbench_model` when the refusing port is built. This module names no -provider, holds no credential, spawns nothing and reaches no network. +IMPORT WEIGHT. The standard library, `opendox.doxbench_binding` and +`opendox.doxbench_model` (whose own imports are `dataclasses` and `typing`). +The runtime's `config` is imported when a state directory is first resolved, +and `doxbench_install` when a binding's catalog entry is first judged +(`unservable_because`). This module names no provider, holds no credential, +spawns nothing and reaches no network. A CREATED FILE: it has no row in openxFactory's `docs/opendox-carve-manifest.yaml`, because the manifest declares what LEAVES @@ -100,6 +110,7 @@ import hashlib import json import os +import re import shlex import stat import sys @@ -109,6 +120,7 @@ from typing import Any from opendox import doxbench_binding as binding_mod +from opendox import doxbench_model try: # POSIX; where it is absent, nothing is recorded import fcntl @@ -116,6 +128,8 @@ fcntl = None __all__ = [ + "APPROVED_UNTRUSTED_NOTICE", + "BASIS_CATALOG", "BASIS_HOST", "BASIS_MACHINE_TRUST", "BindingUntrusted", @@ -128,7 +142,10 @@ "UNTRUSTED_TURN_MESSAGE", "UntrustedBindingPort", "INTAKE_BROKER_UNTRUSTED", + "REASON_UNSERVABLE", + "REMEDY_UNSERVABLE", "binding_digest", + "command_safe_id", "current", "is_registered", "policy", @@ -139,7 +156,9 @@ "resolved_root", "shown", "trust_command", + "trust_remedy", "unregister", + "unservable_because", ] # --------------------------------------------------------------------------- @@ -181,6 +200,10 @@ #: A host policy's verdict (a governed host's own rule). BASIS_HOST = "host" +#: The verdict on a binding the model catalog refuses, given before any +#: policy is asked (`unservable_because`). +BASIS_CATALOG = "catalog" + #: The setting that names openDox's state directory (openDox-code#69). STATE_DIR_SETTING = "OPENDOX_STATE_DIR" @@ -215,6 +238,25 @@ "a host's own policy can, by answering intake_verdict") +#: Why a binding the model catalog refuses is never trusted (Copilot at +#: openDox-code#82, r4174783280). The catalog lists a binding by its id and +#: its label, so a binding whose id or label it refuses is one no chat turn +#: could ever use, and the factory could not declare it. The bounds are the +#: released catalog schema's, as `doxbench_model` restates them. +REASON_UNSERVABLE = ( + "the model catalog cannot list it: an id is 1 to " + f"{doxbench_model.MODEL_REFERENCE_MAX_LENGTH} ASCII letters, digits, " + "'.', '_' and '-', beginning with a letter or a digit, and a label is 1 " + f"to {doxbench_model.LABEL_MAX_LENGTH} characters and not blank, so no " + "chat turn could use it") + +#: What an operator is told to do about such a binding. Trust cannot help, so +#: no command that trusts it is printed. +REMEDY_UNSERVABLE = ( + "Trusting it cannot make it usable: declare it again with an id and a " + "label the model catalog accepts, then trust that binding") + + def unsupported_platform() -> str | None: """Why this platform cannot keep the per-machine store, or None (Copilot at openDox-code#82, r4173876800; #69's `runtime.bundle. @@ -261,9 +303,10 @@ def reason_policy_failed(error: BaseException) -> str: UNTRUSTED_TURN_MESSAGE = ( "the model binding this install declares is not trusted on this machine, " "so nothing was sent: no broker ran, no credential was read and no " - "endpoint was contacted. Run \"opendox model-binding list\" to see which " - "binding and why, trust it with \"opendox model-binding trust \", and " - "restart this console") + "endpoint was contacted. Run \"opendox model-binding list --repo-root " + "\" to see which binding and why, trust it with \"opendox " + "model-binding trust --repo-root \", and restart this " + "console") #: What the chat rail says when the catalog lists a declared model and none is @@ -278,12 +321,29 @@ def reason_policy_failed(error: BaseException) -> str: #: openDox-code#74's no-model line, is held to this spelling by #: `tests/test_model_binding_trust.py`. UNTRUSTED_BINDING_REMEDY = ( - "No declared model is available. \"opendox model-binding list\" shows " - "whether each binding is trusted on this machine, and \"opendox " - "model-binding trust \" trusts one after showing what it would run " - "and where it would connect; then restart this console. A binding " - "already trusted is unavailable for the reason this console printed " - "when its provider refused.") + "No declared model is available. \"opendox model-binding list " + "--repo-root \" shows whether each binding is trusted on " + "this machine, and \"opendox model-binding trust --repo-root " + "\" trusts one after showing what it would run and where it would " + "connect; then restart this console. A binding already trusted is " + "unavailable for the reason this console printed when its provider " + "refused.") + +#: What the console's model approval answers, as its `availability`, when +#: the trust policy does not admit the binding it approved (#1144 16.3a; the +#: trust-state walk). Approval is a governance record, and trust is this +#: machine's: under openDox's strict default an approved binding is still +#: refused until it is trusted, so the result does not say it is available. +#: A host whose policy admits it (a governed host's approval) answers +#: `doxbench_intake.APPROVAL_NOTICE`, as before. A FIXED sentence. +APPROVED_UNTRUSTED_NOTICE = ( + "the model is approved for this console, but its binding is not trusted " + "on this machine, so it is not an available catalog entry: \"opendox " + "model-binding list --repo-root \" shows why, and \"opendox " + "model-binding trust --repo-root \" trusts it after " + "showing what it would run and where it would connect; then restart this " + "console. The credential remains in the broker's custody and this act " + "neither mints nor reads one") #: What the console intake's hand-off is refused with when the trust policy #: does not admit the binding it is declaring (#1144 16.3a, T007 batch M). A @@ -314,9 +374,10 @@ class TrustStoreRefused(binding_mod.BindingRefused): class TrustNotRecorded(binding_mod.BindingRefused): - """The registered policy did not record trust for the binding asked - about: it declined, answered for another binding, or failed. `add`, - `edit` and `trust` refuse with it before they write anything.""" + """Trust was not recorded for the binding asked about: the model catalog + refuses it (`unservable_because`), or the registered policy declined, + answered for another binding, or failed. `add`, `edit` and `trust` + refuse with it before they write anything.""" class TrustPolicyNotRegistered(RuntimeError): @@ -366,30 +427,105 @@ def shown(value: object) -> str: return json.dumps(value, ensure_ascii=True) -def _operand(value: str) -> str: - """One operand of a printed command: quoted for a POSIX shell where it is - printable ASCII, and otherwise in a JSON string's form, which no shell - quoting could print without carrying the bytes it escapes.""" - if value.isascii() and value.isprintable(): - return shlex.quote(value) - return shown(value) - - -def trust_command(binding_id: str, root: str | None = None) -> str: - """The command that trusts `binding_id`. The id and the root come from a - repository and a checkout, and a refusal that printed them bare would hand - a pasted command, or a terminal, whatever they hold (`_operand`). - - The options come first and the id last, after `--` where the id itself - begins with `-`, so the command, run as printed, trusts the binding it - names whatever its id looks like to an option parser (Copilot at - openDox-code#82, r4174632060).""" +def command_safe_id(binding_id: object) -> bool: + """Whether `binding_id` may stand bare in a printed command: an id the + model catalog accepts (`doxbench_model.MODEL_REFERENCE_PATTERN`, at most + `MODEL_REFERENCE_MAX_LENGTH` characters). Its characters are ASCII + letters, digits, `.`, `_` and `-`, which no POSIX shell expands, splits or + quotes, and it begins with a letter or a digit, so no option parser reads + it as an option (Copilot at openDox-code#82, r4174632060). Every other id + is one the catalog refuses, so its binding is one no turn could use + (`unservable_because`), and no command is printed for it (r4174783197).""" + return (isinstance(binding_id, str) + and len(binding_id) <= doxbench_model.MODEL_REFERENCE_MAX_LENGTH + and re.fullmatch(doxbench_model.MODEL_REFERENCE_PATTERN, + binding_id) is not None) + + +def _quoted_path(path: object) -> str | None: + """A path as a POSIX shell reads it back exactly (`shlex.quote`: one + single-quoted word, inside which no shell expands anything), or None + where a character of it is not printable: a newline, a terminal control + sequence, a bidirectional override, or a byte the file system's name did + not decode. Such a path is never printed in a command, because printing + it would hand a terminal what `shown` exists to escape (r4174783197).""" + if not isinstance(path, str) or not path or not path.isprintable(): + return None + return shlex.quote(path) + + +def trust_command(binding_id: str, root: str | None, *, + bindings: str | None = None) -> str | None: + """The command that trusts `binding_id` at `root`, as one line a POSIX + shell reads back as exactly the verb's arguments, or None where it cannot + be printed so (Copilot at openDox-code#82, r4174783197). + + `--repo-root` is required by the verb, so there is no command without a + root. `--bindings` is given where the binding was read from a document + named by one. The id comes last, and only an id the catalog accepts is + printed (`command_safe_id`); each path is quoted (`_quoted_path`). An + absolute path begins with `/` and a relative one is given as `./...`, so + no operand reads as an option.""" + if root is None or not command_safe_id(binding_id): + return None command = "opendox model-binding trust" - if root is not None: - command += f" --repo-root {_operand(root)}" - if binding_id.startswith("-"): - command += " --" - return f"{command} {_operand(binding_id)}" + for option, value in (("--repo-root", root), ("--bindings", bindings)): + if option == "--bindings" and value is None: + continue + quoted = _quoted_path(value) + if quoted is None: + return None + command += f" {option} {quoted}" + return f"{command} {binding_id}" + + +def _command_from_the_root(binding_id: str, root: str | None, + bindings: str | None) -> str | None: + """The command with the repository named `.`, to be run from its root, + for a binding whose root is unknown or cannot be printed. A bindings + document is named relative to that root, and only where it lies inside + it.""" + if bindings is None: + return trust_command(binding_id, ".") + if root is None: + return None + try: + relative = Path(bindings).relative_to(root) + except ValueError: + return None + return trust_command(binding_id, ".", + bindings=os.path.join(".", str(relative))) + + +def trust_remedy(binding_id: str, root: str | None, + reason: str | None = None, *, + bindings: str | None = None) -> str: + """What a refusal, or `list`, tells the operator to do about a binding + that is not trusted: one sentence that ENDS with the command that trusts + it, where a command can be printed safely (`trust_command`). + + A binding the catalog refuses gets no command, since trust cannot make it + usable (`REMEDY_UNSERVABLE`). Where the root is unknown, or a path cannot + be printed, the command names the repository `.` and says to run it from + that repository's root. Where even that cannot be printed, the sentence + says what to give the verb instead.""" + if reason == REASON_UNSERVABLE or not command_safe_id(binding_id): + return REMEDY_UNSERVABLE + command = trust_command(binding_id, root, bindings=bindings) + if command is not None: + return f"Review it, then trust it with: {command}" + command = _command_from_the_root(binding_id, root, bindings) + lead = ("From the root directory of the repository that declares it" + if root is None else + "A path it is read from cannot be printed in a command safely, " + "so, from its repository's own root directory") + if command is not None: + return f"{lead}, review it, then trust it with: {command}" + return ("A path it is read from cannot be printed in a command safely, " + "so none is printed: review it, then run \"opendox model-binding " + "trust\" from its repository's own root directory, with " + "--repo-root . and --bindings naming the document it is read " + f"from, and the id {binding_id}") # --------------------------------------------------------------------------- @@ -438,17 +574,18 @@ def admits(self, binding) -> bool: and binding_digest(binding) == self.digest) -def refusal_message(binding_id: str, root: str | None, reason: str) -> str: +def refusal_message(binding_id: str, root: str | None, reason: str, *, + bindings: str | None = None) -> str: """The refusal of an untrusted binding, BY NAME: the id, the reason, and - the command that trusts it. It names no secret, and nothing has been + what to do, which ends with the command that trusts it where one can be + printed safely (`trust_remedy`). It names no secret, and nothing has been resolved when it is composed.""" where = (f" in the repository at {shown(root)}" if root is not None else "") return (f"model binding {shown(binding_id)}{where} is not trusted on this " f"machine ({reason}), so it is not used: no broker runs, no " "credential reference is resolved and no endpoint is contacted. " - "Review it, then trust it with: " - f"{trust_command(binding_id, root)}") + f"{trust_remedy(binding_id, root, reason, bindings=bindings)}") def require_admitted(binding, trust: TrustVerdict | None) -> None: @@ -493,12 +630,37 @@ def _held_to(binding, verdict: Any, *, root: Path | str) -> TrustVerdict: reason=REASON_NOT_COVERED) +def unservable_because(binding) -> str | None: + """Why no chat turn could use `binding`, or None: the model catalog + refuses its id or its label (Copilot at openDox-code#82, r4174783280). + + Judged by building the very catalog the factory would declare for it + (`doxbench_install.brokered_catalog`), so the two cannot disagree. Asked + BEFORE any policy is: no policy, a host's included, trusts a binding that + could never be served, `add`, `edit` and `trust` record nothing for one + and write nothing, and the factory declares a refusing port for one + rather than fail at start on what a repository wrote.""" + from opendox import doxbench_install + + try: + doxbench_install.brokered_catalog(binding) + except doxbench_model.ModelCatalogError: + return REASON_UNSERVABLE + return None + + def verdict_for(binding, *, root: Path | str) -> TrustVerdict: """The registered policy's verdict on `binding` at `root`, held to it. What every consumer asks before it uses a binding read from a repository. - A policy that raises trusts nothing, and its words are not repeated - (`reason_policy_failed`).""" + A binding the catalog refuses is untrusted before any policy is asked + (`unservable_because`). A policy that raises trusts nothing, and its + words are not repeated (`reason_policy_failed`).""" + unservable = unservable_because(binding) + if unservable is not None: + return TrustVerdict.untrusted_for(binding, root=root, + basis=BASIS_CATALOG, + reason=unservable) try: verdict = policy().verdict(binding, root=root) except Exception as error: # noqa: BLE001 - a policy that fails trusts nothing @@ -518,7 +680,16 @@ def recorded_for(binding, *, root: Path | str) -> TrustVerdict: and never its words. Only openDox's own store's `TrustStoreRefused` passes through as it is. `add`, `edit` and `trust` ask this before they write anything (Copilot at - openDox-code#82, r4173513738).""" + openDox-code#82, r4173513738). + + A binding the catalog refuses is refused before any policy is asked, and + nothing is recorded for it (`unservable_because`, r4174783280).""" + unservable = unservable_because(binding) + if unservable is not None: + raise TrustNotRecorded( + f"model binding {shown(binding.id)} is not trusted on this " + f"machine, and no trust was recorded for it: {unservable}. " + f"{REMEDY_UNSERVABLE}") registered = policy() try: verdict = registered.record(binding, root=root) @@ -636,6 +807,23 @@ def _store_refused_whole(path: Path | str, size: int) -> TrustStoreRefused: "this trust is not recorded and every trust already held stays held") +def _store_failed(state: Path | str, error: OSError, *, + writing: bool) -> TrustStoreRefused: + """What the store says when the system refuses an act on its tree that + no check above named (a permission, a full disk, a path that vanished), + BY NAME and by the system's own short word for it (Copilot at + openDox-code#82, r4174783301). Nothing is trusted through it.""" + word = error.strerror or type(error).__name__ + if writing: + return TrustStoreRefused( + f"the model-binding trust store in {shown(str(state))} could not " + f"be written ({word}), so this trust is not recorded and every " + "trust already held stays held") + return TrustStoreRefused( + f"the model-binding trust store in {shown(str(state))} could not be " + f"read ({word}), so nothing is trusted through it") + + def _store_refused(path: Path | str, reason: str) -> TrustStoreRefused: return TrustStoreRefused( f"the model-binding trust store refuses {shown(str(path))}: it " @@ -645,18 +833,34 @@ def _store_refused(path: Path | str, reason: str) -> TrustStoreRefused: "it") +def _store_refused_dangling(path: Path | str) -> TrustStoreRefused: + return TrustStoreRefused( + f"the model-binding trust store refuses {shown(str(path))}: it is a " + "symbolic link to nothing, so the store would be made or read through " + "a path no check has judged, and nothing is trusted through it. " + "Create what it points to, or set openDox's state directory " + f"({STATE_DIR_SETTING}) to a directory that exists") + + def _refuse_foreign_links(state: Path, *, existing_only: bool, uid: int) -> None: """No link on the way to the store belongs to anyone but this user or - root, who alone could point it elsewhere.""" + root, who alone could point it elsewhere. And none, whoever owns it, + points at nothing (Copilot at openDox-code#82, r4174783301): a link to + nothing resolves to a path the tree check never judged, and the store + would be made or read through it.""" for component in (state, *state.parents): if existing_only and not os.path.lexists(component): continue info = os.lstat(component) - if stat.S_ISLNK(info.st_mode) and info.st_uid not in (uid, 0): + if not stat.S_ISLNK(info.st_mode): + continue + if info.st_uid not in (uid, 0): raise _store_refused( component, f"is a symbolic link owned by uid {info.st_uid}, " "neither this user nor root, who could point it elsewhere") + if not os.path.exists(component): + raise _store_refused_dangling(component) def _tree_to_judge(state: Path, *, @@ -891,7 +1095,11 @@ def verdict(self, binding, *, root: Path | str) -> TrustVerdict: A store that cannot be used trusts nothing, and says why.""" key_root = resolved_root(root) try: - entries = self._read(self._state_dir_outside(key_root)) + state = self._state_dir_outside(key_root) + try: + entries = self._read(state) + except OSError as error: + raise _store_failed(state, error, writing=False) from None except TrustStoreRefused as refusal: return TrustVerdict.untrusted_for( binding, root=key_root, basis=BASIS_MACHINE_TRUST, @@ -916,13 +1124,18 @@ def record(self, binding, *, root: Path | str) -> TrustVerdict: digest = binding_digest(binding) with self._lock: state = self._state_dir_outside(key_root) - _refuse_an_unsafe_tree(state, existing_only=True) - _make_private_directories(state) - _refuse_an_unsafe_tree(state, existing_only=False) - with _store_locked(state): - entries = self._read(state) - entries[(key_root, binding.id)] = digest - self._write(state, entries) + try: + _refuse_an_unsafe_tree(state, existing_only=True) + _make_private_directories(state) + _refuse_an_unsafe_tree(state, existing_only=False) + with _store_locked(state): + entries = self._read(state) + entries[(key_root, binding.id)] = digest + self._write(state, entries) + except OSError as error: + # Whatever the system refused that no check named: refused + # BY NAME, never a raw error (r4174783301). + raise _store_failed(state, error, writing=True) from None return TrustVerdict.trusted_for(binding, root=key_root, basis=BASIS_MACHINE_TRUST) @@ -1068,9 +1281,10 @@ class UntrustedBindingPort: factory's notice, in `opendox model-binding list`, and in a refused turn's message (`UNTRUSTED_TURN_MESSAGE`).""" - __slots__ = ("_catalog", "_verdict") + __slots__ = ("_bindings", "_catalog", "_verdict") - def __init__(self, catalog, verdict: TrustVerdict) -> None: + def __init__(self, catalog, verdict: TrustVerdict, *, + bindings: str | None = None) -> None: from opendox import doxbench_model if not isinstance(catalog, doxbench_model.ModelCatalog): @@ -1081,6 +1295,7 @@ def __init__(self, catalog, verdict: TrustVerdict) -> None: "an untrusted binding's catalog offers no available entry") self._catalog = catalog self._verdict = verdict + self._bindings = bindings @property def timeout_seconds(self) -> float: @@ -1096,7 +1311,8 @@ def catalog(self): def dispatch(self, prompt_envelope: object) -> object: raise BindingUntrusted(refusal_message( self._verdict.binding_id, self._verdict.root, - self._verdict.reason or REASON_NEVER_TRUSTED)) + self._verdict.reason or REASON_NEVER_TRUSTED, + bindings=self._bindings)) def __repr__(self) -> str: return f"" diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index 11a4ebd5..fe24c10e 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -1395,9 +1395,31 @@ def _handle_workbench_model_approval(self) -> None: "ok": True, "kind": "workbench-model-approval-result", "declaration": approved.as_read_back(), - "availability": doxbench_intake.APPROVAL_NOTICE, + "availability": self._approved_availability(binding), }) + def _approved_availability(self, binding) -> str: + """What an approval says of the binding it approved (#1144 16.3a; + the trust-state walk). Approval is a governance record, and trust is + this machine's, so the result says the binding is available only + where the registered trust policy admits it: a governed host's, which + trusts what its gate approved, answers `APPROVAL_NOTICE` as before, + and openDox's strict default, until the binding is trusted, answers + `doxbench_trust.APPROVED_UNTRUSTED_NOTICE`. + + ASKED OF A REGISTERED POLICY ONLY. This act is not one of the + consumers that register openDox's default (`doxbench_trust.policy`), + so where nothing is registered no binding has been judged trusted in + this process, and the result says it is not, rather than read a + store no consumer has asked for.""" + from opendox import doxbench_intake + from opendox import doxbench_trust + if (doxbench_trust.is_registered() + and doxbench_trust.verdict_for( + binding, root=Path(self.checkout_root)).admits(binding)): + return doxbench_intake.APPROVAL_NOTICE + return doxbench_trust.APPROVED_UNTRUSTED_NOTICE + def _approval_binding(self, binding_id: str): """The binding a pending declaration names, or None. Read through the ONE store both entrypoints read, so an approval cannot be recorded diff --git a/src/opendox/web/views/doxbench-chat.js b/src/opendox/web/views/doxbench-chat.js index 1d4459a0..453cdec1 100644 --- a/src/opendox/web/views/doxbench-chat.js +++ b/src/opendox/web/views/doxbench-chat.js @@ -371,7 +371,7 @@ export function noModelConfiguredRemedy(stateValue) { // twin is `doxbench_trust.UNTRUSTED_BINDING_REMEDY`, which // tests/test_model_binding_trust.py holds to this spelling. export const UNTRUSTED_BINDING_REMEDY = - "No declared model is available. \"opendox model-binding list\" shows whether each binding is trusted on this machine, and \"opendox model-binding trust \" trusts one after showing what it would run and where it would connect; then restart this console. A binding already trusted is unavailable for the reason this console printed when its provider refused."; + "No declared model is available. \"opendox model-binding list --repo-root \" shows whether each binding is trusted on this machine, and \"opendox model-binding trust --repo-root \" trusts one after showing what it would run and where it would connect; then restart this console. A binding already trusted is unavailable for the reason this console printed when its provider refused."; export function untrustedBindingRemedy(stateValue) { if (stateValue.catalogFailure) return null; diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 9697dd85..93deb9e4 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -633,7 +633,8 @@ def test_a_binding_carrying_control_characters_is_shown_escaped(served, """A hand-written binding whose id, label and one broker argv member carry a newline and a terminal escape is printed with both escaped, by `trust`, by `list` and in the refusal, and no raw control byte reaches the - output.""" + output. The catalog refuses such an id, so `trust` shows it and then + refuses it (Copilot at openDox-code#82, r4174783280).""" hostile = "evil\n\x1b[2J" served.hand_write(served.record( "broker", id=hostile, label=f"Label{hostile}", @@ -643,7 +644,7 @@ def test_a_binding_carrying_control_characters_is_shown_escaped(served, port.dispatch(_Envelope()) assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 assert _cli("model-binding", "trust", "--repo-root", str(served.repo), - hostile) == 0 + hostile) == 1 captured = capsys.readouterr() for text in (captured.out, captured.err, str(refused.value)): assert "\x1b" not in text @@ -1058,13 +1059,15 @@ def test_a_record_that_would_outgrow_the_read_bound_is_refused( assert not served.trust.verdict(first, root=second).trusted -@pytest.mark.parametrize("binding_id", ["-dash-model", "--repo-root", - BINDING_ID]) +@pytest.mark.parametrize("binding_id", [BINDING_ID, "0.dotted_id-9", + "M" * 128]) def test_the_printed_trust_command_trusts_the_binding_it_names( served, capsys, binding_id): - """Copilot at openDox-code#82 (r4174632060). A valid id may begin with - `-`. The command a refusal prints, run as printed, trusts exactly that - binding, whatever its id looks like to an option parser.""" + """Copilot at openDox-code#82 (r4174632060, r4174783197). The command a + refusal prints, run as printed, trusts exactly that binding, for every + shape of id the catalog accepts, up to its bound. An id that begins with + `-` is one the catalog refuses, and no command is printed for it + (`test_an_id_the_catalog_refuses_prints_no_command_and_is_never_trusted`).""" import shlex as shlex_mod trust_mod = _trust_mod() @@ -1097,6 +1100,320 @@ def no_lock(*_args, **_kwargs): assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() +# --- every command printed for an operator to paste (r4174783197) ----------- + +#: Repository directory names, each holding what a shell acts on: a command +#: substitution in both spellings, a command separator, both quotes, a +#: newline, a terminal escape, and a printable non-ASCII name with a space. +#: A shell that ran any of them would make `CANARY` where it runs. +HOSTILE_ROOTS = { + "dollar-paren": "r$(touch CANARY)", + "backtick": "r`touch CANARY`", + "semicolon": "r; touch CANARY", + "quotes": "r'b\"c $(touch CANARY)", + "newline": "r\n$(touch CANARY)", + "escape": "r\x1b[2J$(touch CANARY)", + "non-ascii": "r é $(touch CANARY)", +} + +#: Every POSIX shell on this machine (`sh` always is one), each with the flag +#: that keeps it from reading the user's own start-up files. +SHELLS = tuple((shell, *flags) for shell, *flags in (("sh",), ("bash",), + ("zsh", "-f")) + if shutil.which(shell)) + + +def _as_each_shell_reads(command: str, where: Path) -> dict[str, list[str]]: + """`command`, as each shell here reads it, with `opendox` stubbed by a + shell function that writes the arguments it was given, NUL-separated: + exactly what that shell would hand the real verb. Run in `where`, so + whatever the command ran would land there.""" + env = {k: v for k, v in _clean_env().items() + if k not in ("BASH_ENV", "ENV")} + stub = 'opendox() { printf "%s\\0" "$@" > "$OPENDOX_ARGV"; }\n' + read: dict[str, list[str]] = {} + for shell, *flags in SHELLS: + argv_file = where / f"argv-{shell}" + subprocess.run([shell, *flags, "-c", stub + command + "\n"], + cwd=where, env={**env, "OPENDOX_ARGV": str(argv_file)}, + check=True, timeout=60) + read[shell] = [part.decode("utf-8", "surrogateescape") for part + in argv_file.read_bytes().split(b"\0")[:-1]] + return read + + +def _printed_commands(text: str) -> list[str]: + """Every command `text` prints for an operator to paste: what follows + "trust it with: " on its line.""" + return [line.rsplit("trust it with: ", 1)[1] + for line in text.splitlines() if "trust it with: " in line] + + +@pytest.mark.parametrize("name", sorted(HOSTILE_ROOTS)) +def test_every_printed_trust_command_reads_back_exactly_in_each_shell( + served, capsys, monkeypatch, name): + """Copilot at openDox-code#82 (r4174783197). A repository's path can hold + anything a directory name can. Every command printed to trust its binding + (the factory's notice, a refused turn, `list`, and `list --bindings`) is + ONE line that `sh`, `bash` and `zsh` each read back as exactly the verb's + arguments, and it runs nothing: no `CANARY` is made. A path that is not + printable is never printed in a command: the command names the + repository `.`, to be run from its root. Where the factory or `list` was + given the bindings document, the command names it too. Run as printed, + from there, the command trusts the binding it names.""" + trust_mod = _trust_mod() + root = served.fresh_repository(HOSTILE_ROOTS[name]) + document = served.hand_write(served.record("env"), root=root) + port = served.port(root) + notice = capsys.readouterr().err + with pytest.raises(trust_mod.BindingUntrusted) as refused: + port.dispatch(_Envelope()) + port = install_mod.declared_model_port_factory( + served.tmp / "sessions", checkout_root=root, + bindings_path=document)() + notice_naming_the_document = capsys.readouterr().err + with pytest.raises(trust_mod.BindingUntrusted) as refused_naming: + port.dispatch(_Envelope()) + assert _cli("model-binding", "list", "--repo-root", str(root)) == 0 + listed = capsys.readouterr().out + assert _cli("model-binding", "list", "--repo-root", str(root), + "--bindings", str(document)) == 0 + listed_naming_the_document = capsys.readouterr().out + printable = str(root.resolve()).isprintable() + place = str(root.resolve()) if printable else "." + named = (str(document.resolve()) if printable + else os.path.join(".", str(document.relative_to(root)))) + plain = ["model-binding", "trust", "--repo-root", place, BINDING_ID] + naming = ["model-binding", "trust", "--repo-root", place, "--bindings", + named, BINDING_ID] + expected = {"notice": plain, "refused turn": plain, "list": plain, + "notice --bindings": naming, + "refused turn --bindings": naming, + "list --bindings": naming} + printed = {"notice": notice, "refused turn": str(refused.value), + "list": listed, + "notice --bindings": notice_naming_the_document, + "refused turn --bindings": str(refused_naming.value), + "list --bindings": listed_naming_the_document} + shells_run_in = served.tmp / "shells" + shells_run_in.mkdir() + for source, text in printed.items(): + commands = _printed_commands(text) + assert len(commands) == 1, (source, text) + assert commands[0].isprintable(), (source, commands[0]) + for shell, argv in _as_each_shell_reads(commands[0], + shells_run_in).items(): + assert argv == expected[source], (source, shell, commands[0]) + assert not list(served.tmp.rglob("CANARY")) + monkeypatch.chdir(root) + assert _cli(*expected["list --bindings"]) == 0 + capsys.readouterr() + assert served.trust.verdict(served.declared(root), root=root).trusted + assert not list(served.tmp.rglob("CANARY")) + served.nothing_was_touched() + + +#: Ids a repository may write that the model catalog refuses, each holding +#: what a shell or an option parser would act on, or past the catalog's +#: bound, or outside its ASCII vocabulary. +HOSTILE_IDS = { + "dollar-paren": "$(touch CANARY)", + "backtick": "`touch CANARY`", + "semicolon": "m; touch CANARY", + "quotes": "m'b\"c", + "newline": "m\n$(touch CANARY)", + "dash": "-dash-model", + "option": "--repo-root", + "overlong": "M" * 129, + "non-ascii": "mé", +} + + +@pytest.mark.parametrize("name", sorted(HOSTILE_IDS)) +def test_an_id_the_catalog_refuses_prints_no_command_and_is_never_trusted( + served, capsys, name): + """Copilot at openDox-code#82 (r4174783197, r4174783280). An id the + model catalog refuses belongs to a binding no turn could use, so no + command that trusts it is printed anywhere (the factory's notice, a + refused turn, `list`), each says why instead, and `trust` refuses it with + nothing recorded. The catalog lists nothing, and the start does not + fail.""" + trust_mod = _trust_mod() + binding_id = HOSTILE_IDS[name] + served.hand_write(served.record("env", id=binding_id)) + port = served.port() + notice = capsys.readouterr().err + with pytest.raises(trust_mod.BindingUntrusted) as refused: + port.dispatch(_Envelope()) + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + listed = capsys.readouterr().out + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + "--", binding_id) == 1 + trusting = capsys.readouterr() + shown = (notice, str(refused.value), listed, trusting.err) + for text in shown: + assert "opendox model-binding trust" not in text, text + for text in shown: + assert trust_mod.REASON_UNSERVABLE in text, text + assert trust_mod.REMEDY_UNSERVABLE in text, text + assert list(port.catalog().entries) == [] + assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() + assert not trust_mod.verdict_for(served.declared(), + root=served.repo).trusted + assert not list(served.tmp.rglob("CANARY")) + served.nothing_was_touched() + + +@pytest.mark.parametrize("field", ["id", "label"]) +def test_a_binding_the_catalog_refuses_is_never_trusted_nor_fails_the_start( + served, capsys, field): + """Copilot at openDox-code#82 (r4174783280). `ModelProviderBinding` + takes an id or a label the model catalog refuses (here, one past the + catalog's bound). Such a binding is refused before any policy is asked: + a trust the store recorded for it before, or a host policy that trusts + every binding, still leaves the start declaring a refusing port, never + failing on `brokered_catalog`; and `trust`, `add` and `edit` record + nothing and write nothing for one.""" + trust_mod = _trust_mod() + record = served.record( + "env", **({"id": "M" * 129} if field == "id" else {"label": "L" * 201})) + document = served.hand_write(record) + # recorded straight into the store, as a store written before this check + served.trust.record(served.declared(), root=served.repo) + for policy in (served.trust, _TrustsEveryBinding()): + trust_mod.unregister() + trust_mod.register(policy) + port = served.port() + assert list(port.catalog().entries) == [] + with pytest.raises(trust_mod.BindingUntrusted) as refused: + port.dispatch(_Envelope()) + assert trust_mod.REASON_UNSERVABLE in str(refused.value) + capsys.readouterr() + trust_mod.unregister() + trust_mod.register(served.trust) + store = served.state_dir / trust_mod.TRUST_FILENAME + held = store.read_bytes() + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + "--", record["id"]) == 1 + assert trust_mod.REASON_UNSERVABLE in capsys.readouterr().err + assert store.read_bytes() == held + document.unlink() + adding = served.add_argv("env") + adding[adding.index(f"--{field}") + 1] = record[field] + assert _cli(*adding) == 1 + assert trust_mod.REASON_UNSERVABLE in capsys.readouterr().err + assert not document.exists() + assert store.read_bytes() == held + assert _cli(*served.add_argv("env")) == 0 + capsys.readouterr() + before = document.read_bytes() + editing = served.add_argv("env") + editing[1] = "edit" + editing[editing.index("--label") + 1] = "L" * 201 + assert _cli(*editing) == 1 + assert trust_mod.REASON_UNSERVABLE in capsys.readouterr().err + assert document.read_bytes() == before + served.nothing_was_touched() + + +@pytest.mark.parametrize("second", ["another-form", "unreadable"]) +def test_list_discloses_and_judges_one_reading_of_the_bindings( + served, capsys, monkeypatch, second): + """Copilot at openDox-code#82 (r4174783250). `list` reads the bindings + document ONCE, and the fields it discloses and the trust it reports are + both of that reading: a document that changes after it, or stops + reading, cannot pair one form's fields with another form's verdict, or + end the listing in a traceback.""" + record = served.record("env") + served.hand_write(record) + served.trust.record(served.declared(), root=served.repo) + read = binding_mod.BindingStore._load + readings = [] + + def load(store): + readings.append(store.path) + if len(readings) == 1: + return read(store) + if second == "unreadable": + raise binding_mod.BindingRefused( + "the bindings document changed between two readings") + return [binding_mod.ModelProviderBinding.from_record( + {**record, "label": "Another form"})] + + monkeypatch.setattr(binding_mod.BindingStore, "_load", load) + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + listed = capsys.readouterr().out + assert json.dumps(record["label"]) in listed + assert "Another form" not in listed + assert " trust trusted on this machine\n" in listed + assert len(readings) == 1 + + +@pytest.mark.parametrize("where", ["state-directory", "above-it"]) +def test_a_link_to_nothing_on_the_way_to_the_store_is_refused_by_name( + served, capsys, where): + """Copilot at openDox-code#82 (r4174783301). A link this user owns that + points at nothing, as the state directory or above it, is refused BY + NAME, by `record` (which went through it and failed raw) and by + `verdict`, and `trust` prints that refusal rather than call it a policy + failure. Nothing is made where it points.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + link = served.tmp / "dangling" + link.symlink_to(served.tmp / "nowhere") + policy = trust_mod.MachineTrust( + state_dir=link if where == "state-directory" else link / "st") + trust_mod.unregister() + trust_mod.register(policy) + with pytest.raises(trust_mod.TrustStoreRefused) as refused: + policy.record(served.declared(), root=served.repo) + assert (f"refuses {json.dumps(str(link))}: it is a symbolic link to " + "nothing") in str(refused.value) + verdict = policy.verdict(served.declared(), root=served.repo) + assert not verdict.trusted and verdict.reason == str(refused.value) + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + assert str(refused.value) in capsys.readouterr().err + assert not (served.tmp / "nowhere").exists() + + +def test_a_store_the_system_refuses_to_make_is_refused_by_name( + served, capsys, monkeypatch): + """Copilot at openDox-code#82 (r4174783301). Whatever the system refuses + on the store's tree that no check named (here, a permission) is refused + BY NAME, as a `TrustStoreRefused` naming the store and the system's word + for it, never a raw `OSError` that `trust` could only call a policy + failure. A verdict says the same of a store it cannot read, and `list` + prints it.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + + def refused_by_the_system(_leaf): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(trust_mod, "_make_private_directories", + refused_by_the_system) + with pytest.raises(trust_mod.TrustStoreRefused) as refused: + served.trust.record(served.declared(), root=served.repo) + assert json.dumps(str(served.state_dir)) in str(refused.value) + assert "(Permission denied)" in str(refused.value) + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + err = capsys.readouterr().err + assert str(refused.value) in err + assert "trust policy failed" not in err + monkeypatch.setattr(trust_mod, "_refuse_an_unsafe_tree", + lambda *_args, **_kwargs: refused_by_the_system(None)) + verdict = served.trust.verdict(served.declared(), root=served.repo) + assert not verdict.trusted + assert verdict.reason == ( + f"the model-binding trust store in {json.dumps(str(served.state_dir))}" + " could not be read (Permission denied), so nothing is trusted " + "through it") + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + assert verdict.reason in capsys.readouterr().out + + # --- the console intake ------------------------------------------------------ @@ -1695,15 +2012,22 @@ def intake_verdict(self, binding, *, root): return self.verdict(binding, root=root) -def _approve(root: Path, binding_id: str) -> None: +def _propose(root: Path, binding_id: str): + """A PENDING declaration of `binding_id` in `root`'s declarations + document, as the console intake proposes one. Returns the store.""" store = intake_mod.DeclarationStore(intake_mod.declarations_path(root)) store.propose(intake_mod.ModelDeclaration( binding_id=binding_id, status=intake_mod.STATUS_PENDING, install_posture=intake_mod.POSTURE_SINGLE_OPERATOR, proposed_by="brett@opensoft.one", proposed_at=intake_mod.stamp())) - store.approve(binding_id, issued_by="console", approved_by="brett", - expires_at=intake_mod.approval_expiry(), - audit_ref="opaud-approved-1") + return store + + +def _approve(root: Path, binding_id: str) -> None: + _propose(root, binding_id).approve( + binding_id, issued_by="console", approved_by="brett", + expires_at=intake_mod.approval_expiry(), + audit_ref="opaud-approved-1") @pytest.mark.parametrize("declared", ["approved", "undeclared"]) @@ -1761,6 +2085,190 @@ def test_a_governed_host_policy_refuses_a_pending_declaration(served): trust_mod.recorded_for(served.declared(), root=served.repo) +# --- the trust-state walk: what is printed, stored and enforced agree ------- + + +def _listed(out: str) -> dict[str, dict[str, str]]: + """`list`'s output as {binding id: {field: value}}: each binding's line + opens with its id in JSON's spelling, and each field's line is indented + four, its name padded to seventeen.""" + blocks: dict[str, dict[str, str]] = {} + fields: dict[str, str] = {} + for line in out.splitlines(): + if line.startswith(' "'): + fields = blocks.setdefault( + json.JSONDecoder().raw_decode(line[2:])[0], {}) + elif line.startswith(" ") and not line.startswith(" "): + fields[line[4:21].strip()] = line[21:] + return blocks + + +@pytest.mark.parametrize("declarations", ["none", "first-pending", + "unreadable"]) +def test_list_names_the_one_binding_a_console_declares(served, capsys, + declarations): + """The trust-state walk: pending, approved, undeclared and unreadable + declarations. `list` names the binding a console serving this repository + declares, by its factory's own rule, so a binding listed as trusted is + never taken for the one in use: a pending declaration is passed over, an + undeclared binding is the operator's own and counts as approved, an + unreadable declarations document declares nothing pending, and the + console declares the FIRST of the rest. Listing another document says + the console does not read it.""" + from opendox import cli_model_binding as cmb + + served.hand_write(served.record("env", id="first-model"), + served.record("env", id="second-model")) + if declarations == "first-pending": + _propose(served.repo, "first-model") + elif declarations == "unreadable": + path = intake_mod.declarations_path(served.repo) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{not: [a, document", encoding="utf-8") + for binding in binding_mod.BindingStore( + binding_mod.bindings_path(served.repo)).list(): + served.trust.record(binding, root=served.repo) + port = served.port() + capsys.readouterr() + declared = "second-model" if declarations == "first-pending" else ( + "first-model") + assert [(e.model_id, e.available) for e in port.catalog().entries] == [ + (declared, True)] + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + blocks = _listed(capsys.readouterr().out) + assert {block["trust"] for block in blocks.values()} == { + "trusted on this machine"} + assert blocks[declared]["console"] == cmb.CONSOLE_DECLARES_THIS + if declarations == "first-pending": + assert blocks["first-model"]["console"] == ( + cmb.CONSOLE_PASSES_OVER_PENDING) + else: + assert blocks["second-model"]["console"] == ( + cmb.CONSOLE_DECLARES_ANOTHER.format( + binding_id=json.dumps("first-model"))) + elsewhere = served.tmp / "elsewhere.yaml" + shutil.copy(binding_mod.bindings_path(served.repo), elsewhere) + assert _cli("model-binding", "list", "--repo-root", str(served.repo), + "--bindings", str(elsewhere)) == 0 + blocks = _listed(capsys.readouterr().out) + assert {block["console"] for block in blocks.values()} == { + cmb.CONSOLE_READS_ANOTHER_DOCUMENT.format(path=json.dumps(str( + binding_mod.bindings_path(served.repo.resolve()))))} + + +class _ApprovingHostGate(_HostGate): + """A host's gate that also builds, validates and writes the approval's + record, which openDox's own default refuses to build, as + `tests/test_capability_honesty.py`'s host gate does.""" + + def __init__(self) -> None: + super().__init__() + self.build_gate_action_record = lambda **fields: dict(fields) + self.validate_gate_action_record = lambda record: None + self.HumanGate = lambda root, prefixes, *, human_actor: ( + root, tuple(prefixes), human_actor) + self.write_gate_action_record = lambda human, records_dir, record: ( + self.written.append(record) + or Path(human[0]) / records_dir / "record.yaml") + + +def _post_an_approval(served, binding_id: str) -> dict: + """The console's model approval of `binding_id`, posted to a stand-in + host that registers its gate (#77), as `_served_intake`'s host does. + Returns the answer.""" + import http.client + + from opendox import column_seams, serve + + column_seams.gate.unregister() + column_seams.gate.register(_ApprovingHostGate()) + httpd = serve.build_server( + REPO_ROOT / "src" / "opendox" / "web", + served.tmp / "out" / "snapshot.json", served.repo, port=0, + actor="brett", model_port_factory=lambda: None) + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + try: + base = httpd.server_address[:2] + connection = http.client.HTTPConnection(*base, timeout=30) + connection.request("GET", "/capabilities") + caps = json.loads(connection.getresponse().read().decode("utf-8")) + connection.close() + connection = http.client.HTTPConnection(*base, timeout=30) + connection.request( + "POST", "/actions/workbench/model-approval", + body=json.dumps({"binding": binding_id}).encode("utf-8"), + headers={"Content-Type": "application/json", + serve.CONSOLE_TOKEN_HEADER: caps.get("console_token", + "")}) + answer = json.loads(connection.getresponse().read().decode("utf-8") + or "{}") + connection.close() + return answer + finally: + httpd.shutdown() + httpd.server_close() + worker.join(timeout=10) + column_seams.gate.unregister() + + +@pytest.mark.parametrize("judged_by", ["strict-default", "strict-default-trusted", + "host", "nothing-registered"]) +def test_an_approval_says_available_only_where_the_binding_is_trusted( + served, judged_by): + """The trust-state walk, at the console's approval. Approval is a + governance record, and trust is this machine's: under openDox's strict + default an approved binding is still refused until it is trusted, so the + result says so rather than that it is now an available catalog entry. A + host whose policy admits the binding (a governed host's approval) and a + binding this machine trusts read as before. Where nothing is registered, + the act registers nothing and reads no store: no binding has been judged + trusted in that process.""" + trust_mod = _trust_mod() + _caps, answer = _served_intake(served, host_policy=_AdmitsTheIntake()) + assert answer.get("error") is None, answer + trust_mod.unregister() + if judged_by == "host": + trust_mod.register(_AdmitsTheIntake()) + elif judged_by != "nothing-registered": + trust_mod.register(served.trust) + if judged_by == "strict-default-trusted": + served.trust.record(served.declared(), root=served.repo) + approval = _post_an_approval(served, BINDING_ID) + assert approval.get("ok") is True, approval + if judged_by in ("strict-default", "nothing-registered"): + assert approval["availability"] != intake_mod.APPROVAL_NOTICE + assert approval["availability"] == ( + trust_mod.APPROVED_UNTRUSTED_NOTICE) + else: + assert approval["availability"] == intake_mod.APPROVAL_NOTICE + assert trust_mod.is_registered() == (judged_by != "nothing-registered") + + +@pytest.mark.parametrize("sentence", ["UNTRUSTED_TURN_MESSAGE", + "UNTRUSTED_BINDING_REMEDY", + "APPROVED_UNTRUSTED_NOTICE"]) +def test_each_command_a_fixed_sentence_quotes_is_one_the_verb_takes( + served, sentence): + """The trust-state walk. A fixed sentence (a refused turn's, the rail's, + an approval's) names no repository and no binding, so it quotes each + command with placeholders. Filled in, each is one `opendox` parses: + `--repo-root` is required by every `model-binding` verb, and a sentence + that left it out would send the operator to a usage error.""" + import re + import shlex + + text = getattr(_trust_mod(), sentence) + quoted = re.findall(r'"(opendox [^"]*)"', text) + assert sorted(command.split()[2] for command in quoted) == [ + "list", "trust"] + for command in quoted: + argv = shlex.split(command.replace( + "", str(served.repo)).replace("", BINDING_ID)) + args = cli_mod.build_parser().parse_args(argv[1:]) + assert Path(args.repo_root) == served.repo + + # =========================================================================== # 5. the store's default home, the rail, and a served turn (each waited on # another draft, #69, #74 and #77, now all on `main`) From e05c475ced63b9e6d4b75390d08f017df8a5ca0f Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:24:05 +0000 Subject: [PATCH 42/45] T100 round 5: the cases that kill the round's last two mutants (plan 034) Two of round 5's mutants survived at db77c4ea, each a defence the cases did not yet reach: - "an id the catalog refuses is printed in a command": the refusals of a catalog-refused id carried REASON_UNSERVABLE, so `command_safe_id` was never the only guard. The hostile-id case now also asks the provider's own refusals (`require_admitted` with no verdict, and with a verdict for another binding) and `trust_command` itself: no command for such an id on any path. - "a binding trust cannot repair is told to trust": the over-long-label case checked the reason but not the absence of a command. Its refused turn, factory notice and `list` now print REMEDY_UNSERVABLE and no trust command, though the id itself is a valid one. Both mutants are killed with these cases (mutants-run-13). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_binding_trust.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 93deb9e4..d4fa70bd 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -1260,6 +1260,17 @@ def test_an_id_the_catalog_refuses_prints_no_command_and_is_never_trusted( assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() assert not trust_mod.verdict_for(served.declared(), root=served.repo).trusted + # Every other refusal path holds the same line, whatever its reason: the + # provider's own refusal of a binding no verdict covers, or one covering + # another binding, prints no command for such an id either. + assert trust_mod.trust_command(binding_id, str(served.repo)) is None + other = trust_mod.TrustVerdict.trusted_for( + _a_binding(), root=served.repo, basis=trust_mod.BASIS_HOST) + for verdict in (None, other): + with pytest.raises(trust_mod.BindingUntrusted) as refused: + trust_mod.require_admitted(served.declared(), verdict) + assert "opendox model-binding trust" not in str(refused.value) + assert trust_mod.REMEDY_UNSERVABLE in str(refused.value) assert not list(served.tmp.rglob("CANARY")) served.nothing_was_touched() @@ -1287,8 +1298,16 @@ def test_a_binding_the_catalog_refuses_is_never_trusted_nor_fails_the_start( assert list(port.catalog().entries) == [] with pytest.raises(trust_mod.BindingUntrusted) as refused: port.dispatch(_Envelope()) - assert trust_mod.REASON_UNSERVABLE in str(refused.value) - capsys.readouterr() + notice = capsys.readouterr().err + assert _cli("model-binding", "list", "--repo-root", + str(served.repo)) == 0 + listed = capsys.readouterr().out + # trust cannot repair it, so no command that trusts it is printed, + # whatever its id looks like (here, for the label, a valid one) + for text in (str(refused.value), notice, listed): + assert trust_mod.REASON_UNSERVABLE in text, text + assert trust_mod.REMEDY_UNSERVABLE in text, text + assert "opendox model-binding trust" not in text, text trust_mod.unregister() trust_mod.register(served.trust) store = served.state_dir / trust_mod.TRUST_FILENAME From a6c2a844e6a6515b51b0197b09832f314ded6869 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:15:44 +0000 Subject: [PATCH 43/45] T100 round 6: a binding the catalog cannot list is told its remedy, never to trust (plan 034) Copilot at openDox-code#82, review at e05c475c (r4175203889). A binding whose id or label the model catalog refuses is never trusted: verdict_for refuses it and recorded_for never records it. But two fixed sentences still sent the operator to `trust`, a command that can never succeed for it: - the console's model approval answered APPROVED_UNTRUSTED_NOTICE for an approved binding past the catalog's bounds; - a served turn on such a binding (its refusing port lists nothing) was refused with UNTRUSTED_TURN_MESSAGE. Each now names the actual remedy and no command that trusts: APPROVED_UNSERVABLE_NOTICE, judged first under any policy and reading no store, and UNSERVABLE_TURN_MESSAGE, chosen by doxbench_trust.turn_message_for from the refusing port's verdict, which is held within the released failure envelope's 500-character message bound. REMEDY_UNSERVABLE, which the refusals, the notice and `list` print, names the verbs that correct a binding, `edit` (the same id) or `remove` then `add` (a new id), each of which records trust for what it writes. Every case fails first at e05c475c (7 failed), and each change has a mutant. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 63 ++++++++++++++++++++-- src/opendox/serve_workbench.py | 18 ++++--- tests/test_model_binding_trust.py | 89 ++++++++++++++++++++++++++----- 3 files changed, 146 insertions(+), 24 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 1d80632f..00925ddd 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -128,6 +128,7 @@ fcntl = None __all__ = [ + "APPROVED_UNSERVABLE_NOTICE", "APPROVED_UNTRUSTED_NOTICE", "BASIS_CATALOG", "BASIS_HOST", @@ -139,6 +140,7 @@ "TrustPolicyNotRegistered", "TrustStoreRefused", "TrustVerdict", + "UNSERVABLE_TURN_MESSAGE", "UNTRUSTED_TURN_MESSAGE", "UntrustedBindingPort", "INTAKE_BROKER_UNTRUSTED", @@ -157,6 +159,7 @@ "shown", "trust_command", "trust_remedy", + "turn_message_for", "unregister", "unservable_because", ] @@ -250,11 +253,16 @@ f"to {doxbench_model.LABEL_MAX_LENGTH} characters and not blank, so no " "chat turn could use it") -#: What an operator is told to do about such a binding. Trust cannot help, so -#: no command that trusts it is printed. +#: What an operator is told to do about such a binding: the ACTUAL remedy. +#: Trust cannot help (`recorded_for` refuses such a binding), so no command +#: that trusts it is printed. The binding is corrected instead, by the verbs +#: that declare one, and each records trust for what it writes (Copilot at +#: openDox-code#82, r4175203889). REMEDY_UNSERVABLE = ( - "Trusting it cannot make it usable: declare it again with an id and a " - "label the model catalog accepts, then trust that binding") + "Trusting it cannot make it usable: correct it so the model catalog " + "accepts its id and its label, with \"opendox model-binding edit\" " + "(the same id) or \"opendox model-binding remove\" and then \"add\" (a " + "new id), each of which records trust for what it writes") def unsupported_platform() -> str | None: @@ -308,6 +316,21 @@ def reason_policy_failed(error: BaseException) -> str: "model-binding trust --repo-root \", and restart this " "console") +#: What a refused chat turn says when the binding this install declares is +#: one the model catalog cannot list (`REASON_UNSERVABLE`), so its port lists +#: nothing (Copilot at openDox-code#82, r4175203889). Trust cannot repair it, +#: so this sentence, unlike `UNTRUSTED_TURN_MESSAGE`, names no command that +#: trusts: it names the remedy. A FIXED sentence, as that one is, and held +#: within the released failure envelope's `message` bound (500 characters). +UNSERVABLE_TURN_MESSAGE = ( + "the model binding this install declares cannot be used, because the " + "model catalog cannot list its id or its label, so nothing was sent and " + "nothing was contacted. Trusting it cannot help. Run \"opendox " + "model-binding list --repo-root \" to see which binding, " + "correct it with \"opendox model-binding edit\" (the same id) or " + "\"remove\" and \"add\" (a new id), each of which records trust for " + "what it writes, and restart this console") + #: What the chat rail says when the catalog lists a declared model and none is #: available (#1144 16.3a; RULED openxFactory#656 comment 5962785556, item 2, @@ -345,6 +368,22 @@ def reason_policy_failed(error: BaseException) -> str: "console. The credential remains in the broker's custody and this act " "neither mints nor reads one") +#: What the console's model approval answers when the binding it approved is +#: one the model catalog cannot list (`REASON_UNSERVABLE`): approved, and +#: still unusable, and trust cannot repair it, so this names the remedy and +#: no command that trusts (Copilot at openDox-code#82, r4175203889). Whatever +#: policy is registered: a host's cannot make it usable either. A FIXED +#: sentence. +APPROVED_UNSERVABLE_NOTICE = ( + "the model is approved for this console, but the model catalog cannot " + "list its binding's id or its label, so no chat turn can use it, and " + "trusting it cannot help: \"opendox model-binding list --repo-root " + "\" shows which binding and why; correct it with \"opendox " + "model-binding edit\" (the same id) or \"opendox model-binding remove\" " + "and then \"add\" (a new id), each of which records trust for what it " + "writes, then restart this console. The credential remains in the " + "broker's custody and this act neither mints nor reads one") + #: What the console intake's hand-off is refused with when the trust policy #: does not admit the binding it is declaring (#1144 16.3a, T007 batch M). A #: FIXED sentence: an intake refusal's reason never carries what the request @@ -1318,6 +1357,22 @@ def __repr__(self) -> str: return f"" +def turn_message_for(port: object) -> str | None: + """The FIXED sentence a chat turn is refused with where `port` is the + refusing port the factory declared, or None for any other port (#1144 + 16.3a). A binding the catalog cannot list gets `UNSERVABLE_TURN_MESSAGE`, + which names its remedy, and every other untrusted binding + `UNTRUSTED_TURN_MESSAGE`, which names the command that trusts it. Telling + the operator to trust a binding `recorded_for` will never record would + send them to a command that cannot help (Copilot at openDox-code#82, + r4175203889).""" + if not isinstance(port, UntrustedBindingPort): + return None + if port.verdict.reason == REASON_UNSERVABLE: + return UNSERVABLE_TURN_MESSAGE + return UNTRUSTED_TURN_MESSAGE + + # --------------------------------------------------------------------------- # the seam # --------------------------------------------------------------------------- diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index fe24c10e..8d052062 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -1407,6 +1407,12 @@ def _approved_availability(self, binding) -> str: and openDox's strict default, until the binding is trusted, answers `doxbench_trust.APPROVED_UNTRUSTED_NOTICE`. + A BINDING THE MODEL CATALOG CANNOT LIST is judged first, under any + policy, and answers `doxbench_trust.APPROVED_UNSERVABLE_NOTICE`: no + policy can make it usable and `trust` refuses it, so the result names + the remedy and no command that trusts (Copilot at openDox-code#82, + r4175203889). That judgement reads no store. + ASKED OF A REGISTERED POLICY ONLY. This act is not one of the consumers that register openDox's default (`doxbench_trust.policy`), so where nothing is registered no binding has been judged trusted in @@ -1414,6 +1420,8 @@ def _approved_availability(self, binding) -> str: store no consumer has asked for.""" from opendox import doxbench_intake from opendox import doxbench_trust + if doxbench_trust.unservable_because(binding) is not None: + return doxbench_trust.APPROVED_UNSERVABLE_NOTICE if (doxbench_trust.is_registered() and doxbench_trust.verdict_for( binding, root=Path(self.checkout_root)).admits(binding)): @@ -2000,16 +2008,14 @@ def _session_text(rel, _root=source_root): # A BINDING NOT TRUSTED ON THIS MACHINE SAYS SO (#1144 16.3a): its # port is `doxbench_trust.UntrustedBindingPort`, and the refusal # carries that module's FIXED sentence, which names no binding and - # no path. The catalog's shape is closed, so this is where a turn - # reads why. + # no path: how to trust it, or, for a binding the catalog cannot + # list, how to correct it, since trust cannot (r4175203889). The + # catalog's shape is closed, so this is where a turn reads why. from opendox import doxbench_trust self._refuse_turn( validators, DOXBENCH_ERR_MODEL_UNAVAILABLE, turn_id, failure_kind=failure_kind, - message=(doxbench_trust.UNTRUSTED_TURN_MESSAGE - if isinstance(port, - doxbench_trust.UntrustedBindingPort) - else None)) + message=doxbench_trust.turn_message_for(port)) return effective_input_limit = doxbench_model.effective_limit_bytes( diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index d4fa70bd..fced1100 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -42,6 +42,7 @@ from __future__ import annotations import contextlib +import dataclasses import http.server import io import json @@ -2232,7 +2233,9 @@ def _post_an_approval(served, binding_id: str) -> dict: @pytest.mark.parametrize("judged_by", ["strict-default", "strict-default-trusted", - "host", "nothing-registered"]) + "host", "nothing-registered", + "unservable-strict-default", + "unservable-host"]) def test_an_approval_says_available_only_where_the_binding_is_trusted( served, judged_by): """The trust-state walk, at the console's approval. Approval is a @@ -2242,12 +2245,21 @@ def test_an_approval_says_available_only_where_the_binding_is_trusted( host whose policy admits the binding (a governed host's approval) and a binding this machine trusts read as before. Where nothing is registered, the act registers nothing and reads no store: no binding has been judged - trusted in that process.""" + trusted in that process. + + A binding the catalog cannot list (its label past the catalog's bound, + written after the intake) is approved and still unusable under ANY + policy, and trust cannot repair it, so the result names the remedy and + no command that trusts (Copilot at openDox-code#82, r4175203889).""" trust_mod = _trust_mod() _caps, answer = _served_intake(served, host_policy=_AdmitsTheIntake()) assert answer.get("error") is None, answer + if judged_by.startswith("unservable-"): + store = binding_mod.BindingStore(binding_mod.bindings_path( + served.repo)) + store.edit(dataclasses.replace(served.declared(), label="L" * 201)) trust_mod.unregister() - if judged_by == "host": + if judged_by.endswith("host"): trust_mod.register(_AdmitsTheIntake()) elif judged_by != "nothing-registered": trust_mod.register(served.trust) @@ -2255,7 +2267,11 @@ def test_an_approval_says_available_only_where_the_binding_is_trusted( served.trust.record(served.declared(), root=served.repo) approval = _post_an_approval(served, BINDING_ID) assert approval.get("ok") is True, approval - if judged_by in ("strict-default", "nothing-registered"): + if judged_by.startswith("unservable-"): + assert "model-binding trust" not in approval["availability"] + assert approval["availability"] == ( + trust_mod.APPROVED_UNSERVABLE_NOTICE) + elif judged_by in ("strict-default", "nothing-registered"): assert approval["availability"] != intake_mod.APPROVAL_NOTICE assert approval["availability"] == ( trust_mod.APPROVED_UNTRUSTED_NOTICE) @@ -2264,30 +2280,62 @@ def test_an_approval_says_available_only_where_the_binding_is_trusted( assert trust_mod.is_registered() == (judged_by != "nothing-registered") -@pytest.mark.parametrize("sentence", ["UNTRUSTED_TURN_MESSAGE", - "UNTRUSTED_BINDING_REMEDY", - "APPROVED_UNTRUSTED_NOTICE"]) +#: Each fixed sentence that quotes a `model-binding` command, with the verbs +#: it quotes in full (with their arguments) and those it only names. +FIXED_SENTENCES = { + "UNTRUSTED_TURN_MESSAGE": (["list", "trust"], []), + "UNTRUSTED_BINDING_REMEDY": (["list", "trust"], []), + "APPROVED_UNTRUSTED_NOTICE": (["list", "trust"], []), + "UNSERVABLE_TURN_MESSAGE": (["list"], ["edit"]), + "APPROVED_UNSERVABLE_NOTICE": (["list"], ["edit", "remove"]), + "REMEDY_UNSERVABLE": ([], ["edit", "remove"]), +} + + +@pytest.mark.parametrize("sentence", sorted(FIXED_SENTENCES)) def test_each_command_a_fixed_sentence_quotes_is_one_the_verb_takes( served, sentence): """The trust-state walk. A fixed sentence (a refused turn's, the rail's, an approval's) names no repository and no binding, so it quotes each command with placeholders. Filled in, each is one `opendox` parses: `--repo-root` is required by every `model-binding` verb, and a sentence - that left it out would send the operator to a usage error.""" + that left it out would send the operator to a usage error. A sentence for + a binding the catalog cannot list quotes no `trust` (r4175203889): it only + names the verbs that correct a binding.""" import re import shlex text = getattr(_trust_mod(), sentence) + in_full, named = FIXED_SENTENCES[sentence] quoted = re.findall(r'"(opendox [^"]*)"', text) - assert sorted(command.split()[2] for command in quoted) == [ - "list", "trust"] + assert sorted(command.split()[2] for command in quoted) == sorted( + in_full + named) for command in quoted: + if command.split()[2] in named: + assert command == f"opendox model-binding {command.split()[2]}" + continue argv = shlex.split(command.replace( "", str(served.repo)).replace("", BINDING_ID)) args = cli_mod.build_parser().parse_args(argv[1:]) assert Path(args.repo_root) == served.repo +@pytest.mark.parametrize("sentence", ["UNTRUSTED_TURN_MESSAGE", + "UNSERVABLE_TURN_MESSAGE"]) +def test_each_turn_sentence_fits_the_released_failure_envelope(sentence): + """A refused turn's sentence rides the RELEASED failure envelope, whose + `message` the schema bounds; one past it would fail the envelope's own + validation and lose its cause. Read from the released schema.""" + import yaml + + schema = yaml.safe_load((REPO_ROOT / "src" / "opendox" / "contracts" + / "schemas" + / "xfactory-workbench-chat-turn.schema.yaml" + ).read_text(encoding="utf-8")) + bound = schema["$defs"]["failure_v2"]["properties"]["message"] + assert 1 <= len(getattr(_trust_mod(), sentence)) <= bound["maxLength"] + + # =========================================================================== # 5. the store's default home, the rail, and a served turn (each waited on # another draft, #69, #74 and #77, now all on `main`) @@ -2493,10 +2541,15 @@ def get(self, _kind, _default=None): return _Conforms() -def test_a_served_turn_on_an_untrusted_binding_says_how_to_trust_it(served): +@pytest.mark.parametrize("binding", ["untrusted", "unservable"]) +def test_a_served_turn_on_an_untrusted_binding_says_how_to_trust_it(served, + binding): """A served turn naming the untrusted binding is refused `model_unavailable` with the fixed sentence that says how to trust it, - and nothing is contacted.""" + and nothing is contacted. Where the catalog cannot list the binding (a + label past its bound), trust cannot help, so the sentence names the + remedy and no command that trusts (Copilot at openDox-code#82, + r4175203889).""" import http.client from opendox import doxbench_hash, serve @@ -2508,7 +2561,9 @@ def test_a_served_turn_on_an_untrusted_binding_says_how_to_trust_it(served): / "plain-documents", served.tmp / "turn") git(repo, "config", "user.name", "fixture") git(repo, "config", "user.email", "fixture@example.invalid") - served.hand_write(served.record("env"), root=repo) + served.hand_write(served.record( + "env", **({"label": "L" * 201} if binding == "unservable" else {})), + root=repo) out = served.tmp / "turn-out" / "snapshot.json" generated, status = run_module( served.tmp, "opendox.cli", "generate", "--repo-root", str(repo), @@ -2566,5 +2621,11 @@ def buffer(kind, path, content): httpd.server_close() worker.join(timeout=10) assert body.get("error") == DOXBENCH_ERR_MODEL_UNAVAILABLE, body - assert body.get("message") == _trust_mod().UNTRUSTED_TURN_MESSAGE, body + if binding == "unservable": + assert "model-binding trust" not in body.get("message", ""), body + assert body.get("message") == _trust_mod().UNSERVABLE_TURN_MESSAGE, ( + body) + else: + assert body.get("message") == _trust_mod().UNTRUSTED_TURN_MESSAGE, ( + body) served.nothing_was_touched() From 42c98f9dd10ff6d2836625acd4d6723501355380 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:58:56 +0000 Subject: [PATCH 44/45] T100 round 7: the lock file is 0600 whatever the umask, and the approval reads the trust seam once (plan 034) Copilot at openDox-code#82, review at 735d0c14. - r4177946237. `os.open`'s mode is filtered by the umask. Under 0777 the lock file was born 000: the first record went through the descriptor it had open, and every later record was refused ("cannot be opened"), so the store was unusable. Once its owner and mode are judged, the lock file is set to exactly 0600 through that descriptor, as `_write` already sets the store's temporary file. - r4177946288. The approval asked `is_registered()` and then `verdict_for()`, which reads the seam twice. A host that unregistered between the two reads would have had openDox's default installed in its place by an act that promised not to, and the default's store's answer given as the host's. `doxbench_trust.registered_verdict_for` reads the registration once, under the seam's lock (`_registered_now`), registers nothing, and gives that policy's verdict alone. `verdict_for` and it share one judging path (`_judged`), with the catalog check first. Both cases fail at 735d0c14, and each change has a mutant. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 63 +++++++++++++++++++++++++++---- src/opendox/serve_workbench.py | 11 ++++-- tests/test_model_binding_trust.py | 50 ++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 00925ddd..8520060a 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -154,6 +154,7 @@ "refusal_message", "register", "register_default", + "registered_verdict_for", "require_admitted", "resolved_root", "shown", @@ -688,26 +689,54 @@ def unservable_because(binding) -> str | None: return None -def verdict_for(binding, *, root: Path | str) -> TrustVerdict: - """The registered policy's verdict on `binding` at `root`, held to it. - - What every consumer asks before it uses a binding read from a repository. - A binding the catalog refuses is untrusted before any policy is asked - (`unservable_because`). A policy that raises trusts nothing, and its - words are not repeated (`reason_policy_failed`).""" +def _judged(policy_of, binding, *, root: Path | str) -> TrustVerdict: + """The verdict of the policy `policy_of()` answers, on `binding` at + `root`, held to it. A binding the catalog refuses is untrusted before any + policy is asked (`unservable_because`). A policy that raises trusts + nothing, and its words are not repeated (`reason_policy_failed`).""" unservable = unservable_because(binding) if unservable is not None: return TrustVerdict.untrusted_for(binding, root=root, basis=BASIS_CATALOG, reason=unservable) try: - verdict = policy().verdict(binding, root=root) + verdict = policy_of().verdict(binding, root=root) except Exception as error: # noqa: BLE001 - a policy that fails trusts nothing return TrustVerdict.untrusted_for(binding, root=root, basis=BASIS_HOST, reason=reason_policy_failed(error)) return _held_to(binding, verdict, root=root) +def verdict_for(binding, *, root: Path | str) -> TrustVerdict: + """The registered policy's verdict on `binding` at `root`, held to it: + openDox's strict default registered first where nothing is (`policy`). + + What every consumer asks before it uses a binding read from a repository. + A binding the catalog refuses is untrusted before any policy is asked + (`unservable_because`). A policy that raises trusts nothing, and its + words are not repeated (`reason_policy_failed`).""" + return _judged(policy, binding, root=root) + + +def registered_verdict_for(binding, *, + root: Path | str) -> TrustVerdict | None: + """The verdict of the policy registered NOW on `binding` at `root`, held + to it, or None where nothing is registered. It REGISTERS NOTHING, for an + act that is not one of the consumers that register openDox's default + (the console's model approval). + + ONE READ OF THE SEAM (Copilot at openDox-code#82, r4177946288). The + registration is read once, under the seam's lock, and the verdict is + that policy's alone. Asking `is_registered()` and then `verdict_for()` + would read the seam twice: a host that unregistered between the two + would have the default installed by an act that promised not to, and + its answer given in the host's place.""" + registered = _registered_now() + if registered is None: + return None + return _judged(lambda: registered, binding, root=root) + + def recorded_for(binding, *, root: Path | str) -> TrustVerdict: """Ask the registered policy to RECORD trust for `binding` at `root`, and return the verdict, which admits exactly `binding`. @@ -1007,6 +1036,12 @@ def _store_locked(state: Path): own=True, directory=False) if reason is not None: raise _store_refused(path, reason) + # EXACTLY 0600, WHATEVER THE UMASK (Copilot at openDox-code#82, + # r4177946237). `os.open`'s mode is filtered by the umask, so under + # a restrictive one the file is born 000: this open succeeds, and + # every later one fails, which would leave the store unusable. Set + # through the descriptor already judged, as `_write` sets the store. + os.fchmod(descriptor, 0o600) try: _lock_exclusively(descriptor) except OSError as error: @@ -1462,6 +1497,18 @@ def current() -> Any: return registered +def _registered_now() -> Any: + """The registered policy, read ONCE under the seam's lock, or None. It + registers nothing; reading the default closes its window, as `current` + does.""" + global _default_read + with _lock: + registered = _registered + if registered is not None and _is_default: + _default_read = True + return registered + + def policy() -> Any: """What a consumer asks: openDox's strict default registered where no host has registered one, then the registered policy.""" diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index 8d052062..22392141 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -1417,14 +1417,17 @@ def _approved_availability(self, binding) -> str: consumers that register openDox's default (`doxbench_trust.policy`), so where nothing is registered no binding has been judged trusted in this process, and the result says it is not, rather than read a - store no consumer has asked for.""" + store no consumer has asked for. The registration is read ONCE, and + the verdict is that policy's (`registered_verdict_for`), so a host + that unregisters meanwhile never has the default installed in its + place by this act (Copilot at openDox-code#82, r4177946288).""" from opendox import doxbench_intake from opendox import doxbench_trust if doxbench_trust.unservable_because(binding) is not None: return doxbench_trust.APPROVED_UNSERVABLE_NOTICE - if (doxbench_trust.is_registered() - and doxbench_trust.verdict_for( - binding, root=Path(self.checkout_root)).admits(binding)): + verdict = doxbench_trust.registered_verdict_for( + binding, root=Path(self.checkout_root)) + if verdict is not None and verdict.admits(binding): return doxbench_intake.APPROVAL_NOTICE return doxbench_trust.APPROVED_UNTRUSTED_NOTICE diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index fced1100..b764a8a1 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -1101,6 +1101,30 @@ def no_lock(*_args, **_kwargs): assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() +def test_a_restrictive_umask_leaves_the_store_usable(served): + """Copilot at openDox-code#82 (r4177946237). `os.open`'s mode is + filtered by the umask: under 0777 the lock file was born 000, the first + record went through the descriptor it had open, and every later one was + refused ("cannot be opened"). The lock file and the store are each + exactly 0600 whatever the umask, and every record after the first + succeeds.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + binding = served.declared() + second = served.fresh_repository("r2") + previous = os.umask(0o777) + try: + served.trust.record(binding, root=served.repo) + served.trust.record(binding, root=second) + finally: + os.umask(previous) + for name in (trust_mod.TRUST_FILENAME, trust_mod.TRUST_LOCK_FILENAME): + assert stat.S_IMODE((served.state_dir / name).stat().st_mode) == ( + 0o600), name + assert served.trust.verdict(binding, root=served.repo).trusted + assert served.trust.verdict(binding, root=second).trusted + + # --- every command printed for an operator to paste (r4174783197) ----------- #: Repository directory names, each holding what a shell acts on: a command @@ -2320,6 +2344,32 @@ def test_each_command_a_fixed_sentence_quotes_is_one_the_verb_takes( assert Path(args.repo_root) == served.repo +def test_an_approval_reads_the_trust_seam_once(served, monkeypatch): + """Copilot at openDox-code#82 (r4177946288). The approval's verdict is + the policy registered when it reads the seam, read ONCE. A host that + unregisters between two reads (here, a registration check that answers + yes and tears the host down) must not have openDox's default installed + in its place by the approval, nor that store's answer given as the + host's: the store trusts the binding, and the host does not.""" + trust_mod = _trust_mod() + _caps, answer = _served_intake(served, host_policy=_AdmitsTheIntake()) + assert answer.get("error") is None, answer + served.trust.record(served.declared(), root=served.repo) + host = _Declines() + trust_mod.unregister() + trust_mod.register(host) + + def registered_then_torn_down(): + trust_mod.unregister() + return True + + monkeypatch.setattr(trust_mod, "is_registered", registered_then_torn_down) + approval = _post_an_approval(served, BINDING_ID) + assert approval.get("ok") is True, approval + assert approval["availability"] == trust_mod.APPROVED_UNTRUSTED_NOTICE + assert trust_mod.current() is host + + @pytest.mark.parametrize("sentence", ["UNTRUSTED_TURN_MESSAGE", "UNSERVABLE_TURN_MESSAGE"]) def test_each_turn_sentence_fits_the_released_failure_envelope(sentence): From adb19f1efe7777860e583c6d3d93897cd715aaa6 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:35:16 +0000 Subject: [PATCH 45/45] T100 round 8: the store and its lock file are opened without waiting on a FIFO (plan 034) Copilot at openDox-code#82, review at 42c98f9d (r4178064601). A read-only open of a FIFO blocks until a writer comes. With a FIFO in the store's place, `list`, the start and every verdict waited forever, before the descriptor's type check could refuse it. The store and its lock file are now opened O_NONBLOCK, so a FIFO is refused by the type check on the descriptor that was opened, never by a second look at the path. A regular file reads the same either way. O_NONBLOCK joins the primitives the platform check names. The case fails at 42c98f9d: the store's verdict waited on the FIFO (red run). The lock file's case passes there too, because Linux opens a FIFO read-write without waiting; it holds the refusal on platforms that wait. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 23 ++++++++++++----- tests/test_model_binding_trust.py | 42 ++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 8520060a..d4ff89f8 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -270,15 +270,16 @@ def unsupported_platform() -> str | None: """Why this platform cannot keep the per-machine store, or None (Copilot at openDox-code#82, r4173876800; #69's `runtime.bundle. unsupported_platform` names its own gaps the same way). The store is - judged by its owner's uid, opened and made without following a link, - made relative to its parent's descriptor, written with `fchmod`, and - recorded under a file lock. Where one of those is missing, nothing can be - trusted, and the store says so by name rather than fail on the first - missing name.""" + judged by its owner's uid, opened and made without following a link and + without waiting on what it opened (a FIFO, r4178064601), made relative + to its parent's descriptor, written with `fchmod`, and recorded under a + file lock. Where one of those is missing, nothing can be trusted, and + the store says so by name rather than fail on the first missing name.""" missing = [name for name, present in ( ("os.getuid", hasattr(os, "getuid")), ("os.O_DIRECTORY", hasattr(os, "O_DIRECTORY")), ("os.O_NOFOLLOW", hasattr(os, "O_NOFOLLOW")), + ("os.O_NONBLOCK", hasattr(os, "O_NONBLOCK")), ("os.fchmod", hasattr(os, "fchmod")), ("mkdir with dir_fd", os.mkdir in getattr(os, "supports_dir_fd", ())), ("fcntl.flock", fcntl is not None), @@ -1022,8 +1023,11 @@ def _store_locked(state: Path): nothing is recorded.""" path = state / TRUST_LOCK_FILENAME try: + # NONBLOCKING, so a FIFO in the lock file's place is refused by the + # descriptor's own type below rather than waited on (r4178064601). descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW - | getattr(os, "O_CLOEXEC", 0), 0o600) + | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0), + 0o600) except OSError: if os.path.lexists(path): reason = _unsafe_because(os.lstat(path), uid=os.getuid(), @@ -1229,7 +1233,14 @@ def _read(self, state: Path) -> dict[tuple[str, str], str]: _refuse_an_unsafe_tree(state, existing_only=True) path = state / TRUST_FILENAME try: + # NONBLOCKING (Copilot at openDox-code#82, r4178064601): a FIFO + # in the store's place would otherwise hold this open until some + # writer came, and `list`, the start and every verdict with it. + # Opened so, it is refused by the descriptor's own type below, + # judged on what was opened and not on a second look at the + # path. A regular file reads the same either way. descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW + | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0)) except FileNotFoundError: return {} diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index b764a8a1..c60ce9e7 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -985,7 +985,7 @@ def __getattr__(self, name): @pytest.mark.parametrize("missing", ["getuid", "O_NOFOLLOW", "O_DIRECTORY", - "fchmod", "fcntl"]) + "O_NONBLOCK", "fchmod", "fcntl"]) def test_a_platform_without_the_stores_primitives_trusts_nothing( served, capsys, monkeypatch, missing): """Copilot at openDox-code#82 (r4173876800). Where the platform lacks a @@ -1101,6 +1101,46 @@ def no_lock(*_args, **_kwargs): assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() +@pytest.mark.parametrize("which", ["store", "lock"]) +def test_a_fifo_in_the_stores_place_is_refused_without_waiting(served, + which): + """Copilot at openDox-code#82 (r4178064601). A FIFO where the store or + its lock file belongs is refused by name, as not a regular file, and + nothing waits on it: a read-only open of a FIFO otherwise blocks until a + writer comes, holding `list`, the start and every verdict with it. Asked + on a thread, so a store that waited fails this case rather than hang the + suite.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + binding = served.declared() + served.state_dir.mkdir(mode=0o700) + fifo = served.state_dir / (trust_mod.TRUST_FILENAME if which == "store" + else trust_mod.TRUST_LOCK_FILENAME) + os.mkfifo(fifo, 0o600) + answers: dict = {} + + def ask(): + answers["verdict"] = served.trust.verdict(binding, root=served.repo) + try: + served.trust.record(binding, root=served.repo) + except trust_mod.TrustStoreRefused as refusal: + answers["record"] = refusal + + worker = threading.Thread(target=ask, daemon=True) + worker.start() + worker.join(timeout=20) + waited = worker.is_alive() + if waited: + # Release the reader a waiting store left behind, so the case ends. + os.close(os.open(fifo, os.O_WRONLY | os.O_NONBLOCK)) + assert not waited, "the trust store waited on a FIFO" + if which == "store": + assert not answers["verdict"].trusted + assert "is not a regular file" in answers["verdict"].reason + assert "is not a regular file" in str(answers["record"]) + assert stat.S_ISFIFO(fifo.lstat().st_mode) + + def test_a_restrictive_umask_leaves_the_store_usable(served): """Copilot at openDox-code#82 (r4177946237). `os.open`'s mode is filtered by the umask: under 0777 the lock file was born 000, the first