diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 4702ff34..1d58ab1e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -279,6 +279,30 @@ jobs: # xfail as a skip. T084 landed (openDox-code#77, `e49b17c3`), T082 # merged `main` and removed the five markers in that merge, and the # five now pass. So T082 adds no skip. + # + # 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. + # BACK FROM 14 TO 13: #74 landed on `main` (`9a490405`) and reached + # this branch through #64's merge of `main` `1130e996` (`63e534cb`), + # so the rail case runs and passes, with the rail's trust line it + # waited on. + # BACK FROM 13 TO 12: #69 landed on `main` (`5e7ab003`) and reached + # this branch through #64's final head (`04bcb68e`), so the store's + # default-home case runs and passes. One remains: #77's. + # BACK FROM 12 TO 11: #77 landed on `main` (`e49b17c3`) and this + # branch merged it, so the served-turn case runs and passes. All + # three are in, and the pin is `main`'s again. T100 moves neither + # floor: the floors are T082's re-pin above (3977 / 3966, #76), and + # T100 only adds cases, so its selected and passed counts sit above + # them. EXPECT_SKIPPED: "11" run: | python3 - <<'PY' > triple.env diff --git a/src/opendox/cli_model_binding.py b/src/opendox/cli_model_binding.py index 51a9709e..ce483e98 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,31 @@ 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, 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: + 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, @@ -72,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). @@ -81,53 +123,143 @@ 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 - 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']}") + 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 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'])}") + 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[binding.id]}") + print(f" console {consoles[binding.id]}") return 0 +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 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 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}). {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 + + 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 +290,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 +311,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.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) - 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 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) + 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 +525,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 +561,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 7cb15c1e..a045e79c 100644 --- a/src/opendox/doxbench_install.py +++ b/src/opendox/doxbench_install.py @@ -264,20 +264,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] = {} @@ -297,6 +302,77 @@ 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, + 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). + + 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. + + 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. + + 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, + 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, 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, bindings=bindings) + + 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, @@ -337,7 +413,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 @@ -368,4 +451,6 @@ def declared_model_port_factory(session_root: Path | str, *, if (harness_present or harness_installed)(): return model_port_factory(Path(session_root), spawn=spawn) return no_model_port_factory - return brokered_model_port_factory(approved[0]) + return trust_gated_model_port_factory(approved[0], + checkout_root=checkout_root, + bindings_path=bindings_path) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 70b77462..dba66480 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -121,6 +121,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 @@ -864,7 +865,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 @@ -886,7 +887,17 @@ def _broker_operation(binding, operation: str, read, *, runner, 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.""" + and its answer is read here, as before. + + 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, + and before either runner's branch below. `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) if runner is subprocess_broker_runner: result, failure = _run_broker(argv, source=source, @@ -913,7 +924,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. @@ -943,9 +954,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: @@ -966,7 +980,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. @@ -1013,7 +1027,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: @@ -1036,7 +1050,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 @@ -1046,7 +1060,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: @@ -1058,7 +1072,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 @@ -1067,7 +1082,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: @@ -1137,7 +1152,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. @@ -1167,7 +1182,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( @@ -1179,6 +1202,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) @@ -1539,6 +1563,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, @@ -1555,6 +1580,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 @@ -1584,8 +1613,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) @@ -1624,12 +1657,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 @@ -1687,7 +1728,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: @@ -1768,8 +1809,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..d4ff89f8 --- /dev/null +++ b/src/opendox/doxbench_trust.py @@ -0,0 +1,1540 @@ +"""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. + +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 +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, `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 +openxFactory and never what a destination assembles (RULED OQ-C). +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import errno +import hashlib +import json +import os +import re +import shlex +import stat +import sys +import threading +from collections.abc import Mapping +from pathlib import Path +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 +except ImportError: # pragma: no cover - exercised through _lock_exclusively + fcntl = None + +__all__ = [ + "APPROVED_UNSERVABLE_NOTICE", + "APPROVED_UNTRUSTED_NOTICE", + "BASIS_CATALOG", + "BASIS_HOST", + "BASIS_MACHINE_TRUST", + "BindingUntrusted", + "MachineTrust", + "TRUST_FILENAME", + "TrustPolicyAlreadyRegistered", + "TrustPolicyNotRegistered", + "TrustStoreRefused", + "TrustVerdict", + "UNSERVABLE_TURN_MESSAGE", + "UNTRUSTED_TURN_MESSAGE", + "UntrustedBindingPort", + "INTAKE_BROKER_UNTRUSTED", + "REASON_UNSERVABLE", + "REMEDY_UNSERVABLE", + "binding_digest", + "command_safe_id", + "current", + "is_registered", + "policy", + "refusal_message", + "register", + "register_default", + "registered_verdict_for", + "require_admitted", + "resolved_root", + "shown", + "trust_command", + "trust_remedy", + "turn_message_for", + "unregister", + "unservable_because", +] + +# --------------------------------------------------------------------------- +# 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 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. +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 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" + +# --------------------------------------------------------------------------- +# 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") + +#: 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") + + +#: 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: 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: 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: + """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 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), + ) 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.""" + 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. +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 --repo-root " + "\" to see which binding and why, trust it with \"opendox " + "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, +#: "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 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 " + "--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'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 +#: 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 TrustNotRecorded(binding_mod.BindingRefused): + """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): + """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 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" + 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}") + + +# --------------------------------------------------------------------------- +# 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, *, + bindings: str | None = None) -> str: + """The refusal of an untrusted binding, BY NAME: the id, the reason, and + 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. " + f"{trust_remedy(binding_id, root, reason, bindings=bindings)}") + + +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)) + + +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 + and verdict.digest == binding_digest(binding)): + return verdict + return TrustVerdict.untrusted_for(binding, root=root, basis=BASIS_HOST, + 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 _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_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`. + + 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 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). + + 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) + 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). + # "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, + 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, + 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_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" + 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 {_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_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_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 " + f"{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 _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. 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 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, *, + 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): + 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) + 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) + + +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: + # 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 + | 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(), + 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) + # 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: + 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 +# --------------------------------------------------------------------------- + + +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`). + + 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: + 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`. 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: + 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( + "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: + """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() + 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 + 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: + 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, + 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) + 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) + + 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]: + """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: + # 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 {} + 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 + 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 + 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__ = ("_bindings", "_catalog", "_verdict") + + def __init__(self, catalog, verdict: TrustVerdict, *, + bindings: str | None = None) -> 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 + self._bindings = bindings + + @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, + bindings=self._bindings)) + + 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 +# --------------------------------------------------------------------------- + +#: 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. +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 _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.""" + 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 7e7a0abc..22392141 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -1165,6 +1165,26 @@ 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 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: 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 + 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, + doxbench_trust.INTAKE_BROKER_UNTRUSTED) + return accepts_secret = ( declared["kind"] == doxbench_binding.AUTH_KIND_API_KEY) source = (_CredentialStream(self.rfile, length) @@ -1179,7 +1199,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 @@ -1372,9 +1395,42 @@ 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`. + + 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 + this process, and the result says it is not, rather than read a + 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 + 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 + 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 @@ -1952,8 +2008,17 @@ 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: 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.turn_message_for(port)) return effective_input_limit = doxbench_model.effective_limit_bytes( diff --git a/src/opendox/web/views/doxbench-chat.js b/src/opendox/web/views/doxbench-chat.js index 7b4d66b9..453cdec1 100644 --- a/src/opendox/web/views/doxbench-chat.js +++ b/src/opendox/web/views/doxbench-chat.js @@ -353,6 +353,36 @@ export function noModelConfiguredRemedy(stateValue) { return NO_MODEL_CONFIGURED_REMEDY; } +// A DECLARED MODEL, NONE OF IT AVAILABLE, SAYS HOW TO TRUST IT (#1144 16.3a; +// plan 034 T100; RULED openxFactory#656 comment 5962785556, item 2: "make the +// rail say how to trust"). A binding read from the served repository is used +// 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 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 +// has its own remedy), and no intake affordance is rendered (where a host +// offers intake it supplies its own remedy, as for 16.4's line). The Python +// 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 --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; + if (stateValue.models === null) return null; + const models = stateValue.models || []; + if (models.length === 0) return null; + if (models.some((entry) => entry.available === true)) return null; + if (stateValue.intakeOffered === true) return null; + return UNTRUSTED_BINDING_REMEDY; +} + // T104 F10-2/4: the over-bound paste, refused VISIBLY. The pure model // refuses by returning the IDENTICAL state (refused, never truncated) and // render()'s unconditional value reassignment reverts the DOM — correct, but @@ -1147,6 +1177,9 @@ export function mountDoxBenchChatRail(host, options = {}) { // text through `announce` below, once per change. const noModelNote = el("div", "doxchat-no-model"); noModelNote.hidden = true; + // 16.3a's visible line (`untrustedBindingRemedy`), announced as 16.4's is. + const untrustedNote = el("div", "doxchat-untrusted"); + untrustedNote.hidden = true; const transcriptList = el("ul", "doxchat-transcript"); transcriptList.setAttribute("aria-label", "chat transcript"); const failureNote = el("div", "doxchat-failure"); @@ -1203,7 +1236,8 @@ export function mountDoxBenchChatRail(host, options = {}) { sendBtn.setAttribute("aria-describedby", unavailableNote.id); host.append(loadedSelect, loadedNote, loadedEmpty, loadedFull, subjectInput, - unavailableNote, noModelNote, transcriptList, contextNote, + unavailableNote, noModelNote, untrustedNote, transcriptList, + contextNote, retryNote, cardsHost, announce, failureNote, composer, disclosure, sendrow); @@ -1529,6 +1563,12 @@ export function mountDoxBenchChatRail(host, options = {}) { noModelNote.textContent = remedyText; if (remedyText) announce.textContent = remedyText; } + const trustText = untrustedBindingRemedy(state) || ""; + if (untrustedNote.textContent !== trustText) { + untrustedNote.hidden = !trustText; + untrustedNote.textContent = trustText; + if (trustText) announce.textContent = trustText; + } selector.value = defaultSelectorValue(state); transcriptList.textContent = ""; for (const turn of transcriptWindow(state)) { diff --git a/tests/fixtures/web_boundary_census.yaml b/tests/fixtures/web_boundary_census.yaml index e6e7b298..ebbbd252 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: 18486} + A: {files: 26, loc: 18526} B: {files: 1, loc: 73} C: {files: 14, loc: 12757} "?": {files: 1, loc: 1586} @@ -278,8 +278,8 @@ files: - path: views/doxbench-chat.js class: A - loc: 1890 - note: "doxBench chat rail; imports only the pure chat model" + 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_chat_model_configuration.py b/tests/test_chat_model_configuration.py index 96b992b5..6ffd0067 100644 --- a/tests/test_chat_model_configuration.py +++ b/tests/test_chat_model_configuration.py @@ -197,12 +197,31 @@ def _binding(**overrides) -> binding_mod.ModelProviderBinding: def _declare(checkout: Path, binding=None) -> binding_mod.ModelProviderBinding: """A binding declared in the checkout's bindings document, as - `opendox model-binding add` declares one.""" + `opendox model-binding add` declares one: written, and trusted on this + machine (#1144 16.3a; plan 034 T100), in the private store + `_a_private_trust_store` registers.""" + from opendox import doxbench_trust + binding = binding or _binding() + doxbench_trust.policy().record(binding, root=checkout) binding_mod.BindingStore(binding_mod.bindings_path(checkout)).add(binding) return binding +@pytest.fixture(autouse=True) +def _a_private_trust_store(tmp_path_factory): + """No case reads or writes this machine's own model-binding trust + (#1144 16.3a; plan 034 T100): each registers the strict store over a + state directory of its own.""" + from opendox import doxbench_trust + + doxbench_trust.unregister() + doxbench_trust.register(doxbench_trust.MachineTrust( + state_dir=tmp_path_factory.mktemp("trust") / "st")) + yield + doxbench_trust.unregister() + + def _resolve(tmp_path: Path, checkout: Path, *, harness: bool, spawn=None): return inst.declared_model_port_factory( tmp_path / "sessions", checkout_root=checkout, spawn=spawn, diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py new file mode 100644 index 00000000..c60ce9e7 --- /dev/null +++ b/tests/test_model_binding_trust.py @@ -0,0 +1,2721 @@ +"""#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 as well as through the setting, so a case never depends on where +openDox-code#69's `config.state_dir` would put it; one case reads the setting +itself (`test_the_default_store_lives_in_the_settings_state_directory`). +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 dataclasses +import http.server +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 --repo-root {root.resolve()} " + f"{BINDING_ID}") + + +# =========================================================================== +# 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. 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}", + 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) == 1 + 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 + 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())}] + + +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 + + +_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() + + +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", + "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 + 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_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", [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, 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() + 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.""" + 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() + + +@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 + 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 +#: 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 + # 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() + + +@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()) + 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 + 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 ------------------------------------------------------ + + +class _HostGate: + """A host's gate, registered at openDox's gate seam, as a host that + offers the console intake registers one (openDox-code#77, T084: standalone, + with no gate-record writer, the intake is refused; `5961364221` item 1). + openDox's default's names, with a record writer of its own.""" + + def __init__(self) -> None: + from opendox import column_seams as cs + from opendox import default_columns as dc + + for name in (*cs.GATE_CALLABLES, *cs.GATE_VALUES): + setattr(self, name, getattr(dc.GATE, name)) + self.written = [] + self.write_gate_action_record = lambda gate, records_dir, record: ( + self.written.append(record) or Path(records_dir) / "record.yaml") + + +def _served_intake(served, *, host_policy=None): + """A stand-in host that offers the console intake: a host's gate + registered (#77), 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. The host registers a TRUST policy + only where `host_policy` names one.""" + import http.client + + from opendox import column_seams, 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) + column_seams.gate.unregister() + column_seams.gate.register(_HostGate()) + try: + return _post_an_intake(served, snapshot, http.client, serve) + finally: + column_seams.gate.unregister() + + +def _post_an_intake(served, snapshot, client, serve): + 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 = 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 = 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() + + +class _TrustsEveryBinding: + """A host policy that trusts every BINDING, and says nothing of the + console intake: it has no `intake_verdict`.""" + + 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) + + +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) + + +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, + "subclass-store-refuses": _SubclassStoreRefuses} + + +@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 + if policy == "refuses": + assert "BindingRefused" in captured.err + if policy in ("store-refuses", "subclass-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"]) +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 --------------------------------------------------------- + + +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 (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 + + 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( + 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 intake_verdict(self, binding, *, root): + return self.verdict(binding, root=root) + + +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())) + 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"]) +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) + + +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) + + +# --- 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", + "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 + 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. + + 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.endswith("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.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) + else: + assert approval["availability"] == intake_mod.APPROVAL_NOTICE + assert trust_mod.is_registered() == (judged_by != "nothing-registered") + + +#: 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. 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) == 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 + + +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): + """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`) +# =========================================================================== + +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) + + +_VIEWS = REPO_ROOT / "src" / "opendox" / "web" / "views" +_RAIL = _VIEWS / "doxbench-chat.js" + +#: 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; + let release; + const gate = new Promise((res) => { release = res; }); + const rail = mountDoxBenchChatRail(host, { + scopeKey: KEY, + transports: { catalog: async () => { await gate; return catalog(); }, + chatTurn: async () => { turns += 1; return null; } }, + editorState }); + // 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; + }; + 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)); +""" + + +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") + 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 + # 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'" + 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} + + +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.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. 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 + 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", **({"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), + "--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 + 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() diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index eb990497..02cec511 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -110,6 +110,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, @@ -670,7 +708,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) @@ -692,7 +730,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 @@ -705,6 +743,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), @@ -732,7 +773,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"] # =========================================================================== @@ -754,7 +798,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 @@ -782,7 +826,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 @@ -801,7 +845,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 @@ -814,7 +858,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 @@ -843,16 +887,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 @@ -910,7 +954,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 @@ -1089,7 +1133,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) @@ -1117,7 +1161,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 @@ -1128,7 +1172,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 @@ -1136,7 +1180,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 @@ -1188,6 +1232,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() @@ -1230,6 +1275,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() @@ -1308,7 +1356,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: @@ -1339,7 +1387,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 @@ -1383,7 +1431,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 @@ -1603,7 +1651,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": []} @@ -1793,7 +1841,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 @@ -1824,7 +1874,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"}]} @@ -2413,7 +2463,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 -------------------- @@ -2539,14 +2589,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] @@ -2571,7 +2621,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 @pytest.mark.parametrize("which", ["raw-key-shape", "past-the-url-bound"]) @@ -2596,7 +2646,8 @@ def test_a_broker_reference_the_record_would_refuse_is_malformed(tmp_path, binding = _broker_binding(script) source = io.StringIO("x") with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.hand_off_credential(binding, source) + provider_mod.hand_off_credential(binding, source, + trust=_trusted(binding)) assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED assert reference not in str(caught.value) @@ -2641,7 +2692,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 @@ -2709,7 +2760,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) @@ -2911,7 +2962,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 @@ -3002,7 +3053,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) @@ -3024,7 +3075,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"] == ( @@ -3090,7 +3141,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) @@ -3179,7 +3230,7 @@ def test_an_answer_http_client_cannot_read_is_unreachable_and_keeps_no_key( port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), runner=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) @@ -3306,7 +3357,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 @@ -3421,7 +3472,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" @@ -3452,7 +3503,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: @@ -3651,7 +3702,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 @@ -3690,7 +3741,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 @@ -3851,13 +3902,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))), } @@ -3986,7 +4037,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) @@ -4308,7 +4361,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) @@ -4356,7 +4409,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")) @@ -4408,6 +4461,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"