From 3e4958ab85a6807dfb50c72018b7a659d2647622 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:38:28 +0000 Subject: [PATCH 01/16] T100 follow-on: the trust store's review findings (plan 034) The holder's adversarial review of openDox-code#82 (T100, landed as 38d3350e) found 19 issues and 2 older ones. This change acts on A1 to A19 except A14, and on N1 and N2. The rulings are on openxFactory#656 comment 5982436447: A2 "Refuse in-repo programs" and A14 "Served repo only, limit" (Brett Heap), and A8 to follow F16.1's ratified text (the holder). - A1: a `trust` command is printed only where `trust` can repair the refusal (TRUST_REPAIRS, trust_can_repair). Elsewhere the store's, platform's or host's own reason is printed, with REMEDY_NOT_BY_TRUST. This adds UNTRUSTABLE_TURN_MESSAGE and APPROVED_UNTRUSTABLE_NOTICE. - A2: a broker command naming a file inside the served repository is refused by name (in_repository_program, REASON_IN_REPOSITORY, REMEDY_IN_REPOSITORY). It is refused where trust is recorded (recorded_for) and wherever it is judged (_judged, intake_verdict_for, require_admitted). - A3: the start passes over a binding the model catalog cannot list, as it passes over a pending one, and says so by name. `list` mirrors it. - A4: the brokered port prints one fixed [model-provider] line, naming the binding and its fixed diagnostic, as it turns unavailable. - A5: the console intake is offered only where the registered trust policy could admit its hand-off (intake_admissible). Otherwise the reason is INTAKE_NOT_ADMISSIBLE. - A6: set-credential's refusal prints the command for the document it read. - A7: only a link, an owner or a mode is blamed on another user. A torn, newer or foreign store, and a directory this user cannot write, each name their cause and a recovery. - A8: a state directory that is itself a symbolic link trusts nothing. - A9: cases pin the store rules the reviewer's mutants showed unpinned. - A10: printed paths are shown escaped. - A11: a failed edit re-records the trusted form and is refused by name. - A12: a verdict is a TrustVerdict exactly, in the seam, the gate and the provider's catalog. - A13: policy() is one locked operation, asked inside recorded_for's refusal net. - A15: the root conftest gives the session, and each case, a scratch OPENDOX_STATE_DIR, and empties the trust seam after each case. - A16: the digest scheme is versioned (DIGEST_SCHEME_FIELDS), and a digest of another scheme reads REASON_RECORD_FORM. - A17: the state directory is fsynced after the replace. - A18: _make_private_directories judges its starting directory by descriptor. - A19: an approval with nothing registered asks openDox's default without registering it. - N1: the bindings and declarations documents are neither read nor written through a symbolic link a clone could carry. - N2: an unreadable settings document (permissions, not UTF-8, nested too deeply) is refused by name. The JS twin of UNTRUSTED_BINDING_REMEDY moves with it, on its one line, so census A is unchanged. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- conftest.py | 47 + src/opendox/cli_model_binding.py | 91 +- src/opendox/doxbench_binding.py | 66 +- src/opendox/doxbench_install.py | 33 +- src/opendox/doxbench_intake.py | 21 +- src/opendox/doxbench_provider.py | 34 +- src/opendox/doxbench_trust.py | 530 +++++++++-- src/opendox/serve_workbench.py | 43 +- src/opendox/web/views/doxbench-chat.js | 2 +- tests/test_capability_honesty.py | 25 +- tests/test_model_binding_trust.py | 1145 +++++++++++++++++++++++- 11 files changed, 1905 insertions(+), 132 deletions(-) diff --git a/conftest.py b/conftest.py index 982f978d..9e64d309 100644 --- a/conftest.py +++ b/conftest.py @@ -13,9 +13,12 @@ from __future__ import annotations +import itertools import sys from pathlib import Path +import pytest + SRC = Path(__file__).resolve().parent / "src" if SRC.is_dir(): @@ -102,3 +105,47 @@ class _SuiteProfile: if _domain_profile is not None and not _domain_profile.is_registered(): _domain_profile.register(_SuiteProfile()) + + +# --------------------------------------------------------------------------- +# NO CASE READS OR WRITES THIS MACHINE'S TRUST (T100 follow-on, A15). +# +# The per-machine trust store (`opendox.doxbench_trust.MachineTrust`) lives in +# openDox's state directory, `OPENDOX_STATE_DIR` or, where that is unset, the +# per-user one under the operator's home. A case that serves a console through +# the trust-gated factory without naming a state directory of its own read +# the OPERATOR'S store: what the case saw depended on what this machine +# trusts, and a case that recorded trust wrote it there. So every case gets +# a scratch state directory of its own, which does not exist until something +# records into it (an absent store trusts nothing), and the session gets one +# too, for what a module- or session-scoped fixture builds before any case. +# The trust seam is emptied after every case, so no policy a case registered, +# and no default a case's console registered over a scratch directory, is +# the next case's. +# +# `tests_runtime/conftest.py` clears every `OPENDOX_*` setting before each of +# its cases, this one included; those cases name the state directory they +# mean. A case that names its own (`monkeypatch.setenv`) wins, as it runs +# after this fixture. +_STATE_SETTING = "OPENDOX_STATE_DIR" +_scratch_cases = itertools.count() + + +@pytest.fixture(scope="session", autouse=True) +def _scratch_state_base(tmp_path_factory: pytest.TempPathFactory): + """The session's scratch state directory, and the base of each case's.""" + base = tmp_path_factory.mktemp("opendox-state") + with pytest.MonkeyPatch.context() as patch: + patch.setenv(_STATE_SETTING, str(base / "session")) + yield base + + +@pytest.fixture(autouse=True) +def _scratch_state_directory(_scratch_state_base, monkeypatch: pytest.MonkeyPatch): + """A case's own scratch state directory; the trust seam emptied after.""" + monkeypatch.setenv(_STATE_SETTING, str( + _scratch_state_base / f"case-{next(_scratch_cases)}")) + yield + trust = sys.modules.get("opendox.doxbench_trust") + if trust is not None: + trust.unregister() diff --git a/src/opendox/cli_model_binding.py b/src/opendox/cli_model_binding.py index ce483e98..1c69ead1 100644 --- a/src/opendox/cli_model_binding.py +++ b/src/opendox/cli_model_binding.py @@ -66,6 +66,23 @@ def _record_trust(binding: "binding_mod.ModelProviderBinding", return trust_mod.recorded_for(binding, root=_repo_root(args)) +def _named_document(store: "binding_mod.BindingStore", + args: argparse.Namespace) -> str | None: + """The bindings document this invocation read, where `--bindings` named + it, for every command printed from it: a command that read another + document would act on another binding (T100 follow-on, A6).""" + return str(store.path) if getattr(args, "bindings", None) else None + + +def _cannot_write(store: "binding_mod.BindingStore", error: OSError) -> str: + """The refusal of a bindings document the system will not write, by the + system's own short word for why (T100 follow-on, A11). Raised by the + write itself, after nothing in it changed.""" + return (f"the bindings document {trust_mod.shown(str(store.path))} could " + f"not be written ({error.strerror or type(error).__name__}), so " + "nothing in it changed") + + 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)}") @@ -108,6 +125,8 @@ def _declared_binding(args: argparse.Namespace) -> "binding_mod.ModelProviderBin "declares this one, the first binding not pending approval") CONSOLE_PASSES_OVER_PENDING = ( "passes over it: its declaration is pending approval") +CONSOLE_PASSES_OVER_UNSERVABLE = ( + "passes over it: the model catalog cannot list its id or its label") CONSOLE_DECLARES_ANOTHER = ( "declares {binding_id}, the first binding not pending approval, and " "declares one binding at a time") @@ -132,7 +151,7 @@ def cmd_model_binding_list(args: argparse.Namespace) -> int: except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 - print(f" bindings {store.path}") + print(f" bindings {trust_mod.shown(str(store.path))}") if not bindings: print(" (none declared — this install talks to no brokered provider)") return 0 @@ -173,7 +192,7 @@ def _trust_lines(bindings, store: "binding_mod.BindingStore", 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) + named = _named_document(store, args) lines: dict[str, str] = {} for binding in bindings: verdict = trust_mod.verdict_for(binding, root=root) @@ -196,7 +215,8 @@ def _console_lines(bindings, store: "binding_mod.BindingStore", 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.""" + factory does, and so is its passing over a binding the model catalog + cannot list (T100 follow-on, A3).""" from opendox import doxbench_intake as intake_mod root = _repo_root(args) @@ -207,11 +227,17 @@ def _console_lines(bindings, store: "binding_mod.BindingStore", 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] + unservable = {binding.id for binding in bindings + if binding.id not in pending + and trust_mod.unservable_because(binding) is not None} + approved = [binding for binding in bindings + if binding.id not in pending and binding.id not in unservable] lines: dict[str, str] = {} for binding in bindings: if binding.id in pending: lines[binding.id] = CONSOLE_PASSES_OVER_PENDING + elif binding.id in unservable: + lines[binding.id] = CONSOLE_PASSES_OVER_UNSERVABLE elif binding is approved[0]: lines[binding.id] = CONSOLE_DECLARES_THIS else: @@ -234,11 +260,16 @@ def cmd_model_binding_add(args: argparse.Namespace) -> int: 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) + try: + store.add(binding) + except OSError as error: + raise binding_mod.BindingRefused( + _cannot_write(store, error)) from None except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 - print(f" declared {trust_mod.shown(binding.id)} in {store.path}") + print(f" declared {trust_mod.shown(binding.id)} in " + f"{trust_mod.shown(str(store.path))}") print(f" {binding.custody_notice()}") print(_trusted_line(binding, verdict)) return 0 @@ -247,18 +278,37 @@ def cmd_model_binding_add(args: argparse.Namespace) -> int: 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.""" + known to exist, so a store that refuses leaves nothing written. + + A WRITE THAT FAILS AFTER THE TRUST WAS RECORDED UNDOES IT (T100 + follow-on, A11). The store holds one form per binding, so recording the + new form untrusted the old one; where the old form was trusted, it is + trusted again, so a failed edit changes neither the document nor what + this machine trusts. The refusal names the write's cause, never a raw + error.""" store = _binding_store(args) try: binding = _declared_binding(args) - if store.get(binding.id) is None: + existing = store.get(binding.id) + if existing is None: store.edit(binding) # refuses the unknown id, in its own words + was_trusted = trust_mod.verdict_for( + existing, root=_repo_root(args)).admits(existing) verdict = _record_trust(binding, args) - store.edit(binding) + try: + store.edit(binding) + except (binding_mod.BindingRefused, OSError) as error: + if was_trusted: + _record_trust(existing, args) + if isinstance(error, OSError): + raise binding_mod.BindingRefused( + _cannot_write(store, error)) from None + raise except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 - print(f" updated {trust_mod.shown(binding.id)} in {store.path}") + print(f" updated {trust_mod.shown(binding.id)} in " + f"{trust_mod.shown(str(store.path))}") print(_trusted_line(binding, verdict)) return 0 @@ -270,7 +320,8 @@ def cmd_model_binding_remove(args: argparse.Namespace) -> int: except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 - print(f" retired {binding.id} from {store.path}") + print(f" retired {trust_mod.shown(binding.id)} from " + f"{trust_mod.shown(str(store.path))}") print(f" {binding.removal_notice()}") return 0 @@ -312,12 +363,22 @@ def cmd_model_binding_set_credential(args: argparse.Namespace, *, 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) + if not verdict.admits(binding): + # BY NAME, and the command it prints reads the document this + # invocation read (T100 follow-on, A6). + raise trust_mod.BindingUntrusted(trust_mod.refusal_message( + binding.id, verdict.root, + verdict.reason or trust_mod.REASON_NEVER_TRUSTED, + bindings=_named_document(store, args))) reference = provider_mod.hand_off_credential( binding, source if source is not None else sys.stdin, trust=verdict) - rewritten = store.edit(dataclasses.replace(binding, - credential_ref=reference)) + try: + rewritten = store.edit(dataclasses.replace( + binding, credential_ref=reference)) + except OSError as error: + raise binding_mod.BindingRefused( + _cannot_write(store, error)) from None except (binding_mod.BindingRefused, provider_mod.BrokerRefused) as exc: print(str(exc), file=sys.stderr) return 1 @@ -409,7 +470,7 @@ def cmd_model_binding_trust(args: argparse.Namespace) -> int: 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}") + f"declared in {trust_mod.shown(str(store.path))}") except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index e2c441dd..657ac019 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -529,6 +529,53 @@ class BindingRefused(ValueError): caller declaring a binding has exactly one thing to catch.""" +def linked_component(path: Path | str, relpath: str) -> Path | None: + """The symbolic link on the way to a settings document, or None (T100 + follow-on, N1): the document itself and, where its path ends with + `relpath` (a checkout's own default for it), every directory of `relpath` + above it. A clone carries a link as readily as a file, so a document + reached through one could be read from, or written to, anywhere the link + points: a write through it would create or overwrite a file outside the + repository. Directories above `relpath`, the checkout's own path, are + the operator's.""" + path = Path(path) + candidates = [path] + parts = Path(relpath).parts + if len(path.parts) > len(parts) and path.parts[-len(parts):] == parts: + candidates += list(path.parents)[:len(parts) - 1] + for candidate in candidates: + if candidate.is_symlink(): + return candidate + return None + + +#: What a store says of a settings document reached through a link. +LINKED_DOCUMENT = ( + "the {what} at {path} is reached through a symbolic link ({link}), which " + "a clone can carry to point anywhere, so it is neither read nor written; " + "replace the link with the file or directory itself") + + +def read_settings_document(path: Path, *, what: str, yaml, refused): + """The YAML document at `path`, parsed, or a refusal BY NAME (`refused`, + the caller's own refusal class) for one that cannot be read (T100 + follow-on, N2): one the system will not read for this user, not UTF-8, + nested past what the parser can descend, or not YAML. A console's start + reads it, so none of these may surface as a raw error there.""" + try: + return yaml.safe_load(path.read_text(encoding="utf-8")) + except OSError as error: + raise refused(f"the {what} at {path} cannot be read " + f"({error.strerror or type(error).__name__})") from None + except UnicodeDecodeError: + raise refused(f"the {what} at {path} is not UTF-8 text") from None + except RecursionError: + raise refused(f"the {what} at {path} nests too deeply to " + "read") from None + except yaml.YAMLError as error: + raise refused(f"the {what} at {path} is not readable YAML") from error + + def _require_non_blank_str(field: str, value: object) -> str: if not isinstance(value, str): raise BindingRefused( @@ -1027,19 +1074,25 @@ def remove(self, binding_id: str) -> ModelProviderBinding: # -- the document ------------------------------------------------------ + def _refuse_a_link(self) -> None: + """No link on the way to the document, which a clone could carry + (T100 follow-on, N1; `linked_component`).""" + link = linked_component(self.path, DEFAULT_BINDINGS_RELPATH) + if link is not None: + raise BindingRefused(LINKED_DOCUMENT.format( + what="bindings document", path=self.path, link=link)) + def _load(self) -> list[ModelProviderBinding]: + self._refuse_a_link() if not self.path.is_file(): # THE HOSTED PATH, and the reason the import below is lazy: an # install with no bindings document answers here and never needs a # YAML parser at all. return [] yaml = _yaml_or_refused() - try: - document = yaml.safe_load(self.path.read_text(encoding="utf-8")) - except yaml.YAMLError as error: - raise BindingRefused( - f"the bindings document at {self.path} is not readable YAML" - ) from error + document = read_settings_document( + self.path, what="bindings document", yaml=yaml, + refused=BindingRefused) if document is None: return [] if not isinstance(document, Mapping): @@ -1071,6 +1124,7 @@ def _load(self) -> list[ModelProviderBinding]: return bindings def _save(self, bindings: Iterable[ModelProviderBinding]) -> None: + self._refuse_a_link() yaml = _yaml_or_refused() document = { "schema_version": SCHEMA_VERSION, diff --git a/src/opendox/doxbench_install.py b/src/opendox/doxbench_install.py index a045e79c..8067f5fa 100644 --- a/src/opendox/doxbench_install.py +++ b/src/opendox/doxbench_install.py @@ -338,9 +338,11 @@ def trust_gated_model_port_factory(binding, *, checkout_root: Path | str, 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. + this 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. The console's start never hands one + here: `declared_model_port_factory` passes it over (T100 follow-on, A3), + and this refusal is the defence beneath that, for any other caller. `bindings_path` is the document the binding was read from, where a caller named one, so the command the refusal prints reads that document @@ -405,6 +407,13 @@ def declared_model_port_factory(session_root: Path | str, *, at a time. Choosing among several declared bindings needs a selection rule this change does not have and must not invent — see tasks.md 2.5. + A BINDING THE MODEL CATALOG CANNOT LIST IS PASSED OVER as a pending one + is (T100 follow-on, A3). It is no model at all: no turn could name it, + and declaring it would leave the console with an empty catalog whose + rail line ("No model configured") offers two remedies, a harness on PATH + or another binding, neither of which could then take effect. Passed + over, both do. It is said on stderr, by name, with its remedy. + A PENDING DECLARATION IS SKIPPED (add-doxchat-model-intake task 3.1). A binding the intake flow wrote is DECLARED and not yet APPROVED, and "not yet approved" has to mean something at the one seam where availability is @@ -434,17 +443,29 @@ def declared_model_port_factory(session_root: Path | str, *, f"[model-provider] the bindings document could not be read " f"({error}); reading it as declaring no binding\n") declared = () + from opendox import doxbench_trust as trust_mod + pending = intake_mod.pending_binding_ids(checkout_root) + unservable = tuple(binding for binding in declared + if binding.id not in pending + and trust_mod.unservable_because(binding) is not None) + for binding in unservable: + sys.stderr.write( + f"[model-provider] model binding {trust_mod.shown(binding.id)} " + f"is passed over: {trust_mod.REASON_UNSERVABLE}. " + f"{trust_mod.REMEDY_UNSERVABLE}\n") approved = tuple(binding for binding in declared - if binding.id not in pending) - if len(approved) != len(declared): + if binding.id not in pending + and binding not in unservable) + if len(approved) + len(unservable) != len(declared): # SAID OUT LOUD, on the same stderr channel the unreadable-document # fallback uses: an operator who declared a model through the wizard and # then wondered why the selector still has nothing in it deserves to # read the reason in their own console rather than infer it. sys.stderr.write( "[model-provider] " - f"{len(declared) - len(approved)} declared binding(s) are pending " + f"{len(declared) - len(approved) - len(unservable)} declared " + "binding(s) are pending " "human approval and contribute no available model; approve them " "from the console's model intake flow\n") if not approved: diff --git a/src/opendox/doxbench_intake.py b/src/opendox/doxbench_intake.py index 1619976a..b4c91021 100644 --- a/src/opendox/doxbench_intake.py +++ b/src/opendox/doxbench_intake.py @@ -666,19 +666,27 @@ def approve(self, binding_id: str, *, issued_by: str, approved_by: str, # -- the document ------------------------------------------------------- + def _refuse_a_link(self) -> None: + """No link on the way to the document, which a clone could carry, so + an approval never writes outside the repository (T100 follow-on, N1; + `doxbench_binding.linked_component`).""" + link = binding_mod.linked_component(self.path, + DEFAULT_DECLARATIONS_RELPATH) + if link is not None: + raise IntakeRefused(binding_mod.LINKED_DOCUMENT.format( + what="declarations document", path=self.path, link=link)) + def _load(self) -> tuple[BrokerDeclaration | None, list[ModelDeclaration]]: + self._refuse_a_link() if not self.path.is_file(): # THE HOSTED PATH, and the reason the import below is lazy: an # install with no declarations answers here and never needs a YAML # parser at all. return None, [] yaml = _yaml_or_refused() - try: - document = yaml.safe_load(self.path.read_text(encoding="utf-8")) - except yaml.YAMLError as error: - raise IntakeRefused( - f"the declarations document at {self.path} is not readable " - "YAML") from error + document = binding_mod.read_settings_document( + self.path, what="declarations document", yaml=yaml, + refused=IntakeRefused) if document is None: return None, [] if not isinstance(document, Mapping): @@ -714,6 +722,7 @@ def _load(self) -> tuple[BrokerDeclaration | None, list[ModelDeclaration]]: def _save(self, broker: BrokerDeclaration | None, declarations: Iterable[ModelDeclaration]) -> None: + self._refuse_a_link() yaml = _yaml_or_refused() document = { "schema_version": SCHEMA_VERSION, diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index dba66480..e2174c4e 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -1617,7 +1617,7 @@ def catalog(self) -> model_mod.ModelCatalog: 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) \ + if self._available and type(self._trust) is trust_mod.TrustVerdict \ and self._trust.admits(self._binding): return self._declared_catalog return model_mod.ModelCatalog.from_entries([ @@ -1730,9 +1730,8 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: credential = _PresentedCredential(resolve_credential_reference( self._binding, trust=self._trust, environ=self._environ, keyring_backend=self._keyring_backend)) - except BrokerRefused: - with self._lock: - self._available = False + except BrokerRefused as refusal: + self._now_unavailable(refusal.diagnostic) raise with self._lock: self._available = True @@ -1811,14 +1810,37 @@ def _current_token(self, reason: str, *, try: minted = mint(self._binding, trust=self._trust, retry_of=retry_of, runner=self._runner) - except BrokerRefused: - self._available = False + except BrokerRefused as refusal: + self._now_unavailable(refusal.diagnostic, locked=True) raise self._available = True self._token = minted self._record(reason, audit_ref=minted.audit_ref) return minted + def _now_unavailable(self, diagnostic: str, *, + locked: bool = False) -> None: + """Mark the catalog unavailable, and SAY SO, once, as the port turns + unavailable (T100 follow-on, A4). The chat rail tells an operator + whose binding is trusted, and still unavailable, to read "the reason + this console printed when its provider refused": this is that line. + ONE fixed `[model-provider]` line, through the port's notice seam, + naming the binding's id and the refusal's FIXED diagnostic, which is + one of `FIXED_DIAGNOSTICS` and carries nothing a broker, a reference + or a provider wrote. A refusal while already unavailable says + nothing more; a later success makes the next refusal say it again. + `locked` says the caller already holds the port's lock.""" + if locked: + was_available, self._available = self._available, False + else: + with self._lock: + was_available, self._available = self._available, False + if was_available: + self._notice( + f"[model-provider] model binding " + f"{trust_mod.shown(self._binding.id)} is unavailable: " + f"{diagnostic}\n") + def _forget_token(self) -> None: with self._lock: self._token = None diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index d4ff89f8..2e89a35d 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -112,6 +112,7 @@ import os import re import shlex +import shutil import stat import sys import threading @@ -128,11 +129,13 @@ fcntl = None __all__ = [ + "APPROVED_UNTRUSTABLE_NOTICE", "APPROVED_UNSERVABLE_NOTICE", "APPROVED_UNTRUSTED_NOTICE", "BASIS_CATALOG", "BASIS_HOST", "BASIS_MACHINE_TRUST", + "BASIS_REPOSITORY", "BindingUntrusted", "MachineTrust", "TRUST_FILENAME", @@ -144,10 +147,19 @@ "UNTRUSTED_TURN_MESSAGE", "UntrustedBindingPort", "INTAKE_BROKER_UNTRUSTED", + "INTAKE_NOT_ADMISSIBLE", + "REASON_IN_REPOSITORY", + "REASON_RECORD_FORM", + "REMEDY_IN_REPOSITORY", + "REMEDY_NOT_BY_TRUST", + "UNTRUSTABLE_TURN_MESSAGE", "REASON_UNSERVABLE", "REMEDY_UNSERVABLE", "binding_digest", "command_safe_id", + "in_repository_program", + "intake_admissible", + "trust_can_repair", "current", "is_registered", "policy", @@ -190,10 +202,23 @@ #: 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. +#: The digest's SCHEME, spelled on every digest so a stored one says how it +#: was computed: the algorithm, and the form of the canonical record it was +#: computed over (T100 follow-on, A16). Scheme 1 is `sha256:` over +#: `as_record()` with exactly `DIGEST_SCHEME_FIELDS`. A change to the record's +#: fields changes every digest, so it MUST change this prefix too (for +#: instance to `sha256/2:`): a digest stored under another prefix is then read +#: as trusted under another form of the record (`REASON_RECORD_FORM`), and +#: never as a binding that "has changed", which it has not. DIGEST_PREFIX = "sha256:" +#: The binding fields scheme 1's canonical record holds. Pinned equal to +#: `doxbench_binding.BINDING_FIELDS` by a test, so adding a field cannot go +#: unnoticed by the digest's scheme. +DIGEST_SCHEME_FIELDS: tuple[str, ...] = ( + "id", "label", "provider", "credential_ref", "auth_kind", "approved_by", + "endpoint", "dialect", "model", "broker_argv") + # --------------------------------------------------------------------------- # what a verdict rests on # --------------------------------------------------------------------------- @@ -208,6 +233,11 @@ #: policy is asked (`unservable_because`). BASIS_CATALOG = "catalog" +#: The verdict on a binding whose broker command names a file inside the +#: served repository, given before any policy is asked +#: (`in_repository_program`). +BASIS_REPOSITORY = "repository" + #: The setting that names openDox's state directory (openDox-code#69). STATE_DIR_SETTING = "OPENDOX_STATE_DIR" @@ -231,6 +261,39 @@ "the trust verdict given for it covers another binding, or another form " "of it") +#: Why a binding the store trusted under another digest SCHEME is untrusted +#: (T100 follow-on, A16): another openDox computed that digest over another +#: form of the binding record, so it says nothing about whether this binding +#: changed. It is reviewed and trusted again, as a changed one is. +REASON_RECORD_FORM = ( + "it was trusted under another form of the binding record, by another " + "openDox version, so it is reviewed and trusted again") + +#: Why a binding whose broker command names a file inside the served +#: repository is never trusted (T100 follow-on, A2; RULED by Brett Heap, +#: openxFactory#656 comment 5982436447, item 2, "Refuse in-repo programs"). +#: Trust is of the binding's record, and a pull can change such a program +#: after the record was trusted, so the broker must live outside the +#: repository. +REASON_IN_REPOSITORY = ( + "its broker command names a file inside the served repository, which a " + "pull could change after the binding was trusted") + +#: What an operator is told to do about such a binding. +REMEDY_IN_REPOSITORY = ( + "Trusting it cannot make it usable: install the broker outside the " + "repository, then declare the binding with that command with \"opendox " + "model-binding edit\", which records trust for what it writes") + +#: What every refusal says, in place of a command, where `trust` itself would +#: be refused for the same reason (T100 follow-on, A1): the store cannot be +#: used, the platform cannot keep it, or a host's policy declines. No command +#: is printed that could not succeed. +REMEDY_NOT_BY_TRUST = ( + "Resolve the cause above first: until it is resolved, trusting this " + "binding would be refused for the same reason. Then list its bindings " + "again, which prints the command that trusts 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 @@ -333,6 +396,20 @@ def reason_policy_failed(error: BaseException) -> str: "\"remove\" and \"add\" (a new id), each of which records trust for " "what it writes, and restart this console") +#: What a refused chat turn says when the binding is untrusted for a reason +#: `trust` cannot repair (T100 follow-on, A1): the trust store cannot be +#: used, the platform cannot keep it, a host's policy declines, or its broker +#: lies inside the repository. Like `UNSERVABLE_TURN_MESSAGE`, it names no +#: command that trusts. A FIXED sentence, within the released failure +#: envelope's `message` bound. +UNTRUSTABLE_TURN_MESSAGE = ( + "the model binding this install declares is not usable on this machine, " + "and trusting it cannot help yet: the trust store, the platform or the " + "host's trust policy refuses it, or its broker lies inside the " + "repository. Nothing was sent and nothing was contacted. Run \"opendox " + "model-binding list --repo-root \" to see why and what to " + "do, 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, @@ -348,11 +425,11 @@ def reason_policy_failed(error: BaseException) -> str: 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.") + "this machine and, where trusting it can help, \"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 @@ -386,6 +463,31 @@ def reason_policy_failed(error: BaseException) -> str: "writes, 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 +#: untrusted for a reason `trust` cannot repair (T100 follow-on, A1): the +#: trust store cannot be used, the platform cannot keep it, the host's +#: policy declines, or its broker lies inside the repository. A FIXED +#: sentence, which names no command that trusts. +APPROVED_UNTRUSTABLE_NOTICE = ( + "the model is approved for this console, but its binding is not usable " + "on this machine, and trusting it cannot help yet: the trust store, the " + "platform or the host's trust policy refuses it, or its broker lies " + "inside the repository. \"opendox model-binding list --repo-root " + "\" shows why and what to do; then restart this console. The " + "credential remains in the broker's custody and this act neither mints " + "nor reads one") + +#: Why the console intake is not offered where the trust policy registered +#: now could not admit its hand-off (T100 follow-on, A5). openDox's own +#: per-machine store admits no intake, so an intake offered under it would +#: open a surface every submission of which is refused. A FIXED sentence. +INTAKE_NOT_ADMISSIBLE = ( + "the console intake is not offered: its hand-off runs the broker the " + "served repository's declarations document names, and no registered " + "trust policy admits such a broker. openDox's own per-machine trust " + "admits none; a host's own trust policy (opendox.doxbench_trust." + "register) may, by answering intake_verdict") + #: 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 @@ -538,6 +640,22 @@ def _command_from_the_root(binding_id: str, root: str | None, bindings=os.path.join(".", str(relative))) +#: The reasons `trust` repairs (T100 follow-on, A1): openDox's own "never +#: trusted here", "changed since", "trusted under another form of the +#: record", and a verdict that covered another binding or none at all. Every +#: other reason is one `trust` would be refused for as well. +TRUST_REPAIRS: frozenset[str] = frozenset({ + REASON_NEVER_TRUSTED, REASON_CHANGED, REASON_RECORD_FORM, + REASON_NOT_COVERED, REASON_NO_VERDICT}) + + +def trust_can_repair(reason: str | None) -> bool: + """Whether `opendox model-binding trust` can repair a binding untrusted + for `reason` (T100 follow-on, A1). No reason is the store's plain "never + trusted".""" + return reason is None or reason in TRUST_REPAIRS + + def trust_remedy(binding_id: str, root: str | None, reason: str | None = None, *, bindings: str | None = None) -> str: @@ -546,12 +664,22 @@ def trust_remedy(binding_id: str, root: str | None, 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.""" + usable (`REMEDY_UNSERVABLE`), and neither does one whose broker lies + inside the repository (`REMEDY_IN_REPOSITORY`). NOR DOES ANY BINDING + UNTRUSTED FOR A REASON `trust` WOULD REFUSE TOO (T100 follow-on, A1): a + store that cannot be used, a platform that cannot keep one, a host + policy's own refusal. That remedy is `REMEDY_NOT_BY_TRUST`, and a + command is printed only where `trust_can_repair` says trust repairs the + reason. 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 + if reason == REASON_IN_REPOSITORY: + return REMEDY_IN_REPOSITORY + if reason is not None and not trust_can_repair(reason): + return REMEDY_NOT_BY_TRUST command = trust_command(binding_id, root, bindings=bindings) if command is not None: return f"Review it, then trust it with: {command}" @@ -634,11 +762,21 @@ def require_admitted(binding, trust: TrustVerdict | None) -> None: 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): + trust. A verdict is a `TrustVerdict` EXACTLY: a subclass could answer + `admits` as it liked (T100 follow-on, A12). + + A BROKER INSIDE THE REPOSITORY IS REFUSED HERE TOO, by the verdict's own + root, so the defence beneath the factory holds even for a verdict a + policy gave before the rule existed (T100 follow-on, A2; + `in_repository_program`).""" + if type(trust) is TrustVerdict and trust.admits(binding): + if (trust.root is not None + and in_repository_program(binding, root=trust.root)): + raise BindingUntrusted(refusal_message( + trust.binding_id, trust.root, REASON_IN_REPOSITORY)) return binding_id = getattr(binding, "id", "") - if not isinstance(trust, TrustVerdict): + if type(trust) is not TrustVerdict: raise BindingUntrusted(refusal_message( str(binding_id), None, REASON_NO_VERDICT)) if (trust.trusted or trust.binding_id != binding_id @@ -659,8 +797,9 @@ def _held_to(binding, verdict: Any, *, root: Path | str) -> TrustVerdict: 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( + and something that is not a verdict, a subclass of one included, whose + `admits` could answer anything (T100 follow-on, A12).""" + if type(verdict) is TrustVerdict and verdict.root == resolved_root( root): if verdict.admits(binding): return verdict @@ -679,8 +818,9 @@ def unservable_because(binding) -> str | None: (`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.""" + and write nothing, the console's start passes over one (T100 follow-on, + A3), and the trust gate beneath it declares a refusing port for one + rather than fail on what a repository wrote.""" from opendox import doxbench_install try: @@ -690,16 +830,121 @@ def unservable_because(binding) -> str | None: return None +#: A URL's scheme and authority (`https://`): a value that names no file, +#: whatever separators it carries. +_URL = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://") + + +def _program_path(candidate: str, *, first: bool, + root: Path) -> list[Path]: + """The files one argv member could name, resolved, links followed: a + path (with a separator) as written, made absolute against this process's + working directory, which the broker inherits, and against the served + root, since a broker may be run from either, whether or not a file is + there yet (a pull could add one); a bare word as the program `PATH` + finds, for the command's first member; and any other bare word that + names an existing file in either directory. A URL names no file.""" + found: list[str] = [] + if _URL.match(candidate): + return [] + if candidate in (".", "..") or os.sep in candidate or ( + os.altsep and os.altsep in candidate): + if os.path.isabs(candidate): + found.append(candidate) + else: + found += [os.path.join(os.getcwd(), candidate), + os.path.join(str(root), candidate)] + elif first: + located = shutil.which(candidate) + if located is not None: + found.append(located) + else: + found += [here for here in (os.path.join(os.getcwd(), candidate), + os.path.join(str(root), candidate)) + if os.path.lexists(here)] + return _resolved(found) + + +def _module_paths(name: str, *, root: Path) -> list[Path]: + """The files a module named after `-m` could be imported from, resolved: + its top-level package or module (`a` or `a.py` for `a.b`) in this + process's working directory, which an interpreter run with `-m` imports + from first and which the broker inherits, and in the served root.""" + top = name.split(".", 1)[0] + if not top: + return [] + return _resolved([here for directory in (os.getcwd(), str(root)) + for here in (os.path.join(directory, top), + os.path.join(directory, top + ".py")) + if os.path.lexists(here)]) + + +def _resolved(found: list[str]) -> list[Path]: + paths: list[Path] = [] + for path in found: + try: + paths.append(Path(path).resolve()) + except (OSError, RuntimeError): + paths.append(Path(os.path.abspath(path))) + return paths + + +def in_repository_program(binding, *, root: Path | str) -> str | None: + """The member of `binding`'s broker command that names a file inside the + served repository, or None (T100 follow-on, A2; RULED by Brett Heap, + openxFactory#656 comment 5982436447, item 2, "Refuse in-repo programs"). + + Trust is of the binding's RECORD (`binding_digest`). A program inside the + repository is not in that record, and a pull can change it after the + record was trusted, so a binding whose command names one is never + trusted: it is refused by name where trust is recorded (`recorded_for`) + and wherever it is judged (`verdict_for`, `intake_verdict_for`, + `require_admitted`), with the remedy "install the broker outside the + repository" (`REMEDY_IN_REPOSITORY`). + + Every member of the base invocation is asked, placeholders filled; of a + member that is an option (`-v`, `--config=VALUE`), only its value is. A + member names a file inside + the repository where what it resolves to (`_program_path`) is the served + root or lies under it, and so does a module named after `-m` that would + be imported from there (`_module_paths`). A binding no broker answers + has no command.""" + if binding.credential_source() != binding_mod.CREDENTIAL_FROM_BROKER: + return None + served = Path(resolved_root(root)) + members = binding.substituted_argv() + for index, member in enumerate(members): + if member.startswith("-"): + candidates = ([member.split("=", 1)[1]] if "=" in member + else []) + else: + candidates = [member] + paths = [path for candidate in candidates if candidate + for path in _program_path(candidate, first=index == 0, + root=served)] + if index and members[index - 1] == "-m": + paths += _module_paths(member, root=served) + if any(path == served or served in path.parents for path in paths): + return member + 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`).""" + `root`, held to it. A binding the catalog refuses, and one whose broker + lies inside the repository, are untrusted before any policy is asked + (`unservable_because`, `in_repository_program`). 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) + if in_repository_program(binding, root=root) is not None: + return TrustVerdict.untrusted_for(binding, root=root, + basis=BASIS_REPOSITORY, + reason=REASON_IN_REPOSITORY) try: verdict = policy_of().verdict(binding, root=root) except Exception as error: # noqa: BLE001 - a policy that fails trusts nothing @@ -719,12 +964,19 @@ def verdict_for(binding, *, root: Path | str) -> TrustVerdict: 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). +def registered_verdict_for(binding, *, root: Path | str) -> TrustVerdict: + """The verdict on `binding` at `root`, held to it, of the policy + registered NOW, or, where nothing is registered, of openDox's own + default (`MachineTrust()`), asked WITHOUT REGISTERING it. For an act that + is not one of the consumers that register the default (the console's + model approval). + + WHERE NOTHING IS REGISTERED, THE DEFAULT'S ANSWER IS THE TRUE ONE (T100 + follow-on, A19). A host registers its policy at process start, before + any consumer asks, so a process that has registered nothing is one whose + next start reads this machine's store: saying the binding is untrusted + there, when the store trusts it, would be false. The store is read, and + nothing is registered, so a host's registration after this still wins. 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 @@ -734,7 +986,7 @@ def registered_verdict_for(binding, *, its answer given in the host's place.""" registered = _registered_now() if registered is None: - return None + registered = MachineTrust() return _judged(lambda: registered, binding, root=root) @@ -759,8 +1011,16 @@ def recorded_for(binding, *, root: Path | str) -> TrustVerdict: 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() + if in_repository_program(binding, root=root) 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: " + f"{REASON_IN_REPOSITORY}. {REMEDY_IN_REPOSITORY}") + registered = None try: + # INSIDE the refusal net (T100 follow-on, A13): whatever the seam + # answers, a refusal by name follows, never a raw error. + registered = policy() verdict = registered.record(binding, root=root) except TrustStoreRefused: # openDox's own store's refusal is actionable and composed from @@ -788,6 +1048,24 @@ def recorded_for(binding, *, root: Path | str) -> TrustVerdict: "on this machine and nothing was written") +def intake_admissible() -> bool: + """Whether the policy registered NOW could admit a console intake at all + (T100 follow-on, A5): one that answers `intake_verdict` with something + other than openDox's own per-machine "no". Read ONCE, under the seam's + lock, and nothing is registered: where nothing is registered, the + default a hand-off would register admits no intake, so the answer is no. + + The console intake surface offers enrolment only where this is so, so an + install never opens a form every submission of which is refused.""" + registered = _registered_now() + if registered is None: + return False + ask = getattr(registered, "intake_verdict", None) + if not callable(ask): + return False + return getattr(ask, "__func__", None) is not MachineTrust.intake_verdict + + 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 @@ -798,7 +1076,12 @@ def intake_verdict_for(binding, *, root: Path | str) -> TrustVerdict: 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.""" + admits no intake; one that raises admits nothing. A broker inside the + repository is refused before any policy is asked (T100 follow-on, A2).""" + if in_repository_program(binding, root=root) is not None: + return TrustVerdict.untrusted_for(binding, root=root, + basis=BASIS_REPOSITORY, + reason=REASON_IN_REPOSITORY) try: ask = getattr(policy(), "intake_verdict", None) if not callable(ask): @@ -894,6 +1177,9 @@ def _store_failed(state: Path | str, error: OSError, *, def _store_refused(path: Path | str, reason: str) -> TrustStoreRefused: + """A store refused for what ANOTHER USER could do with it: a link, an + owner or a mode (the tree checks). Only those say so (T100 follow-on, + A7); every other refusal names its own cause (`_store_unusable`).""" return TrustStoreRefused( f"the model-binding trust store refuses {shown(str(path))}: it " f"{reason}, so " @@ -902,6 +1188,54 @@ def _store_refused(path: Path | str, reason: str) -> TrustStoreRefused: "it") +#: The recovery for a store this install cannot read as one of its own: a +#: torn copy, another kind of document, an entry it does not write, a size it +#: never writes. +RECOVER_MOVE_ASIDE = ( + "Move it aside, then trust each binding again: listing a repository's " + "bindings prints the command for each") + +#: The recovery for a store a newer openDox wrote. +RECOVER_NEWER = ( + "Use the openDox that wrote it, or move it aside and trust each binding " + "again: listing a repository's bindings prints the command for each") + +#: The recovery for a store, or its directory, that this user cannot open or +#: write. +RECOVER_PERMISSIONS = ( + f"Make openDox's state directory ({STATE_DIR_SETTING}) and what it holds " + "readable and writable by this user") + + +def _store_unusable(path: Path | str, cause: str, + recovery: str) -> TrustStoreRefused: + """A store refused for what it IS, not for what another user could do + with it (T100 follow-on, A7): its own cause, and how to recover.""" + return TrustStoreRefused( + f"the model-binding trust store refuses {shown(str(path))}: it " + f"{cause}, so nothing is trusted through it. {recovery}") + + +def _store_cannot_open(path: Path | str, error: OSError) -> TrustStoreRefused: + """A store, or its lock file, the system will not open for this user, + named by the system's own short word for why.""" + word = error.strerror or type(error).__name__ + return _store_unusable(path, f"cannot be opened ({word})", + RECOVER_PERMISSIONS) + + +def _store_refused_state_link(path: Path | str) -> TrustStoreRefused: + """A state directory that is itself a symbolic link (T100 follow-on, A8; + F16.1's ratified text, the holder's ruling on openxFactory#656 comment + 5982436447).""" + return TrustStoreRefused( + f"the model-binding trust store refuses {shown(str(path))}: the " + "directory that holds the store is a symbolic link, and the store is " + "never reached through one, so nothing is trusted through it. Set " + f"openDox's state directory ({STATE_DIR_SETTING}) to the directory " + "itself") + + def _store_refused_dangling(path: Path | str) -> TrustStoreRefused: return TrustStoreRefused( f"the model-binding trust store refuses {shown(str(path))}: it is a " @@ -917,13 +1251,21 @@ def _refuse_foreign_links(state: Path, *, existing_only: bool, 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.""" + would be made or read through it. + + THE STATE DIRECTORY ITSELF IS NEVER A LINK, whoever owns it (T100 + follow-on, A8). F16.1's ratified text: "With the trust file, or a + directory that holds it, replaced by a symbolic link ... every binding + reads untrusted". A directory ABOVE it may be one, as `/var` is on some + systems, under the owner rule above.""" 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 component == state: + raise _store_refused_state_link(component) if info.st_uid not in (uid, 0): raise _store_refused( component, f"is a symbolic link owned by uid {info.st_uid}, " @@ -968,7 +1310,14 @@ def _make_private_directories(leaf: Path) -> None: 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.""" + and the tree check judges it. + + THE DIRECTORY IT STARTS FROM IS JUDGED BY ITS DESCRIPTOR before the + first `mkdir` (T100 follow-on, A18; #69's `runtime.bundle`, which asks + the same): an ancestor of the store, so this user's or root's, with any + write by others only behind the sticky bit. The path-wise check before + it asks the same question; this one asks it of the very directory that + is written into.""" uid = os.getuid() missing: list[str] = [] base = leaf @@ -978,6 +1327,10 @@ def _make_private_directories(leaf: Path) -> None: if not missing: return descriptor = os.open(base, os.O_RDONLY | os.O_DIRECTORY) + reason = _unsafe_because(os.fstat(descriptor), uid=uid, own=False) + if reason is not None: + os.close(descriptor) + raise _store_refused(base, reason) path = base previous = os.umask(0o077) try: @@ -1028,13 +1381,13 @@ def _store_locked(state: Path): descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0), 0o600) - except OSError: + except OSError as error: 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 + raise _store_cannot_open(path, error) from None try: reason = _unsafe_because(os.fstat(descriptor), uid=os.getuid(), own=True, directory=False) @@ -1188,9 +1541,13 @@ def verdict(self, binding, *, root: Path | str) -> TrustVerdict: binding, root=key_root, basis=BASIS_MACHINE_TRUST, reason=REASON_NEVER_TRUSTED) if held != binding_digest(binding): + # A digest of ANOTHER SCHEME says nothing of whether this binding + # changed: another openDox computed it over another form of the + # record (T100 follow-on, A16). return TrustVerdict.untrusted_for( binding, root=key_root, basis=BASIS_MACHINE_TRUST, - reason=REASON_CHANGED) + reason=(REASON_CHANGED if held.startswith(DIGEST_PREFIX) + else REASON_RECORD_FORM)) return TrustVerdict.trusted_for(binding, root=key_root, basis=BASIS_MACHINE_TRUST) @@ -1244,14 +1601,14 @@ def _read(self, state: Path) -> dict[tuple[str, str], str]: | getattr(os, "O_CLOEXEC", 0)) except FileNotFoundError: return {} - except OSError: + except OSError as error: 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 + raise _store_cannot_open(path, error) from None try: reason = _unsafe_because(os.fstat(descriptor), uid=os.getuid(), own=True, directory=False) @@ -1268,8 +1625,8 @@ def _read(self, state: Path) -> dict[tuple[str, str], str]: finally: os.close(descriptor) if size > MAX_TRUST_STORE_BYTES: - raise _store_refused(path, "is larger than any store this " - "install writes") + raise _store_unusable(path, "is larger than any store this " + "install writes", RECOVER_MOVE_ASIDE) return self._entries(path, b"".join(chunks)) @staticmethod @@ -1277,22 +1634,34 @@ 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 + raise _store_unusable(path, "does not read as JSON (a torn or " + "hand-edited copy)", + RECOVER_MOVE_ASIDE) from None entries = (document.get("entries") if isinstance(document, dict) else None) + version = (document.get("schema_version") + if isinstance(document, dict) else None) + if (isinstance(document, dict) + and document.get("kind") == TRUST_KIND + and isinstance(version, int) and not isinstance(version, bool) + and version > SCHEMA_VERSION): + raise _store_unusable( + path, f"is a trust store of schema_version {version}, which " + f"a newer openDox writes and this one ({SCHEMA_VERSION}) does " + "not read", RECOVER_NEWER) if (not isinstance(document, dict) - or document.get("schema_version") != SCHEMA_VERSION + or 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") + raise _store_unusable(path, "is not a trust store this install " + "writes", RECOVER_MOVE_ASIDE) 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") + raise _store_unusable(path, "holds an entry this install " + "does not write", RECOVER_MOVE_ASIDE) held[(entry["root"], entry["binding_id"])] = entry["digest"] return held @@ -1327,12 +1696,19 @@ def _write(state: Path, entries: dict[tuple[str, str], str]) -> None: 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 + except OSError as error: + if error.errno in (errno.EEXIST, errno.ELOOP): + # 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 + # A directory this user cannot write, a full disk: named by the + # system's own word (T100 follow-on, A7). + raise _store_unusable( + temporary, "could not be created " + f"({error.strerror or type(error).__name__})", + RECOVER_PERMISSIONS) from None try: os.fchmod(descriptor, 0o600) view = memoryview(payload) @@ -1348,6 +1724,26 @@ def _write(state: Path, entries: dict[tuple[str, str], str]) -> None: raise os.close(descriptor) os.replace(temporary, target) + _sync_directory(state) + + +def _sync_directory(state: Path) -> None: + """Flush the state directory's entry for the replaced store (T100 + follow-on, A17), so a crash after `record` returns cannot bring back the + store it replaced. The replace has happened by now, so a file system that + cannot sync a directory (`EINVAL`), or any failure here, leaves the trust + recorded, as it is: it is never reported as unrecorded.""" + try: + descriptor = os.open(state, os.O_RDONLY | os.O_DIRECTORY + | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)) + except OSError: + return + try: + os.fsync(descriptor) + except OSError: + pass + finally: + os.close(descriptor) # --------------------------------------------------------------------------- @@ -1407,15 +1803,20 @@ 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).""" + which names its remedy; one untrusted for a reason `trust` cannot repair + (the store, the platform, the host's policy, a broker inside the + repository) gets `UNTRUSTABLE_TURN_MESSAGE` (T100 follow-on, A1); 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 + if not trust_can_repair(port.verdict.reason): + # A reason `trust` would be refused for too (T100 follow-on, A1). + return UNTRUSTABLE_TURN_MESSAGE return UNTRUSTED_TURN_MESSAGE @@ -1522,9 +1923,20 @@ def _registered_now() -> Any: 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() + host has registered one, then the registered policy. + + ONE OPERATION UNDER THE SEAM'S LOCK (T100 follow-on, A13). Registering + the default and then reading the seam in two steps would let a host that + unregisters between them leave the second step nothing, and a consumer + would fail on `TrustPolicyNotRegistered`.""" + global _registered, _is_default, _default_read + with _lock: + if _registered is None: + _registered, _is_default, _default_read = (MachineTrust(), True, + False) + if _is_default: + _default_read = True + return _registered def unregister() -> None: diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index 22392141..736c4666 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -988,7 +988,15 @@ def _handle_workbench_model_intake_surface(self, head_only: bool) -> None: # registered the flow is not offered, even beside a hand-written # broker block, and the reason names the seam. records = column_seams.gate_records_writable() - offered = bool(disclosure and disclosure.get("broker")) and records + broker = bool(disclosure and disclosure.get("broker")) + # NOR WHERE NO REGISTERED TRUST POLICY COULD ADMIT ITS HAND-OFF (T100 + # follow-on, A5). openDox's own per-machine trust admits no intake + # broker, so under it every submission would be refused + # INTAKE_BROKER_UNTRUSTED: a form that cannot complete. Read once, + # and nothing is registered. + from opendox import doxbench_trust + admissible = doxbench_trust.intake_admissible() + offered = broker and records and admissible from opendox import doxbench_binding envelope: dict = { "kind": "workbench-model-intake", @@ -1006,8 +1014,10 @@ def _handle_workbench_model_intake_surface(self, head_only: bool) -> None: "declarations": (disclosure or {}).get("declarations", []), } if not offered: - envelope["reason"] = (doxbench_intake.NO_BROKER_NOTICE if records - else column_seams.GATE_RECORDS_REFUSAL) + envelope["reason"] = ( + column_seams.GATE_RECORDS_REFUSAL if not records + else doxbench_intake.NO_BROKER_NOTICE if not broker + else doxbench_trust.INTAKE_NOT_ADMISSIBLE) self._serve_bytes(json.dumps(envelope).encode("utf-8"), JSON_CTYPE, head_only) @@ -1413,22 +1423,31 @@ def _approved_availability(self, binding) -> str: 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).""" + NOTHING IS REGISTERED BY THIS ACT. It is not one of the consumers + that register openDox's default (`doxbench_trust.policy`). Where + nothing is registered it asks the default WITHOUT registering it, + which is what this console's next start would ask, so a binding this + machine's store trusts is never called untrusted (T100 follow-on, + A19). The registration is read ONCE (`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). + + A binding untrusted for a reason `trust` cannot repair (the store, + the platform, the host's policy, a broker inside the repository) + answers `APPROVED_UNTRUSTABLE_NOTICE`, which names no command that + trusts (T100 follow-on, A1).""" 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): + if verdict.admits(binding): return doxbench_intake.APPROVAL_NOTICE + if not doxbench_trust.trust_can_repair(verdict.reason): + # A reason `trust` would be refused for too: no command that + # trusts is named (T100 follow-on, A1). + return doxbench_trust.APPROVED_UNTRUSTABLE_NOTICE return doxbench_trust.APPROVED_UNTRUSTED_NOTICE def _approval_binding(self, binding_id: str): diff --git a/src/opendox/web/views/doxbench-chat.js b/src/opendox/web/views/doxbench-chat.js index 453cdec1..1c253433 100644 --- a/src/opendox/web/views/doxbench-chat.js +++ b/src/opendox/web/views/doxbench-chat.js @@ -371,7 +371,7 @@ export function noModelConfiguredRemedy(stateValue) { // twin is `doxbench_trust.UNTRUSTED_BINDING_REMEDY`, which // tests/test_model_binding_trust.py holds to this spelling. export const UNTRUSTED_BINDING_REMEDY = - "No declared model is available. \"opendox model-binding list --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."; + "No declared model is available. \"opendox model-binding list --repo-root \" shows whether each binding is trusted on this machine and, where trusting it can help, \"opendox model-binding trust --repo-root \" trusts one after showing what it would run and where it would connect; then restart this console. A binding already trusted is unavailable for the reason this console printed when its provider refused."; export function untrustedBindingRemedy(stateValue) { if (stateValue.catalogFailure) return null; diff --git a/tests/test_capability_honesty.py b/tests/test_capability_honesty.py index 30aa6ad1..dcc79d67 100644 --- a/tests/test_capability_honesty.py +++ b/tests/test_capability_honesty.py @@ -817,11 +817,34 @@ def host_gate(): column_seams.gate.unregister() +class _HostTrust: + """A HOST's trust policy that admits the console intake's hand-off, as a + host offering the intake registers one (T100 follow-on, A5): openDox's + own per-machine trust admits no intake, so under it the surface is not + offered. It trusts no binding.""" + + def verdict(self, binding, *, root): + from opendox import doxbench_trust + return doxbench_trust.TrustVerdict.untrusted_for( + binding, root=root, basis=doxbench_trust.BASIS_HOST, + reason="this stand-in host trusts no binding") + + def record(self, binding, *, root): + return self.verdict(binding, root=root) + + def intake_verdict(self, binding, *, root): + from opendox import doxbench_trust + return doxbench_trust.TrustVerdict.trusted_for( + binding, root=root, basis=doxbench_trust.BASIS_HOST) + + def test_a_host_that_registers_its_gate_is_offered_intake_and_approves( tmp_path, host_gate) -> None: import yaml - from opendox import doxbench_intake, serve + from opendox import doxbench_intake, doxbench_trust, serve + doxbench_trust.unregister() + doxbench_trust.register(_HostTrust()) repo = _repository(tmp_path, identity=True) document = _declare(tmp_path, repo) out = _snapshot(tmp_path, repo) diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index c60ce9e7..75ce2f89 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -290,9 +290,21 @@ 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): + def port(self, root: Path | None = None, *, harness: bool = False): + """The port the console's start declares. Whether the local harness + is installed is ANSWERED (`harness`, no by default), so what a case + sees never depends on what this machine has on its `PATH`.""" return install_mod.declared_model_port_factory( - self.tmp / "sessions", checkout_root=root or self.repo)() + self.tmp / "sessions", checkout_root=root or self.repo, + harness_present=lambda: harness)() + + def gated_port(self, root: Path | None = None): + """The port the trust gate resolves for the declared binding itself, + as a host that hands it one binding resolves it. The start passes + over a binding the catalog cannot list (T100 follow-on, A3); this + port is the defence beneath it, and still refuses such a binding.""" + return install_mod.trust_gated_model_port_factory( + self.declared(root), checkout_root=root or self.repo)() def nothing_was_touched(self) -> None: """No broker ran, the variable was never read, the keyring was never @@ -636,11 +648,16 @@ def test_a_binding_carrying_control_characters_is_shown_escaped(served, 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).""" + from opendox import doxbench_model + 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() + # the start passes it over, saying so (T100 follow-on, A3) ... + assert served.port() is doxbench_model.NO_MODEL_CONFIGURED + # ... and the gate beneath still refuses it + port = served.gated_port() with pytest.raises(_trust_mod().BindingUntrusted) as refused: port.dispatch(_Envelope()) assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 @@ -705,6 +722,11 @@ def _plant_link_to_the_file(served): def _plant_link_to_the_directory(served): + """The state directory replaced by a link of this user's own, to a + private directory of this user's own holding a private store: F16.1's + ratified text refuses even this one (T100 follow-on, A8; the holder's + ruling on openxFactory#656 comment 5982436447). Before A8 the case + passed only because the target was made writable by every user.""" real = served.tmp / "real-state" real.mkdir(mode=0o700) (real / _trust_mod().TRUST_FILENAME).write_text(_planted(served), @@ -712,11 +734,7 @@ def _plant_link_to_the_directory(served): 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" + return "the directory that holds the store is a symbolic link" def _plant_a_writable_file(served, mode=0o666): @@ -1303,10 +1321,15 @@ def test_an_id_the_catalog_refuses_prints_no_command_and_is_never_trusted( refused turn, `list`), each says why instead, and `trust` refuses it with nothing recorded. The catalog lists nothing, and the start does not fail.""" + from opendox import doxbench_model + trust_mod = _trust_mod() binding_id = HOSTILE_IDS[name] served.hand_write(served.record("env", id=binding_id)) - port = served.port() + # The start passes it over, saying why (T100 follow-on, A3), and the + # gate beneath refuses it. + assert served.port() is doxbench_model.NO_MODEL_CONFIGURED + port = served.gated_port() notice = capsys.readouterr().err with pytest.raises(trust_mod.BindingUntrusted) as refused: port.dispatch(_Envelope()) @@ -1356,10 +1379,14 @@ def test_a_binding_the_catalog_refuses_is_never_trusted_nor_fails_the_start( 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) + from opendox import doxbench_model + for policy in (served.trust, _TrustsEveryBinding()): trust_mod.unregister() trust_mod.register(policy) - port = served.port() + # passed over at the start (T100 follow-on, A3); refused beneath it + assert served.port() is doxbench_model.NO_MODEL_CONFIGURED + port = served.gated_port() assert list(port.catalog().entries) == [] with pytest.raises(trust_mod.BindingUntrusted) as refused: port.dispatch(_Envelope()) @@ -1451,8 +1478,12 @@ def test_a_link_to_nothing_on_the_way_to_the_store_is_refused_by_name( 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) + # The state directory itself is never a link, whatever it reaches (T100 + # follow-on, A8), so that is its refusal; above it, a link to nothing. + said = ("the directory that holds the store is a symbolic link" + if where == "state-directory" else + "it is a symbolic link to nothing") + assert f"refuses {json.dumps(str(link))}: {said}" 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), @@ -2353,6 +2384,11 @@ def test_an_approval_says_available_only_where_the_binding_is_trusted( "UNSERVABLE_TURN_MESSAGE": (["list"], ["edit"]), "APPROVED_UNSERVABLE_NOTICE": (["list"], ["edit", "remove"]), "REMEDY_UNSERVABLE": ([], ["edit", "remove"]), + # T100 follow-on, A1 and A2: where trusting cannot help, none quotes it + "UNTRUSTABLE_TURN_MESSAGE": (["list"], []), + "APPROVED_UNTRUSTABLE_NOTICE": (["list"], []), + "REMEDY_NOT_BY_TRUST": ([], []), + "REMEDY_IN_REPOSITORY": ([], ["edit"]), } @@ -2390,7 +2426,10 @@ def test_an_approval_reads_the_trust_seam_once(served, monkeypatch): 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.""" + host's: the store trusts the binding, and the host does not. + + The host declines for a reason of its own, which `trust` cannot repair, + so the approval names no command that trusts (T100 follow-on, A1).""" trust_mod = _trust_mod() _caps, answer = _served_intake(served, host_policy=_AdmitsTheIntake()) assert answer.get("error") is None, answer @@ -2406,12 +2445,13 @@ def registered_then_torn_down(): 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 approval["availability"] == trust_mod.APPROVED_UNTRUSTABLE_NOTICE assert trust_mod.current() is host @pytest.mark.parametrize("sentence", ["UNTRUSTED_TURN_MESSAGE", - "UNSERVABLE_TURN_MESSAGE"]) + "UNSERVABLE_TURN_MESSAGE", + "UNTRUSTABLE_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 @@ -2639,11 +2679,18 @@ def test_a_served_turn_on_an_untrusted_binding_says_how_to_trust_it(served, 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).""" + r4175203889). + + SINCE THE T100 FOLLOW-ON (A3) the start passes such a binding over, as + it does a pending one, so the console serves no model: the turn is + refused as a plane with no model capability refuses it, and the start + said why on stderr. Whether the local harness is installed is answered + (no), so the case never depends on this machine's `PATH`.""" import http.client from opendox import doxbench_hash, serve from opendox.serve_wire import (DOXBENCH_CHAT_TURN_V2_KIND, + DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE, DOXBENCH_ERR_MODEL_UNAVAILABLE) from standalone_child import fresh_repository, git, run_module @@ -2663,7 +2710,8 @@ def test_a_served_turn_on_an_untrusted_binding_says_how_to_trust_it(served, 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)) + install_mod.session_root_beside(out), checkout_root=repo, + harness_present=lambda: False)) worker = threading.Thread(target=httpd.serve_forever, daemon=True) worker.start() try: @@ -2710,12 +2758,1069 @@ def buffer(kind, path, content): httpd.shutdown() httpd.server_close() worker.join(timeout=10) - assert body.get("error") == DOXBENCH_ERR_MODEL_UNAVAILABLE, body if binding == "unservable": + assert body.get("error") == ( + DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE), body assert "model-binding trust" not in body.get("message", ""), body - assert body.get("message") == _trust_mod().UNSERVABLE_TURN_MESSAGE, ( - body) else: + assert body.get("error") == DOXBENCH_ERR_MODEL_UNAVAILABLE, body assert body.get("message") == _trust_mod().UNTRUSTED_TURN_MESSAGE, ( body) served.nothing_was_touched() + + +# =========================================================================== +# 6. THE T100 FOLLOW-ON: the holder's adversarial review of openDox-code#82 +# (findings A1 to A19, N1 and N2; rulings on openxFactory#656 comment +# 5982436447: A2 and A14 Brett Heap's, A8 the holder's). Each case fails at +# openDox-code `38d3350e`, where #82 landed, for its finding's reason. +# A14 is RULED "Served repo only, limit": no case, no change. +# =========================================================================== + + +class _OsWith: + """`os`, as the trust module sees it, with some names replaced (a spy, or + a system that answers otherwise). Everything else is the real `os`.""" + + def __init__(self, **replaced) -> None: + self._replaced = replaced + + def __getattr__(self, name): + if name in self._replaced: + return self._replaced[name] + return getattr(os, name) + + +def _no_trust_command_in(*texts: str) -> None: + for text in texts: + assert _printed_commands(text) == [], text + assert "opendox model-binding trust" not in text, text + + +def _store_holding(served, document) -> Path: + served.state_dir.mkdir(mode=0o700, exist_ok=True) + store = served.state_dir / _trust_mod().TRUST_FILENAME + store.write_text(document if isinstance(document, str) + else json.dumps(document), encoding="utf-8") + os.chmod(store, 0o600) + return store + + +# --- A1: no `trust` command where `trust` cannot repair the refusal -------- + + +def test_A1_an_unsupported_platform_is_told_no_trust_command( + served, capsys, monkeypatch): + """On a platform that cannot keep the store, `trust` is refused too, so + no command that trusts is printed: not by `list`, the start's notice, a + refused turn, nor the turn's fixed sentence (`UNTRUSTABLE_TURN_MESSAGE`). + Each names the cause and says to resolve it first.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + monkeypatch.setattr(trust_mod, "fcntl", None) # as on Windows + port = served.port() + assert isinstance(port, trust_mod.UntrustedBindingPort) + with pytest.raises(trust_mod.BindingUntrusted) as refused: + port.dispatch(_Envelope()) + assert trust_mod.turn_message_for(port) == ( + trust_mod.UNTRUSTABLE_TURN_MESSAGE) + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + captured = capsys.readouterr() + _no_trust_command_in(captured.out, captured.err, str(refused.value)) + for text in (captured.out, captured.err, str(refused.value)): + assert "POSIX" in text, text + assert trust_mod.REMEDY_NOT_BY_TRUST in text, text + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + served.nothing_was_touched() + + +def test_A1_a_hosts_own_refusal_is_told_no_trust_command(served, capsys): + """A host's policy that declines (a governed host, for a pending + declaration) declines `trust` too, so `list` prints the host's reason + and no command, and the approval names none either.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + _propose(served.repo, BINDING_ID) + trust_mod.unregister() + trust_mod.register(_GovernedHostPolicy()) + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + listed = capsys.readouterr().out + _no_trust_command_in(listed) + assert "its declaration is not approved" in listed + assert trust_mod.REMEDY_NOT_BY_TRUST in listed + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + class _Fails: + def verdict(self, binding, *, root): + raise RuntimeError(SECRET) + + record = verdict + + # the turn's sentence, for a host's refusal and for a host that fails + for policy in (_GovernedHostPolicy(), _Fails()): + trust_mod.unregister() + trust_mod.register(policy) + port = served.gated_port() + assert isinstance(port, trust_mod.UntrustedBindingPort) + assert trust_mod.turn_message_for(port) == ( + trust_mod.UNTRUSTABLE_TURN_MESSAGE) + assert SECRET not in capsys.readouterr().err + + +def test_A1_an_unusable_store_is_told_no_trust_command(served, capsys): + """A torn store refuses `trust` as it refuses the verdict, so the + start's notice prints its cause and recovery, and no command.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + _store_holding(served, "{") # a torn copy + served.port() + notice = capsys.readouterr().err + _no_trust_command_in(notice) + assert "does not read as JSON" in notice + assert trust_mod.RECOVER_MOVE_ASIDE in notice + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + + +@pytest.mark.parametrize("reason", ["never", "changed", "record-form", + "not-covered", "no-verdict"]) +def test_A1_each_reason_trust_repairs_prints_the_command_that_repairs_it( + served, capsys, reason): + """The other side of A1: where `trust` CAN repair the refusal, the + command is printed, and, run as printed, it trusts the binding.""" + import shlex + + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + words = {"never": trust_mod.REASON_NEVER_TRUSTED, + "changed": trust_mod.REASON_CHANGED, + "record-form": trust_mod.REASON_RECORD_FORM, + "not-covered": trust_mod.REASON_NOT_COVERED, + "no-verdict": trust_mod.REASON_NO_VERDICT}[reason] + assert trust_mod.trust_can_repair(words) + remedy = trust_mod.trust_remedy(BINDING_ID, str(served.repo), words) + [command] = _printed_commands(remedy) + assert _cli(*shlex.split(command)[1:]) == 0 + capsys.readouterr() + assert trust_mod.verdict_for(served.declared(), root=served.repo).trusted + + +# --- A2: a broker program inside the served repository is never trusted ---- + + +def _in_repository_argv(served, where, monkeypatch): + """A broker command naming a file inside the served repository, `where` + each way a command can name one. The repository holds a copy of the + marker broker at `tools/broker.py`.""" + tool = served.repo / "tools" / "broker.py" + tool.parent.mkdir(exist_ok=True) + shutil.copy(served.broker, tool) + if where == "absolute": + return [sys.executable, str(tool)] + if where == "relative-to-the-working-directory": + monkeypatch.chdir(served.repo) + return [sys.executable, "tools/broker.py"] + if where == "relative-from-deeper-in-it": + # as written from the working directory, and from no other + monkeypatch.chdir(served.repo / "tools") + return [sys.executable, "../tools/broker.py"] + if where == "bare-word-in-the-working-directory": + monkeypatch.chdir(served.repo / "tools") + return [sys.executable, "broker.py"] + if where == "module-after-dash-m": + monkeypatch.chdir(served.repo) + return [sys.executable, "-m", "tools.broker"] + if where == "flag-value": + return [sys.executable, str(served.broker), f"--config={tool}"] + if where == "link-from-outside": + link = served.tmp / "outside-link.py" + link.symlink_to(tool) + return [sys.executable, str(link)] + if where == "first-word-on-path": + program = served.repo / "bin" / "opref-broker" + program.parent.mkdir() + program.write_text(f"#!{sys.executable}\n" + + tool.read_text(encoding="utf-8"), + encoding="utf-8") + os.chmod(program, 0o755) + monkeypatch.setenv("PATH", f"{program.parent}{os.pathsep}" + f"{os.environ.get('PATH', '')}") + return ["opref-broker"] + raise AssertionError(where) + + +IN_REPOSITORY = ("absolute", "relative-to-the-working-directory", + "relative-from-deeper-in-it", + "bare-word-in-the-working-directory", "module-after-dash-m", + "flag-value", "link-from-outside", "first-word-on-path") + + +@pytest.mark.parametrize("where", IN_REPOSITORY) +def test_A2_a_broker_inside_the_repository_is_refused_by_name( + served, capsys, monkeypatch, where): + """RULED by Brett Heap, openxFactory#656 comment 5982436447, item 2, + "Refuse in-repo programs". Trust is of the binding's record, and a pull + can change a program inside the repository after the record was + trusted, so a binding whose broker command names one is refused BY NAME + where trust is recorded (`trust`, `add`) and where it is checked (the + verdict, under any policy, a host's that trusts everything included), + with the remedy "install the broker outside the repository".""" + trust_mod = _trust_mod() + argv = _in_repository_argv(served, where, monkeypatch) + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + assert trust_mod.in_repository_program(binding, root=served.repo) + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + err = capsys.readouterr().err + assert trust_mod.REASON_IN_REPOSITORY in err + assert "install the broker outside the repository" in err + assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() + for policy in (served.trust, _TrustsEveryBinding()): + trust_mod.unregister() + trust_mod.register(policy) + verdict = trust_mod.verdict_for(binding, root=served.repo) + assert not verdict.trusted + assert verdict.reason == trust_mod.REASON_IN_REPOSITORY + served.nothing_was_touched() + + +def test_A2_a_broker_outside_the_repository_is_trusted_as_before( + served, capsys): + """The rule names files, so what names none is not refused: an option's + absolute value outside the repository, a URL, a bare word that names no + file, and options with no value. `trust` records it, and a turn uses + it.""" + trust_mod = _trust_mod() + argv = [sys.executable, str(served.broker), "--config=/etc/opref.conf", + "--issuer=https://auth.example/v1", "-v", "--quiet", "plain-word"] + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + assert trust_mod.in_repository_program(binding, root=served.repo) is None + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 0 + capsys.readouterr() + assert trust_mod.verdict_for(binding, root=served.repo).trusted + assert isinstance(served.port(), provider_mod.BrokeredProviderPort) + + +def test_A2_a_trusted_broker_edited_in_the_repository_never_runs( + served, capsys, monkeypatch): + """The review's case: the binding trusted (here, straight into the + store, as a store written before the rule), then a pull edits the + program it runs. Nothing runs: the start refuses it, the gate beneath + refuses even a verdict that admits it, and so does the console intake's + own question.""" + trust_mod = _trust_mod() + argv = _in_repository_argv(served, "absolute", monkeypatch) + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + served.trust.record(binding, root=served.repo) + canary = served.tmp / "CANARY" + tool = Path(argv[1]) + tool.write_text(f"open({str(canary)!r}, 'w').close()\n" + + tool.read_text(encoding="utf-8"), encoding="utf-8") + port = served.port() + assert isinstance(port, trust_mod.UntrustedBindingPort) + with pytest.raises(trust_mod.BindingUntrusted) as refused: + port.dispatch(_Envelope()) + assert "install the broker outside the repository" in str(refused.value) + _no_trust_command_in(str(refused.value), capsys.readouterr().err) + admitted = trust_mod.TrustVerdict.trusted_for( + binding, root=served.repo, basis=trust_mod.BASIS_HOST) + with pytest.raises(trust_mod.BindingUntrusted) as beneath: + trust_mod.require_admitted(binding, admitted) + assert trust_mod.REASON_IN_REPOSITORY in str(beneath.value) + with pytest.raises(binding_mod.BindingRefused): + provider_mod.mint(binding, trust=admitted) + trust_mod.unregister() + trust_mod.register(_AdmitsTheIntake()) + intake = trust_mod.intake_verdict_for(binding, root=served.repo) + assert not intake.trusted + assert intake.reason == trust_mod.REASON_IN_REPOSITORY + assert not canary.exists() + served.nothing_was_touched() + + +def test_A2_add_refuses_a_broker_inside_the_repository_and_writes_nothing( + served, capsys, monkeypatch): + trust_mod = _trust_mod() + argv = _in_repository_argv(served, "absolute", monkeypatch) + adding = served.add_argv("broker") + adding = adding[:adding.index("--") + 1] + argv + assert _cli(*adding) == 1 + assert trust_mod.REMEDY_IN_REPOSITORY in capsys.readouterr().err + assert not binding_mod.bindings_path(served.repo).exists() + assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() + + +# --- A3: an unservable binding is passed over, so 16.4's remedies work ----- + + +def test_A3_the_start_passes_over_a_binding_the_catalog_cannot_list( + served, capsys): + """The rail's "No model configured" line offers two remedies, the local + harness on PATH and another binding. With a binding the catalog cannot + list declared first, the start declared a refusing port for it and + neither remedy could take effect. It is passed over, as a pending one + is, and the start says so by name.""" + from opendox import doxbench_model + + trust_mod = _trust_mod() + served.hand_write(served.record("env", label="L" * 201)) + assert served.port() is doxbench_model.NO_MODEL_CONFIGURED + notice = capsys.readouterr().err + assert (f"[model-provider] model binding {trust_mod.shown(BINDING_ID)} " + f"is passed over: {trust_mod.REASON_UNSERVABLE}. " + f"{trust_mod.REMEDY_UNSERVABLE}") in notice + # remedy (1): the local harness, installed, is what the start declares + harness = served.port(harness=True) + assert not isinstance(harness, trust_mod.UntrustedBindingPort) + assert [entry.model_id for entry in harness.catalog().entries] == [ + "omp-local"] + # remedy (2): another binding, added, is what the start declares + second = served.add_argv("env") + second[second.index("--id") + 1] = "second-model" + assert _cli(*second) == 0 + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + listed = capsys.readouterr().out + assert "passes over it: the model catalog cannot list" in listed + port = served.port() + assert isinstance(port, provider_mod.BrokeredProviderPort) + assert [entry.model_id for entry in port.catalog().entries] == [ + "second-model"] + + +# --- A4: a trusted binding's refusal is printed where the rail says -------- + + +@pytest.mark.parametrize("kind", ["env", "broker"]) +def test_A4_a_trusted_bindings_refusal_is_printed_once_by_name( + served, capsys, kind): + """`UNTRUSTED_BINDING_REMEDY` says a trusted binding is unavailable "for + the reason this console printed when its provider refused". The broker's + stderr is not read and the turn's refusal is not printed, so the port + prints it: one fixed line naming the binding and its fixed diagnostic, + as it turns unavailable, and not again while it stays so. Here the + `env:` reference resolves to nothing, or the broker exits without an + answer.""" + from opendox import doxbench_model + + adding = served.add_argv(kind) + if kind == "env": + served.environ.pop(SECRET_NAME) # the reference resolves to nothing + else: + failing = served.tmp / "failing-broker.py" + failing.write_text("import sys\nsys.exit(3)\n", encoding="utf-8") + adding[-1] = str(failing) + assert _cli(*adding) == 0 + capsys.readouterr() + port = served.port() + entry = port.catalog().entries[0] + for _ in range(2): + outcome = doxbench_model.dispatch_turn(port, _Envelope(), entry=entry, + clock=time.monotonic) + assert isinstance(outcome, doxbench_model.TurnDispatchFailure) + printed = capsys.readouterr().err.splitlines() + lead = (f"[model-provider] model binding " + f"{_trust_mod().shown(BINDING_ID)} is unavailable: ") + said = [line for line in printed if line.startswith(lead)] + assert len(said) == 1, printed + assert said[0][len(lead):] in provider_mod.FIXED_DIAGNOSTICS + assert all(not e.available for e in port.catalog().entries) + assert SECRET not in "\n".join(printed) + + +# --- A5: the intake is not offered where no policy could admit it ---------- + + +def _intake_surface(served) -> dict: + import http.client + + from opendox import serve + + snapshot = served.tmp / "out" / "snapshot.json" + httpd = serve.build_server( + REPO_ROOT / "src" / "opendox" / "web", snapshot, served.repo, + port=0, actor="brett", model_port_factory=lambda: None) + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + try: + base = httpd.server_address[:2] + connection = http.client.HTTPConnection(*base, timeout=30) + connection.request("GET", "/capabilities") + own = json.loads(connection.getresponse().read()) + connection.close() + connection = http.client.HTTPConnection(*base, timeout=30) + connection.request("GET", "/workbench/model-intake", headers={ + serve.CONSOLE_TOKEN_HEADER: own.get("console_token", "")}) + surface = json.loads(connection.getresponse().read()) + connection.close() + return surface + finally: + httpd.shutdown() + httpd.server_close() + worker.join(timeout=10) + + +@pytest.mark.parametrize("policy", ["none-registered", "strict-default", + "no-intake-verdict", "admits"]) +def test_A5_the_intake_is_offered_only_where_a_policy_could_admit_it( + served, policy): + """A host registers a gate (#77) and a broker is declared. Under + openDox's own trust (registered or not), or a host policy with no + `intake_verdict`, every hand-off is refused, so the surface is NOT + offered and says why; a host policy that answers `intake_verdict` is + offered it. Asking registers nothing.""" + from opendox import column_seams + + trust_mod = _trust_mod() + 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") + trust_mod.unregister() + if policy == "strict-default": + trust_mod.register(served.trust) + elif policy == "no-intake-verdict": + trust_mod.register(_TrustsEveryBinding()) + elif policy == "admits": + trust_mod.register(_AdmitsTheIntake()) + column_seams.gate.unregister() + column_seams.gate.register(_HostGate()) + try: + surface = _intake_surface(served) + finally: + column_seams.gate.unregister() + if policy == "admits": + assert surface.get("offered") is True and "reason" not in surface, ( + surface) + else: + assert surface.get("offered") is False, surface + assert surface.get("reason") == trust_mod.INTAKE_NOT_ADMISSIBLE + assert trust_mod.is_registered() == (policy != "none-registered") + assert not served.marker.exists() + + +# --- A6: set-credential's refusal names the document it read -------------- + + +def test_A6_set_credential_prints_the_command_for_the_document_it_read( + served, capsys): + import shlex + + trust_mod = _trust_mod() + record = served.record("broker") + elsewhere = served.tmp / "elsewhere.yaml" + elsewhere.write_text(json.dumps({ + "schema_version": 1, "kind": "model-provider-bindings", + "bindings": [record]}), encoding="utf-8") + served.hand_write({**record, "label": "Another form"}) + assert _cli("model-binding", "set-credential", "--repo-root", + str(served.repo), "--bindings", str(elsewhere), + "--id", BINDING_ID) == 1 + [command] = _printed_commands(capsys.readouterr().err) + assert "--bindings" in command, command + assert _cli(*shlex.split(command)[1:]) == 0 + capsys.readouterr() + refused_for = binding_mod.BindingStore(elsewhere).list()[0] + assert trust_mod.verdict_for(refused_for, root=served.repo).trusted + assert not trust_mod.verdict_for(served.declared(), + root=served.repo).trusted + + +# --- A7: a store refusal names its actual cause ---------------------------- + + +@pytest.mark.parametrize("case", ["torn-json", "newer-schema", "older-schema", + "another-kind", "state-dir-0500", + "state-dir-0500-once-locked"]) +def test_A7_a_store_refusal_names_its_cause_and_recovery(served, case): + """Only a link, an owner or a mode is blamed on another user. A torn + copy, a newer store, another kind of document and a directory this user + cannot write each name their own cause, and how to recover. Each is + refused, by `record` and by the verdict (A9: the kind and the version + are both checked).""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + planted = json.loads(_planted(served)) + if case == "torn-json": + _store_holding(served, '{"schema_version": 1, "kind"') + cause, recovery = "does not read as JSON", trust_mod.RECOVER_MOVE_ASIDE + elif case == "newer-schema": + _store_holding(served, {**planted, "schema_version": 2}) + cause, recovery = "schema_version 2", trust_mod.RECOVER_NEWER + elif case == "older-schema": + _store_holding(served, {**planted, "schema_version": 0}) + cause = "is not a trust store this install writes" + recovery = trust_mod.RECOVER_MOVE_ASIDE + elif case == "another-kind": + _store_holding(served, {**planted, "kind": "something-else"}) + cause = "is not a trust store this install writes" + recovery = trust_mod.RECOVER_MOVE_ASIDE + elif case == "state-dir-0500": + served.state_dir.mkdir(mode=0o700) + os.chmod(served.state_dir, 0o500) + cause, recovery = "cannot be opened", trust_mod.RECOVER_PERMISSIONS + else: + # the lock file exists, so it opens; the store's new copy cannot be + # created beside it + served.trust.record(_a_binding(id="another-model"), root=served.repo) + os.chmod(served.state_dir, 0o500) + cause = "could not be created" + recovery = trust_mod.RECOVER_PERMISSIONS + try: + with pytest.raises(trust_mod.TrustStoreRefused) as refused: + served.trust.record(served.declared(), root=served.repo) + verdict = served.trust.verdict(served.declared(), root=served.repo) + finally: + os.chmod(served.state_dir, 0o700) + words = str(refused.value) + assert "another user could change" not in words, words + assert cause in words and recovery in words, words + if not case.startswith("state-dir-0500"): + assert not verdict.trusted and cause in verdict.reason, verdict + + +# --- A8: a state directory that is a link trusts nothing ------------------- + + +def test_A8_a_state_directory_that_is_a_link_trusts_nothing(served): + """F16.1's ratified text (the holder's ruling, openxFactory#656 comment + 5982436447): "With the trust file, or a directory that holds it, + replaced by a symbolic link ... every binding reads untrusted". Here + the link is this user's own and reaches a private directory of this + user's own holding a store that would trust the binding.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + real = served.tmp / "real-state" + real.mkdir(mode=0o700) + planted = real / trust_mod.TRUST_FILENAME + planted.write_text(_planted(served), encoding="utf-8") + os.chmod(planted, 0o600) + served.state_dir.symlink_to(real, target_is_directory=True) + verdict = served.trust.verdict(served.declared(), root=served.repo) + assert not verdict.trusted + assert "the directory that holds the store is a symbolic link" in ( + verdict.reason) + held = planted.read_bytes() + with pytest.raises(trust_mod.TrustStoreRefused): + served.trust.record(served.declared(), root=served.repo) + assert planted.read_bytes() == held + + +# --- A9: the store rules the suite did not pin ------------------------------ + + +def test_A9_an_ancestor_others_could_write_trusts_nothing_unless_sticky( + served): + """G1. A directory above the store that another user could write, and + that is not sticky, could have the store's directory renamed away and + replaced; with the sticky bit set, it could not.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + shared = served.tmp / "shared" + shared.mkdir() + os.chmod(shared, 0o777) + (shared / "st").mkdir(mode=0o700) + store = shared / "st" / trust_mod.TRUST_FILENAME + store.write_text(_planted(served), encoding="utf-8") + os.chmod(store, 0o600) + policy = trust_mod.MachineTrust(state_dir=shared / "st") + verdict = policy.verdict(served.declared(), root=served.repo) + assert not verdict.trusted and "is not sticky" in verdict.reason, verdict + os.chmod(shared, 0o1777) + assert policy.verdict(served.declared(), root=served.repo).trusted + + +def test_A9_a_link_above_the_store_in_a_directory_others_could_write(served): + """G2. The directories judged include those the state directory is + SPELLED under, not only those it resolves under: a link above it, in a + directory another user could write, could be repointed.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + real = served.tmp / "real" + (real / "st").mkdir(mode=0o700, parents=True) + os.chmod(real, 0o700) + store = real / "st" / trust_mod.TRUST_FILENAME + store.write_text(_planted(served), encoding="utf-8") + os.chmod(store, 0o600) + shared = served.tmp / "shared" + shared.mkdir() + os.chmod(shared, 0o777) + (shared / "link").symlink_to(real, target_is_directory=True) + policy = trust_mod.MachineTrust(state_dir=shared / "link" / "st") + verdict = policy.verdict(served.declared(), root=served.repo) + assert not verdict.trusted and "is not sticky" in verdict.reason, verdict + os.chmod(shared, 0o755) + assert policy.verdict(served.declared(), root=served.repo).trusted + + +def test_A9_a_link_above_the_store_owned_by_another_user_trusts_nothing( + served, monkeypatch): + """A link above the state directory that another user owns could be + pointed elsewhere by them. The other owner is the system's answer, so + `lstat` answers it here.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + real = served.tmp / "real" + (real / "st").mkdir(mode=0o700, parents=True) + store = real / "st" / trust_mod.TRUST_FILENAME + store.write_text(_planted(served), encoding="utf-8") + os.chmod(store, 0o600) + link = served.tmp / "link" + link.symlink_to(real, target_is_directory=True) + policy = trust_mod.MachineTrust(state_dir=link / "st") + assert policy.verdict(served.declared(), root=served.repo).trusted + + def lstat(path, *args, **kwargs): + info = os.lstat(path, *args, **kwargs) + if os.fspath(path) != str(link): + return info + fields = list(info[:10]) + fields[4] = os.getuid() + 4242 + return os.stat_result(fields) + + monkeypatch.setattr(trust_mod, "os", _OsWith(lstat=lstat)) + verdict = policy.verdict(served.declared(), root=served.repo) + assert not verdict.trusted, verdict + assert f"owned by uid {os.getuid() + 4242}" in verdict.reason + + +def test_A9_the_lock_and_the_store_are_opened_without_waiting( + served, monkeypatch): + """Round 8 (r4178064601): both are opened non-blocking, so a FIFO in + either's place is refused by its type rather than waited on. Opening a + FIFO for reading and writing never waits on Linux, so the flag itself is + what is held.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + opened: dict[str, int] = {} + + def spy(path, flags, *args, **kwargs): + opened[os.path.basename(os.fspath(path))] = flags + return os.open(path, flags, *args, **kwargs) + + monkeypatch.setattr(trust_mod, "os", _OsWith(open=spy)) + served.trust.record(served.declared(), root=served.repo) + assert served.trust.verdict(served.declared(), root=served.repo).trusted + for name in (trust_mod.TRUST_LOCK_FILENAME, trust_mod.TRUST_FILENAME): + assert opened[name] & os.O_NONBLOCK, (name, opened) + + +def test_A9_a_refused_intake_body_is_drained_unread(served, monkeypatch): + """The console intake refused at its hand-off drains the body it was + sent, unread, so the connection answers rather than stalls.""" + from opendox import serve_workbench + + drained = [] + real = serve_workbench._drain_refused_body + + def spy(rfile, length): + drained.append(length) + return real(rfile, length) + + monkeypatch.setattr(serve_workbench, "_drain_refused_body", spy) + _caps, answer = _served_intake(served) + assert answer.get("reason") == _trust_mod().INTAKE_BROKER_UNTRUSTED + assert drained == [len(b"sk-stand-in-NOT-A-KEY")] + assert not served.marker.exists() + + +def test_A9_a_directory_made_for_the_store_is_judged_once_made( + served, monkeypatch): + """`_make_private_directories` judges each directory it makes by its + own descriptor once made. A system that leaves one writable by others + (here, a chmod between the make and the open) has it refused.""" + trust_mod = _trust_mod() + + def mkdir(name, mode=0o777, *, dir_fd=None): + os.mkdir(name, mode, dir_fd=dir_fd) + os.chmod(name, 0o770, dir_fd=dir_fd) + + monkeypatch.setattr(trust_mod, "os", _OsWith(mkdir=mkdir)) + with pytest.raises(trust_mod.TrustStoreRefused) as refused: + trust_mod._make_private_directories(served.tmp / "made" / "st") + assert "is writable by its group" in str(refused.value) + + +def test_A9_and_A17_the_store_and_its_directory_are_synced( + served, monkeypatch): + """The store's bytes are synced before the replace (A9), and the state + directory after it (A17), so a crash after `record` returns can neither + lose the new store's bytes nor bring back the store it replaced.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + served.trust.record(_a_binding(id="another-model"), root=served.repo) + events: list[tuple] = [] + + def fsync(descriptor): + info = os.fstat(descriptor) + events.append(("fsync", stat.S_ISDIR(info.st_mode), info.st_ino)) + return os.fsync(descriptor) + + def replace(source, target, *args, **kwargs): + events.append(("replace", os.path.basename(os.fspath(target)))) + return os.replace(source, target, *args, **kwargs) + + monkeypatch.setattr(trust_mod, "os", _OsWith(fsync=fsync, + replace=replace)) + served.trust.record(served.declared(), root=served.repo) + at = events.index(("replace", trust_mod.TRUST_FILENAME)) + assert any(event[0] == "fsync" and not event[1] + for event in events[:at]), events + directory = os.stat(served.state_dir).st_ino + assert ("fsync", True, directory) in events[at + 1:], events + + +# --- A10: no raw path reaches the terminal --------------------------------- + + +def test_A10_a_repositorys_path_is_printed_escaped(served, capsys): + root = served.fresh_repository("r\x1b[8m\n forged line") + served.hand_write(served.record("env"), root=root) + assert _cli("model-binding", "list", "--repo-root", str(root)) == 0 + assert _cli("model-binding", "trust", "--repo-root", str(root), + "no-such-binding") == 1 + adding = served.add_argv("env") + adding[adding.index("--repo-root") + 1] = str(root) + adding[adding.index("--id") + 1] = "second-model" + assert _cli(*adding) == 0 + captured = capsys.readouterr() + for text in (captured.out, captured.err): + assert "\x1b" not in text and "\n forged line" not in text, ( + repr(text[:300])) + assert "\\u001b[8m" in text + + +# --- A11: a failed edit leaves the trusted form trusted --------------------- + + +def test_A11_an_edit_whose_write_fails_keeps_the_trusted_form_trusted( + served, capsys): + trust_mod = _trust_mod() + assert _cli(*served.add_argv("env")) == 0 + capsys.readouterr() + before = served.declared() + document = binding_mod.bindings_path(served.repo) + editing = served.add_argv("env") + editing[1] = "edit" + editing[editing.index("--label") + 1] = "Renamed" + os.chmod(document, 0o444) + os.chmod(document.parent, 0o555) + try: + rc = _cli(*editing) + finally: + os.chmod(document.parent, 0o755) + os.chmod(document, 0o644) + err = capsys.readouterr().err + assert rc == 1 + assert "could not be written" in err and "nothing in it changed" in err + assert served.declared() == before + assert trust_mod.verdict_for(before, root=served.repo).trusted + + +# --- A12: a verdict subclass admits nothing -------------------------------- + + +def test_A12_a_verdict_subclass_cannot_admit_another_binding(served): + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + + class Lax(trust_mod.TrustVerdict): + def admits(self, binding): + return True + + lax = Lax(binding_id="some-other-binding", digest="sha256:0", + root=trust_mod.resolved_root(served.repo), trusted=True, + basis=trust_mod.BASIS_HOST) + + class Host: + def verdict(self, binding, *, root): + return lax + + def record(self, binding, *, root): + return lax + + trust_mod.unregister() + trust_mod.register(Host()) + port = served.port() + assert isinstance(port, trust_mod.UntrustedBindingPort), port + with pytest.raises(trust_mod.BindingUntrusted): + trust_mod.require_admitted(served.declared(), lax) + # beneath both: the provider's port lists nothing available for one, + # even a subclass that names this very binding + exact = trust_mod.TrustVerdict.trusted_for( + served.declared(), root=served.repo, basis=trust_mod.BASIS_HOST) + lookalike = Lax(**{field.name: getattr(exact, field.name) + for field in dataclasses.fields(exact)}) + brokered = provider_mod.BrokeredProviderPort( + served.declared(), install_mod.brokered_catalog(served.declared()), + trust=lookalike) + assert not any(entry.available for entry in brokered.catalog().entries) + served.nothing_was_touched() + + +# --- A13: the seam is read once, inside the refusal net --------------------- + + +def test_A13_a_host_torn_down_while_the_default_registers_records_anyway( + served, monkeypatch): + """`policy()` registered the default, then read the seam: a host that + unregistered between the two left the read nothing, and `recorded_for` + raised a `RuntimeError` no verb catches. It is one operation now.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + real = trust_mod.register_default + + def then_torn_down(): + held = real() + trust_mod.unregister() + return held + + monkeypatch.setattr(trust_mod, "register_default", then_torn_down) + trust_mod.unregister() + verdict = trust_mod.recorded_for(served.declared(), root=served.repo) + assert verdict.admits(served.declared()) + + +def test_A13_a_seam_that_fails_is_refused_by_name(served, monkeypatch): + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + + def fails(): + raise RuntimeError(SECRET) + + monkeypatch.setattr(trust_mod, "policy", fails) + with pytest.raises(trust_mod.TrustNotRecorded) as refused: + trust_mod.recorded_for(served.declared(), root=served.repo) + assert SECRET not in str(refused.value) + + +# --- A15: no case reads this machine's own trust ---------------------------- + + +_A15_SEEN: list[str] = [] + + +@pytest.fixture(scope="module") +def _a15_module_setting(): + """`OPENDOX_STATE_DIR` as a module-scoped fixture sees it: set up before + any case's own fixtures, so it is the session's.""" + return os.environ.get("OPENDOX_STATE_DIR") + + +def test_A15_a_case_has_a_scratch_state_directory_of_its_own( + tmp_path_factory, _a15_module_setting): + """The root conftest gives the session, and every case, a scratch + `OPENDOX_STATE_DIR` under this run's own temporary directory, so no + case and no wider fixture reads or writes the operator's trust. This + case also leaves a policy registered, which the next one proves was + dropped.""" + trust_mod = _trust_mod() + trust_mod.unregister() + trust_mod.register(_TrustsEveryBinding()) + setting = os.environ.get("OPENDOX_STATE_DIR") + assert setting, "no scratch state directory: the operator's is read" + assert _a15_module_setting, "a module fixture reads the operator's" + base = tmp_path_factory.getbasetemp().resolve() + for each in (setting, _a15_module_setting): + assert base in Path(each).resolve().parents, each + assert setting != _a15_module_setting + assert trust_mod.MachineTrust().state_dir() == Path(setting) + _A15_SEEN.append(setting) + + +def test_A15_the_seam_is_emptied_after_every_case(): + """Runs after the case above, which left a policy registered, and has a + state directory of its own.""" + assert not _trust_mod().is_registered() + assert os.environ.get("OPENDOX_STATE_DIR") not in _A15_SEEN + + +# --- A16: a digest of another scheme is not a "changed" binding ------------- + + +def test_A16_a_digest_of_another_scheme_reads_as_another_record_form( + served, capsys): + import shlex + + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + planted = json.loads(_planted(served)) + digest = planted["entries"][0]["digest"] + assert digest.startswith(trust_mod.DIGEST_PREFIX) + planted["entries"][0]["digest"] = "sha256/2:" + digest.split(":", 1)[1] + _store_holding(served, planted) + verdict = served.trust.verdict(served.declared(), root=served.repo) + assert not verdict.trusted + assert verdict.reason == trust_mod.REASON_RECORD_FORM + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + [command] = _printed_commands(capsys.readouterr().out) + assert _cli(*shlex.split(command)[1:]) == 0 + assert served.trust.verdict(served.declared(), root=served.repo).trusted + + +def test_A16_the_digest_scheme_names_every_field_of_the_record(): + """Adding a field to the binding record changes every digest, so it + must change the digest's scheme too: the scheme's field list is pinned to + the record's.""" + trust_mod = _trust_mod() + assert trust_mod.DIGEST_SCHEME_FIELDS == binding_mod.BINDING_FIELDS + assert set(_a_binding().as_record()) == { + "kind", *trust_mod.DIGEST_SCHEME_FIELDS} + + +# --- A18: the directory the store's tree is made from is judged ------------ + + +def test_A18_the_directory_the_store_is_made_in_is_judged_first(served): + trust_mod = _trust_mod() + shared = served.tmp / "shared" + shared.mkdir() + os.chmod(shared, 0o777) + with pytest.raises(trust_mod.TrustStoreRefused) as refused: + trust_mod._make_private_directories(shared / "st") + assert "is not sticky" in str(refused.value) + assert not (shared / "st").exists() + + +# --- A19: an approval never calls a trusted binding untrusted -------------- + + +def test_A19_an_approval_reads_this_machines_store_where_nothing_registered( + served): + """Nothing registered in the serving process yet (its console started + with only the pending binding, so the factory never asked), and this + machine's store trusts the approved binding: the approval says it is + available, and registers nothing.""" + 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) + trust_mod.unregister() + approval = _post_an_approval(served, BINDING_ID) + assert approval.get("ok") is True, approval + assert approval["availability"] == intake_mod.APPROVAL_NOTICE + assert not trust_mod.is_registered() + + +# --- N1: a settings document reached through a link is refused ------------- + + +@pytest.mark.parametrize("link", ["document", "its-directory"]) +def test_N1_add_never_writes_through_a_link_a_clone_carries( + served, capsys, link): + """A clone carries a link as readily as a file. One at the bindings + document's path, or at a directory of its default path, would have + `add` create or overwrite a file wherever it points.""" + outside = served.tmp / "outside" + outside.mkdir() + document = binding_mod.bindings_path(served.repo) + if link == "document": + document.parent.mkdir(parents=True) + document.symlink_to(outside / "created-by-add") + named = document + else: + document.parent.parent.mkdir(parents=True) + document.parent.symlink_to(outside, target_is_directory=True) + named = document.parent + assert _cli(*served.add_argv("env")) == 1 + err = capsys.readouterr().err + assert "symbolic link" in err and str(named) in err, err + assert list(outside.iterdir()) == [] + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 1 + assert "symbolic link" in capsys.readouterr().err + + +def test_N1_each_stores_write_refuses_a_link_by_itself(served): + """The read refuses a link first, so the write's own refusal is asked + directly: a link planted after a read is never written through.""" + outside = served.tmp / "outside" + outside.mkdir() + for path, store, refused, write in ( + (binding_mod.bindings_path(served.repo), binding_mod.BindingStore, + binding_mod.BindingRefused, + lambda store: store._save([_a_binding()])), + (intake_mod.declarations_path(served.repo), + intake_mod.DeclarationStore, intake_mod.IntakeRefused, + lambda store: store._save(None, []))): + path.parent.mkdir(parents=True, exist_ok=True) + path.symlink_to(outside / path.name) + with pytest.raises(refused) as said: + write(store(path)) + assert "symbolic link" in str(said.value) + assert list(outside.iterdir()) == [] + + +def test_N1_the_declarations_document_is_never_written_through_a_link( + served): + outside = served.tmp / "outside" + outside.mkdir() + document = intake_mod.declarations_path(served.repo) + document.parent.mkdir(parents=True) + document.symlink_to(outside / "created-by-intake") + with pytest.raises(intake_mod.IntakeRefused) as refused: + _propose(served.repo, BINDING_ID) + assert "symbolic link" in str(refused.value) + assert list(outside.iterdir()) == [] + + +# --- N2: an unreadable settings document is refused by name --------------- + + +UNREADABLE = {"not-utf-8": (b"\xff\xfe\x00schema_version: 1\n", "not UTF-8"), + "nested": (b"[" * 1000 + b"]" * 1000, "nests too deeply"), + "no-permission": (b"schema_version: 1\n", "cannot be read")} + + +@contextlib.contextmanager +def _unreadable(document: Path, case: str): + """`document` holding `case`'s bytes, and, for "no-permission", a mode + this user cannot read, restored afterwards.""" + raw, _said = UNREADABLE[case] + document.parent.mkdir(parents=True, exist_ok=True) + document.write_bytes(raw) + if case == "no-permission": + os.chmod(document, 0) + try: + yield + finally: + os.chmod(document, 0o644) + + +@pytest.mark.parametrize("case", sorted(UNREADABLE)) +def test_N2_an_unreadable_bindings_document_is_refused_by_name( + served, capsys, case): + """Not UTF-8, or nested past what the parser can descend: refused BY + NAME, never a raw error, and the console's start reads it as declaring + no binding and says why.""" + from opendox import doxbench_model + + _raw, said = UNREADABLE[case] + document = binding_mod.bindings_path(served.repo) + with _unreadable(document, case): + with pytest.raises(binding_mod.BindingRefused) as refused: + binding_mod.BindingStore(document).list() + assert said in str(refused.value) + assert served.port() is doxbench_model.NO_MODEL_CONFIGURED + assert said in capsys.readouterr().err + assert _cli("model-binding", "list", "--repo-root", + str(served.repo)) == 1 + assert said in capsys.readouterr().err + + +@pytest.mark.parametrize("case", sorted(UNREADABLE)) +def test_N2_an_unreadable_declarations_document_is_refused_by_name( + served, case): + _raw, said = UNREADABLE[case] + document = intake_mod.declarations_path(served.repo) + with _unreadable(document, case): + with pytest.raises(intake_mod.IntakeRefused) as refused: + intake_mod.DeclarationStore(document).get(BINDING_ID) + assert said in str(refused.value) + # the console's start reads it as declaring nothing pending + assert not intake_mod.pending_binding_ids(served.repo) From 46ac0a0f10245f4657fbd9d4475c048d0bdc8e79 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:29:22 +0000 Subject: [PATCH 02/16] T100 follow-on: Copilot's first review and the inline-script ruling (plan 034) This commit acts on Copilot's first review of openDox-code#86 (review 5407887563, 10 findings at 3e4958ab). It also covers A2 as extended by Brett Heap on openxFactory#656 comment 5983805990, "Refuse inline scripts (Recommended)". A2 extended: - A broker command that gives a shell or an interpreter an inline script is refused by name, both where trust is recorded and wherever it is judged, with REASON_INLINE_SCRIPT and REMEDY_INLINE_SCRIPT. Examples are sh/bash/zsh/dash -c, python -c, node -e/-p/--eval, perl/ruby -e, php -r, env -S, pwsh -Command, deno eval, and an awk or sed program. - Wrappers (env, timeout, busybox) hide none of these. A program is judged by the name it is called by and by the file it resolves to. - Every broker now starts with its working directory outside the served repository (doxbench_provider.BROKER_WORKING_DIRECTORY). Copilot round 1: - r4179076901: a failed edit puts its earlier trust back only while the store still holds the form that edit recorded, compared and written under the store's lock (MachineTrust.restore, restored_for). - r4179076956: both settings documents are written atomically (doxbench_binding.write_settings_document). - r4179076919: a path that is exactly the default relative path has its directories judged for links. - r4179076934: --bindings is made absolute, not resolved, so a link is refused rather than followed. - r4179076944: the program is never read as an option, and a member after "--" is never one. - r4179076973 and r4179076986: every path in a document's refusal is shown escaped. - r4179077004: REASON_NOT_COVERED, a policy's invalid answer, no longer prints a trust command. - r4179077018: a broker path holding a NUL is judged without failing. - r4179077029: the console intake is not offered for a broker the rules refuse (INTAKE_BROKER_REFUSED). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/cli_model_binding.py | 32 ++- src/opendox/doxbench_binding.py | 85 +++++- src/opendox/doxbench_intake.py | 10 +- src/opendox/doxbench_provider.py | 12 + src/opendox/doxbench_trust.py | 293 ++++++++++++++++++--- src/opendox/serve_workbench.py | 11 +- tests/test_model_binding_trust.py | 415 +++++++++++++++++++++++++++++- 7 files changed, 792 insertions(+), 66 deletions(-) diff --git a/src/opendox/cli_model_binding.py b/src/opendox/cli_model_binding.py index 1c69ead1..b667cd5a 100644 --- a/src/opendox/cli_model_binding.py +++ b/src/opendox/cli_model_binding.py @@ -40,8 +40,13 @@ def _binding_store(args: argparse.Namespace) -> "binding_mod.BindingStore": """The store this invocation acts on. `--bindings` when given, else the - checkout's own declared path — ONE rule, shared with the entrypoints.""" - path = (Path(args.bindings).resolve() if getattr(args, "bindings", None) + checkout's own declared path — ONE rule, shared with the entrypoints. + + `--bindings` is made absolute, NOT resolved (Copilot at openDox-code#86, + r4179076934): resolving it would follow a link at the document, or at a + directory above it, before the store could refuse one.""" + path = (Path(args.bindings).absolute() + if getattr(args, "bindings", None) else binding_mod.bindings_path(Path(args.repo_root).resolve())) return binding_mod.BindingStore(path) @@ -283,9 +288,11 @@ def cmd_model_binding_edit(args: argparse.Namespace) -> int: A WRITE THAT FAILS AFTER THE TRUST WAS RECORDED UNDOES IT (T100 follow-on, A11). The store holds one form per binding, so recording the new form untrusted the old one; where the old form was trusted, it is - trusted again, so a failed edit changes neither the document nor what - this machine trusts. The refusal names the write's cause, never a raw - error.""" + trusted again, so a failed edit changes neither the document (its write + is atomic) nor what this machine trusts. The undoing never overwrites a + trust recorded meanwhile (`doxbench_trust.restored_for`, Copilot at + openDox-code#86, r4179076901). The refusal names the write's cause, + never a raw error.""" store = _binding_store(args) try: binding = _declared_binding(args) @@ -298,12 +305,17 @@ def cmd_model_binding_edit(args: argparse.Namespace) -> int: try: store.edit(binding) except (binding_mod.BindingRefused, OSError) as error: + refusal = (binding_mod.BindingRefused(_cannot_write(store, error)) + if isinstance(error, OSError) else error) if was_trusted: - _record_trust(existing, args) - if isinstance(error, OSError): - raise binding_mod.BindingRefused( - _cannot_write(store, error)) from None - raise + try: + trust_mod.restored_for(existing, replacing=binding, + root=_repo_root(args)) + except binding_mod.BindingRefused as restoring: + raise binding_mod.BindingRefused( + f"{refusal}; and the trust its earlier form held " + f"could not be restored: {restoring}") from None + raise refusal from None except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index 657ac019..6d135dd0 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -89,7 +89,10 @@ from __future__ import annotations import dataclasses +import json import re +import stat +import tempfile from collections.abc import Iterable, Mapping from pathlib import Path from typing import NamedTuple @@ -537,11 +540,13 @@ def linked_component(path: Path | str, relpath: str) -> Path | None: reached through one could be read from, or written to, anywhere the link points: a write through it would create or overwrite a file outside the repository. Directories above `relpath`, the checkout's own path, are - the operator's.""" + the operator's. A path that IS `relpath`, relative to the working + directory, is judged the same way (Copilot at openDox-code#86, + r4179076919).""" path = Path(path) candidates = [path] parts = Path(relpath).parts - if len(path.parts) > len(parts) and path.parts[-len(parts):] == parts: + if len(path.parts) >= len(parts) and path.parts[-len(parts):] == parts: candidates += list(path.parents)[:len(parts) - 1] for candidate in candidates: if candidate.is_symlink(): @@ -549,31 +554,88 @@ def linked_component(path: Path | str, relpath: str) -> Path | None: return None -#: What a store says of a settings document reached through a link. +#: What a store says of a settings document reached through a link. Both +#: paths are filled in `shown_path`'s form. LINKED_DOCUMENT = ( "the {what} at {path} is reached through a symbolic link ({link}), which " "a clone can carry to point anywhere, so it is neither read nor written; " "replace the link with the file or directory itself") +def shown_path(path: Path | str) -> str: + """A path as a refusal prints it: in a JSON string's form, as + `doxbench_trust.shown` prints every value a repository wrote, so a + newline or a terminal control sequence in a checkout's path is escaped + and cannot forge or hide output (Copilot at openDox-code#86, + r4179076973, r4179076986).""" + return json.dumps(str(path), ensure_ascii=True) + + +def _write_all(handle, text: str) -> None: + """Write `text` to the open file `handle`. A seam of its own, so a case + can fail a write part way, as a full disk does.""" + handle.write(text) + + +def write_settings_document(path: Path, text: str) -> None: + """Replace the settings document at `path` with `text`, ATOMICALLY + (Copilot at openDox-code#86, r4179076956): written whole to a new file + beside it, created exclusively and never through a link, then renamed + over it, keeping the document's mode. A write that fails part way (a + full disk, an I/O error) leaves the document as it was, and the new + file is removed, so a refusal can say nothing in it changed. A document + this user cannot write is refused as it always was, by the system's own + error, rather than replaced. A document that does not exist yet is + written in place, and removed again if that write fails.""" + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + try: + with path.open("x", encoding="utf-8") as handle: + _write_all(handle, text) + except FileExistsError: + pass # made meanwhile: replaced below instead + except BaseException: + path.unlink(missing_ok=True) + raise + else: + return + with path.open("a", encoding="utf-8"): + pass # this user may write it, or PermissionError + mode = stat.S_IMODE(path.stat().st_mode) + handle = tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", + suffix=".opendox-new", delete=False) + temporary = Path(handle.name) + try: + with handle: + _write_all(handle, text) + temporary.chmod(mode) + temporary.replace(path) + except BaseException: + temporary.unlink(missing_ok=True) + raise + + def read_settings_document(path: Path, *, what: str, yaml, refused): """The YAML document at `path`, parsed, or a refusal BY NAME (`refused`, the caller's own refusal class) for one that cannot be read (T100 follow-on, N2): one the system will not read for this user, not UTF-8, nested past what the parser can descend, or not YAML. A console's start reads it, so none of these may surface as a raw error there.""" + shown = shown_path(path) try: return yaml.safe_load(path.read_text(encoding="utf-8")) except OSError as error: - raise refused(f"the {what} at {path} cannot be read " + raise refused(f"the {what} at {shown} cannot be read " f"({error.strerror or type(error).__name__})") from None except UnicodeDecodeError: - raise refused(f"the {what} at {path} is not UTF-8 text") from None + raise refused(f"the {what} at {shown} is not UTF-8 text") from None except RecursionError: - raise refused(f"the {what} at {path} nests too deeply to " + raise refused(f"the {what} at {shown} nests too deeply to " "read") from None except yaml.YAMLError as error: - raise refused(f"the {what} at {path} is not readable YAML") from error + raise refused(f"the {what} at {shown} is not readable " + "YAML") from error def _require_non_blank_str(field: str, value: object) -> str: @@ -1080,7 +1142,8 @@ def _refuse_a_link(self) -> None: link = linked_component(self.path, DEFAULT_BINDINGS_RELPATH) if link is not None: raise BindingRefused(LINKED_DOCUMENT.format( - what="bindings document", path=self.path, link=link)) + what="bindings document", path=shown_path(self.path), + link=shown_path(link))) def _load(self) -> list[ModelProviderBinding]: self._refuse_a_link() @@ -1131,10 +1194,8 @@ def _save(self, bindings: Iterable[ModelProviderBinding]) -> None: "kind": BINDINGS_KIND, "bindings": [binding.as_record() for binding in bindings], } - self.path.parent.mkdir(parents=True, exist_ok=True) - self.path.write_text( - yaml.safe_dump(document, sort_keys=False, allow_unicode=True), - encoding="utf-8") + write_settings_document(self.path, yaml.safe_dump( + document, sort_keys=False, allow_unicode=True)) #: What a removal does and does not do, stated once so no surface invents its diff --git a/src/opendox/doxbench_intake.py b/src/opendox/doxbench_intake.py index b4c91021..cd5b56bf 100644 --- a/src/opendox/doxbench_intake.py +++ b/src/opendox/doxbench_intake.py @@ -674,7 +674,9 @@ def _refuse_a_link(self) -> None: DEFAULT_DECLARATIONS_RELPATH) if link is not None: raise IntakeRefused(binding_mod.LINKED_DOCUMENT.format( - what="declarations document", path=self.path, link=link)) + what="declarations document", + path=binding_mod.shown_path(self.path), + link=binding_mod.shown_path(link))) def _load(self) -> tuple[BrokerDeclaration | None, list[ModelDeclaration]]: self._refuse_a_link() @@ -730,10 +732,8 @@ def _save(self, broker: BrokerDeclaration | None, "broker": broker.as_record() if broker is not None else None, "declarations": [d.as_record() for d in declarations], } - self.path.parent.mkdir(parents=True, exist_ok=True) - self.path.write_text( - yaml.safe_dump(document, sort_keys=False, allow_unicode=True), - encoding="utf-8") + binding_mod.write_settings_document(self.path, yaml.safe_dump( + document, sort_keys=False, allow_unicode=True)) def declarations_path(checkout_root: Path | str) -> Path: diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index e2174c4e..715c6219 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -308,6 +308,16 @@ #: grammar can no longer reach a mint. Keeping a sentence here that no path can #: raise would be a refusal nobody can trigger, asserted by a test that proves #: nothing. +#: The working directory every broker starts in (T100 follow-on, A2 +#: extended; RULED by Brett Heap, openxFactory#656 comment 5983805990, +#: "Refuse inline scripts (Recommended)", item 2): the file system's root, +#: which lies outside every served repository. So nothing a broker, or an +#: interpreter it runs, finds relative to its working directory (a relative +#: path, `python -m`'s first import) can be a file a pull changes, whatever +#: directory the console was started from. Defence in depth beneath +#: `doxbench_trust.broker_refusal`, which refuses such a command outright. +BROKER_WORKING_DIRECTORY = os.path.abspath(os.sep) + FIXED_DIAGNOSTICS: frozenset[str] = frozenset({ DIAG_BROKER_UNREACHABLE, DIAG_BROKER_REFUSED, DIAG_BROKER_MALFORMED, DIAG_BROKER_TIMEOUT, DIAG_PROVIDER_UNREACHABLE, @@ -605,6 +615,8 @@ def _run_broker(argv, *, source, timeout: float, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=bridge_mod.child_environment(os.environ), + # OUTSIDE EVERY SERVED REPOSITORY (BROKER_WORKING_DIRECTORY). + cwd=BROKER_WORKING_DIRECTORY, text=True, # Its own process group, so a refusal can kill its descendants # too (`_kill_the_group`). The session, and so the terminal, is diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 2e89a35d..67196f35 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -147,9 +147,12 @@ "UNTRUSTED_TURN_MESSAGE", "UntrustedBindingPort", "INTAKE_BROKER_UNTRUSTED", + "INTAKE_BROKER_REFUSED", "INTAKE_NOT_ADMISSIBLE", + "REASON_INLINE_SCRIPT", "REASON_IN_REPOSITORY", "REASON_RECORD_FORM", + "REMEDY_INLINE_SCRIPT", "REMEDY_IN_REPOSITORY", "REMEDY_NOT_BY_TRUST", "UNTRUSTABLE_TURN_MESSAGE", @@ -157,8 +160,13 @@ "REMEDY_UNSERVABLE", "binding_digest", "command_safe_id", + "broker_command_refused", + "broker_refusal", + "in_repository_argv", + "inline_script", "in_repository_program", "intake_admissible", + "restored_for", "trust_can_repair", "current", "is_registered", @@ -285,6 +293,25 @@ "repository, then declare the binding with that command with \"opendox " "model-binding edit\", which records trust for what it writes") +#: Why a binding whose broker command gives a shell or an interpreter an +#: INLINE script is never trusted (T100 follow-on, A2 extended; RULED by +#: Brett Heap, openxFactory#656 comment 5983805990, "Refuse inline scripts +#: (Recommended)"). The script is text, not a file a review can pin, and it +#: can run whatever the repository holds (`/bin/sh -c "exec +#: ./tools/broker.py"`), so the program must be a real file outside the +#: served repository. +REASON_INLINE_SCRIPT = ( + "its broker command gives a shell or an interpreter an inline script, " + "which is no file a review can pin and can run whatever the repository " + "holds") + +#: What an operator is told to do about such a binding. +REMEDY_INLINE_SCRIPT = ( + "Trusting it cannot make it usable: name the broker program itself, for " + "example [\"pass\", \"show\", \"key\"], or a script kept outside the " + "repository, then declare the binding with that command with \"opendox " + "model-binding edit\", which records trust for what it writes") + #: What every refusal says, in place of a command, where `trust` itself would #: be refused for the same reason (T100 follow-on, A1): the store cannot be #: used, the platform cannot keep it, or a host's policy declines. No command @@ -405,10 +432,10 @@ def reason_policy_failed(error: BaseException) -> str: UNTRUSTABLE_TURN_MESSAGE = ( "the model binding this install declares is not usable on this machine, " "and trusting it cannot help yet: the trust store, the platform or the " - "host's trust policy refuses it, or its broker lies inside the " - "repository. Nothing was sent and nothing was contacted. Run \"opendox " - "model-binding list --repo-root \" to see why and what to " - "do, and restart this console") + "host's trust policy refuses it, or its broker command runs a program " + "inside the repository or an inline script. Nothing was sent and " + "nothing was contacted. Run \"opendox model-binding list --repo-root " + "\" to see why and what to do, and restart this console") #: What the chat rail says when the catalog lists a declared model and none is @@ -471,12 +498,25 @@ def reason_policy_failed(error: BaseException) -> str: APPROVED_UNTRUSTABLE_NOTICE = ( "the model is approved for this console, but its binding is not usable " "on this machine, and trusting it cannot help yet: the trust store, the " - "platform or the host's trust policy refuses it, or its broker lies " - "inside the repository. \"opendox model-binding list --repo-root " + "platform or the host's trust policy refuses it, or its broker command " + "runs a program inside the repository or an inline script. \"opendox " + "model-binding list --repo-root " "\" shows why and what to do; then restart this console. The " "credential remains in the broker's custody and this act neither mints " "nor reads one") +#: Why the console intake is not offered where the broker command the +#: served repository's declarations document names runs a program inside +#: the repository, or an inline script (T100 follow-on, A2 and its +#: extension; Copilot at openDox-code#86, r4179077029): every hand-off to it +#: is refused before any policy is asked. A FIXED sentence. +INTAKE_BROKER_REFUSED = ( + "the console intake is not offered: the broker command the served " + "repository's declarations document names runs a program inside the " + "repository, or an inline script, which no review can pin, so no " + "hand-off to it is ever admitted. Name a broker program installed " + "outside the repository") + #: Why the console intake is not offered where the trust policy registered #: now could not admit its hand-off (T100 follow-on, A5). openDox's own #: per-machine store admits no intake, so an intake offered under it would @@ -642,11 +682,14 @@ def _command_from_the_root(binding_id: str, root: str | None, #: The reasons `trust` repairs (T100 follow-on, A1): openDox's own "never #: trusted here", "changed since", "trusted under another form of the -#: record", and a verdict that covered another binding or none at all. Every -#: other reason is one `trust` would be refused for as well. +#: record", and no verdict at all. Every other reason is one `trust` would +#: be refused for as well. A verdict that covers another binding +#: (`REASON_NOT_COVERED`) is a policy's invalid answer, which the same +#: policy gives when asked to record, so it is not one (Copilot at +#: openDox-code#86, r4179077004). TRUST_REPAIRS: frozenset[str] = frozenset({ REASON_NEVER_TRUSTED, REASON_CHANGED, REASON_RECORD_FORM, - REASON_NOT_COVERED, REASON_NO_VERDICT}) + REASON_NO_VERDICT}) def trust_can_repair(reason: str | None) -> bool: @@ -678,6 +721,8 @@ def trust_remedy(binding_id: str, root: str | None, return REMEDY_UNSERVABLE if reason == REASON_IN_REPOSITORY: return REMEDY_IN_REPOSITORY + if reason == REASON_INLINE_SCRIPT: + return REMEDY_INLINE_SCRIPT if reason is not None and not trust_can_repair(reason): return REMEDY_NOT_BY_TRUST command = trust_command(binding_id, root, bindings=bindings) @@ -765,15 +810,16 @@ def require_admitted(binding, trust: TrustVerdict | None) -> None: trust. A verdict is a `TrustVerdict` EXACTLY: a subclass could answer `admits` as it liked (T100 follow-on, A12). - A BROKER INSIDE THE REPOSITORY IS REFUSED HERE TOO, by the verdict's own - root, so the defence beneath the factory holds even for a verdict a - policy gave before the rule existed (T100 follow-on, A2; - `in_repository_program`).""" + A BROKER COMMAND THE RULES REFUSE IS REFUSED HERE TOO (a program inside + the repository, by the verdict's own root, or an inline script), so the + defence beneath the factory holds even for a verdict a policy gave + before the rule existed (T100 follow-on, A2 and its extension; + `broker_refusal`).""" if type(trust) is TrustVerdict and trust.admits(binding): - if (trust.root is not None - and in_repository_program(binding, root=trust.root)): + refused = broker_refusal(binding, root=trust.root) + if refused is not None: raise BindingUntrusted(refusal_message( - trust.binding_id, trust.root, REASON_IN_REPOSITORY)) + trust.binding_id, trust.root, refused)) return binding_id = getattr(binding, "id", "") if type(trust) is not TrustVerdict: @@ -880,11 +926,15 @@ def _module_paths(name: str, *, root: Path) -> list[Path]: def _resolved(found: list[str]) -> list[Path]: + """Each path resolved, links followed, or as written, made absolute, + where it cannot be resolved: a loop, or an embedded NUL, which no + program can be run with anyway (Copilot at openDox-code#86, + r4179077018).""" paths: list[Path] = [] for path in found: try: paths.append(Path(path).resolve()) - except (OSError, RuntimeError): + except (OSError, RuntimeError, ValueError): paths.append(Path(os.path.abspath(path))) return paths @@ -903,7 +953,9 @@ def in_repository_program(binding, *, root: Path | str) -> str | None: repository" (`REMEDY_IN_REPOSITORY`). Every member of the base invocation is asked, placeholders filled; of a - member that is an option (`-v`, `--config=VALUE`), only its value is. A + member that is an option (`-v`, `--config=VALUE`), only its value is. The + program itself is never an option, whatever its name, and nothing after + a `--` member is one (Copilot at openDox-code#86, r4179076944). A member names a file inside the repository where what it resolves to (`_program_path`) is the served root or lies under it, and so does a module named after `-m` that would @@ -911,10 +963,21 @@ def in_repository_program(binding, *, root: Path | str) -> str | None: has no command.""" if binding.credential_source() != binding_mod.CREDENTIAL_FROM_BROKER: return None + return in_repository_argv(binding.substituted_argv(), root=root) + + +def in_repository_argv(members, *, root: Path | str) -> str | None: + """The member of a broker command `members` that names a file inside + the served repository, or None: `in_repository_program`'s rule, for a + command no binding carries yet (the console intake's broker).""" served = Path(resolved_root(root)) - members = binding.substituted_argv() + members = tuple(members) + positional = False for index, member in enumerate(members): - if member.startswith("-"): + if index and member == "--" and not positional: + positional = True + continue + if index and member.startswith("-") and not positional: candidates = ([member.split("=", 1)[1]] if "=" in member else []) else: @@ -922,13 +985,139 @@ def in_repository_program(binding, *, root: Path | str) -> str | None: paths = [path for candidate in candidates if candidate for path in _program_path(candidate, first=index == 0, root=served)] - if index and members[index - 1] == "-m": + if index and members[index - 1] == "-m" and not positional: paths += _module_paths(member, root=served) if any(path == served or served in path.parents for path in paths): return member return None +#: Shells: an option cluster holding `c` gives one an inline script. +_SHELLS = frozenset({"sh", "bash", "rbash", "zsh", "dash", "ksh", "mksh", + "pdksh", "ash", "yash", "posh", "fish", "csh", "tcsh"}) + +#: Each interpreter, by its file name without a version suffix, and the +#: letters of a short option cluster that give it an inline script +#: (`python -c`, `perl -e`, `node -e`/`-p`, `php -r`, `env -S`). +_INLINE_LETTERS: dict[str, str] = { + **{shell: "c" for shell in _SHELLS}, + "python": "c", "pypy": "c", "jython": "c", + "perl": "eE", "ruby": "e", "php": "r", "lua": "e", "luajit": "e", + "node": "ep", "nodejs": "ep", "bun": "ep", "osascript": "e", + "env": "S", +} + +#: The long options that give an interpreter an inline script, as a member +#: or as `--option=VALUE`. PowerShell's are matched without regard to case. +_INLINE_LONG: dict[str, tuple[str, ...]] = { + "node": ("--eval", "--print"), "nodejs": ("--eval", "--print"), + "bun": ("--eval", "--print"), "fish": ("--command",), + "env": ("--split-string",), + "pwsh": ("-c", "-command", "--command", "-e", "-ec", "-encodedcommand", + "--encodedcommand", "-cwa", "-commandwithargs"), +} +_INLINE_LONG["powershell"] = _INLINE_LONG["pwsh"] + +#: Programs whose own first operand IS a script, unless a file is named for +#: it (`-f FILE`), judged where they are the command's program. +_SCRIPT_OPERAND = frozenset({"awk", "gawk", "mawk", "nawk", "sed"}) + +_SHORT_CLUSTER = re.compile(r"-[A-Za-z]+") + + +def _unversioned(name: str) -> str: + """A program's file name without a version suffix: `python3.12` and + `python3` are `python`, `perl5.36` is `perl`.""" + return re.sub(r"[-.\d]+$", "", name) or name + + +def _program_names(member: str, *, first: bool, root: Path) -> set[str]: + """The names `member` could run as: its own file name and, where it + resolves to a file, that file's (`/bin/sh` may be `dash`, and a link + named `broker` may be `python3`), each without a version suffix.""" + names = {Path(member).name} + names.update(path.name for path in _program_path(member, first=first, + root=root)) + return {_unversioned(name) for name in names if name} + + +def _gives_an_inline_script(name: str, rest: tuple[str, ...], *, + first: bool) -> bool: + """Whether a program named `name`, followed by `rest`, is given an inline + script. Only its options are read, up to a `--` member.""" + options = [] + for member in rest: + if member == "--": + break + options.append(member) + if name in _SCRIPT_OPERAND: + return first and bool(rest) and not any( + member in ("-f", "--file") or member.startswith("--file=") + or (_SHORT_CLUSTER.fullmatch(member) and "f" in member[1:]) + for member in rest) + if name == "deno": + return bool(rest) and rest[0] == "eval" + longs = _INLINE_LONG.get(name, ()) + letters = _INLINE_LETTERS.get(name, "") + for member in options: + spelled = member.lower() if name in ("pwsh", "powershell") else member + if spelled in longs or any( + long.startswith("--") and spelled.startswith(long + "=") + for long in longs): + return True + if letters and _SHORT_CLUSTER.fullmatch(member) and any( + letter in member[1:] for letter in letters): + return True + return False + + +def inline_script(members, *, root: Path | str | None = None) -> str | None: + """The member of a broker command `members` that is a shell or an + interpreter given an INLINE script, or None (T100 follow-on, A2 + extended; RULED by Brett Heap, openxFactory#656 comment 5983805990, + "Refuse inline scripts (Recommended)"). + + Every member is asked, not the program alone, so a wrapper (`env sh -c`, + `timeout 5 bash -c`, `busybox sh -c`) hides none. A member's name is its + own file name and, where it resolves to a file, that file's, each + without a version suffix (`_program_names`).""" + members = tuple(members) + where = Path(resolved_root(root)) if root is not None else Path( + os.path.abspath(os.sep)) + for index, member in enumerate(members): + if not member or (index and member.startswith("-")): + continue + for name in _program_names(member, first=index == 0, root=where): + if _gives_an_inline_script(name, members[index + 1:], + first=index == 0): + return member + return None + + +def broker_command_refused(members, *, + root: Path | str | None) -> str | None: + """Why the broker command `members` may never be trusted, or None: it + gives a shell or an interpreter an inline script + (`REASON_INLINE_SCRIPT`), or, at a known `root`, it names a file inside + the served repository (`REASON_IN_REPOSITORY`). Asked where trust is + recorded and wherever it is judged, and of the console intake's + broker.""" + if inline_script(members, root=root) is not None: + return REASON_INLINE_SCRIPT + if root is not None and in_repository_argv(members, + root=root) is not None: + return REASON_IN_REPOSITORY + return None + + +def broker_refusal(binding, *, root: Path | str | None) -> str | None: + """`broker_command_refused` for `binding`'s broker command, placeholders + filled, or None for a binding no broker answers.""" + if binding.credential_source() != binding_mod.CREDENTIAL_FROM_BROKER: + return None + return broker_command_refused(binding.substituted_argv(), root=root) + + 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, and one whose broker @@ -941,10 +1130,11 @@ def _judged(policy_of, binding, *, root: Path | str) -> TrustVerdict: return TrustVerdict.untrusted_for(binding, root=root, basis=BASIS_CATALOG, reason=unservable) - if in_repository_program(binding, root=root) is not None: + refused = broker_refusal(binding, root=root) + if refused is not None: return TrustVerdict.untrusted_for(binding, root=root, basis=BASIS_REPOSITORY, - reason=REASON_IN_REPOSITORY) + reason=refused) try: verdict = policy_of().verdict(binding, root=root) except Exception as error: # noqa: BLE001 - a policy that fails trusts nothing @@ -1011,11 +1201,12 @@ def recorded_for(binding, *, root: Path | str) -> TrustVerdict: f"model binding {shown(binding.id)} is not trusted on this " f"machine, and no trust was recorded for it: {unservable}. " f"{REMEDY_UNSERVABLE}") - if in_repository_program(binding, root=root) is not None: + refused = broker_refusal(binding, root=root) + if refused 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: " - f"{REASON_IN_REPOSITORY}. {REMEDY_IN_REPOSITORY}") + f"machine, and no trust was recorded for it: {refused}. " + f"{trust_remedy(binding.id, str(root), refused)}") registered = None try: # INSIDE the refusal net (T100 follow-on, A13): whatever the seam @@ -1048,6 +1239,22 @@ def recorded_for(binding, *, root: Path | str) -> TrustVerdict: "on this machine and nothing was written") +def restored_for(binding, *, replacing, root: Path | str) -> None: + """After a write that failed, trust `binding` (the form the document + still holds) again in place of `replacing` (the form trust was recorded + for, for that write), where `binding` was trusted before (T100 + follow-on, A11). openDox's own store does it only while it still holds + `replacing`'s digest, under its lock (`MachineTrust.restore`), so a + trust another process recorded meanwhile is never overwritten (Copilot + at openDox-code#86, r4179076901). A host's policy is asked to record it + again, as `recorded_for` asks. Refused BY NAME where it cannot be done.""" + registered = _registered_now() + if type(registered) is MachineTrust: + registered.restore(binding, root=root, replacing=replacing) + return + recorded_for(binding, root=root) + + def intake_admissible() -> bool: """Whether the policy registered NOW could admit a console intake at all (T100 follow-on, A5): one that answers `intake_verdict` with something @@ -1077,11 +1284,13 @@ def intake_verdict_for(binding, *, root: Path | str) -> TrustVerdict: 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. A broker inside the - repository is refused before any policy is asked (T100 follow-on, A2).""" - if in_repository_program(binding, root=root) is not None: + repository, or an inline script, is refused before any policy is asked + (T100 follow-on, A2 and its extension).""" + refused = broker_refusal(binding, root=root) + if refused is not None: return TrustVerdict.untrusted_for(binding, root=root, basis=BASIS_REPOSITORY, - reason=REASON_IN_REPOSITORY) + reason=refused) try: ask = getattr(policy(), "intake_verdict", None) if not callable(ask): @@ -1574,6 +1783,32 @@ def record(self, binding, *, root: Path | str) -> TrustVerdict: return TrustVerdict.trusted_for(binding, root=key_root, basis=BASIS_MACHINE_TRUST) + def restore(self, binding, *, root: Path | str, replacing) -> bool: + """Trust `binding` at `root` again IN PLACE OF `replacing`, and only + while the store still holds `replacing`'s digest for its id: one + read, comparison and write under the store's lock (Copilot at + openDox-code#86, r4179076901). A failed edit asks this, so a trust + another process recorded meanwhile stands. Returns whether the + store was changed.""" + key_root = resolved_root(root) + held_for = (key_root, replacing.id) + 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) + if entries.get(held_for) != binding_digest(replacing): + return False + del entries[held_for] + entries[(key_root, binding.id)] = binding_digest(binding) + self._write(state, entries) + except OSError as error: + raise _store_failed(state, error, writing=True) from None + return True + 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.""" diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index 736c4666..db2b9db5 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -996,7 +996,15 @@ def _handle_workbench_model_intake_surface(self, head_only: bool) -> None: # and nothing is registered. from opendox import doxbench_trust admissible = doxbench_trust.intake_admissible() - offered = broker and records and admissible + # NOR WHERE THE BROKER COMMAND IT WOULD HAND OFF TO IS ONE THE + # RULES REFUSE (T100 follow-on, A2 and its extension; Copilot at + # openDox-code#86, r4179077029): a program inside the served + # repository, or an inline script. Every hand-off to it is refused + # before any policy is asked, whatever the host's policy answers. + refused = broker and doxbench_trust.broker_command_refused( + (disclosure["broker"] or {}).get("argv") or (), + root=Path(self.checkout_root)) is not None + offered = broker and records and not refused and admissible from opendox import doxbench_binding envelope: dict = { "kind": "workbench-model-intake", @@ -1017,6 +1025,7 @@ def _handle_workbench_model_intake_surface(self, head_only: bool) -> None: envelope["reason"] = ( column_seams.GATE_RECORDS_REFUSAL if not records else doxbench_intake.NO_BROKER_NOTICE if not broker + else doxbench_trust.INTAKE_BROKER_REFUSED if refused else doxbench_trust.INTAKE_NOT_ADMISSIBLE) self._serve_bytes(json.dumps(envelope).encode("utf-8"), JSON_CTYPE, head_only) diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 75ce2f89..355194d1 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -1822,8 +1822,12 @@ def intake_verdict(self, binding, *, root): port = served.port() notice = capsys.readouterr().err assert isinstance(port, trust_mod.UntrustedBindingPort) - assert _command(served.repo) in notice + # an invalid answer, which the same policy gives when asked to record, + # so no command that trusts is printed (Copilot at openDox-code#86, + # r4179077004) + _no_trust_command_in(notice) assert trust_mod.REASON_NOT_COVERED in notice + assert trust_mod.REMEDY_NOT_BY_TRUST in notice served.nothing_was_touched() @@ -1833,7 +1837,9 @@ def test_a_verdict_for_another_binding_is_refused_naming_this_one( """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.""" + about, never the other one. The answer is the policy's own invalid one, + which it gives when asked to record too, so none of them prints a + command that trusts (Copilot at openDox-code#86, r4179077004).""" trust_mod = _trust_mod() class _AnswersAnother(_Declines): @@ -1857,8 +1863,10 @@ def verdict(self, binding, *, root): 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 BINDING_ID in text assert "other-binding" not in text + _no_trust_command_in(text) + assert trust_mod.REMEDY_NOT_BY_TRUST in text assert trust_mod.REASON_NOT_COVERED in notice served.nothing_was_touched() @@ -2835,6 +2843,39 @@ def test_A1_an_unsupported_platform_is_told_no_trust_command( served.nothing_was_touched() +def test_A1_a_policys_answer_for_another_binding_is_told_no_trust_command( + served, capsys): + """Copilot at openDox-code#86 (r4179077004). A host whose `verdict` and + `record` both answer with a verdict that does not cover the binding (a + subclass, as in A12) is refused `REASON_NOT_COVERED`; `trust` would ask + the same host and be refused, so no command is printed.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + + class Lax(trust_mod.TrustVerdict): + def admits(self, binding): + return True + + class Host: + def verdict(self, binding, *, root): + exact = trust_mod.TrustVerdict.trusted_for( + binding, root=root, basis=trust_mod.BASIS_HOST) + return Lax(**{field.name: getattr(exact, field.name) + for field in dataclasses.fields(exact)}) + + record = verdict + + trust_mod.unregister() + trust_mod.register(Host()) + assert not trust_mod.trust_can_repair(trust_mod.REASON_NOT_COVERED) + assert _cli("model-binding", "list", "--repo-root", str(served.repo)) == 0 + listed = capsys.readouterr().out + assert trust_mod.REASON_NOT_COVERED in listed + _no_trust_command_in(listed) + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + + def test_A1_a_hosts_own_refusal_is_told_no_trust_command(served, capsys): """A host's policy that declines (a governed host, for a pending declaration) declines `trust` too, so `list` prints the host's reason @@ -2884,7 +2925,7 @@ def test_A1_an_unusable_store_is_told_no_trust_command(served, capsys): @pytest.mark.parametrize("reason", ["never", "changed", "record-form", - "not-covered", "no-verdict"]) + "no-verdict"]) def test_A1_each_reason_trust_repairs_prints_the_command_that_repairs_it( served, capsys, reason): """The other side of A1: where `trust` CAN repair the refusal, the @@ -2896,7 +2937,6 @@ def test_A1_each_reason_trust_repairs_prints_the_command_that_repairs_it( words = {"never": trust_mod.REASON_NEVER_TRUSTED, "changed": trust_mod.REASON_CHANGED, "record-form": trust_mod.REASON_RECORD_FORM, - "not-covered": trust_mod.REASON_NOT_COVERED, "no-verdict": trust_mod.REASON_NO_VERDICT}[reason] assert trust_mod.trust_can_repair(words) remedy = trust_mod.trust_remedy(BINDING_ID, str(served.repo), words) @@ -2937,6 +2977,20 @@ def _in_repository_argv(served, where, monkeypatch): link = served.tmp / "outside-link.py" link.symlink_to(tool) return [sys.executable, str(link)] + if where == "dash-named-program-on-path": + program = served.repo / "bin" / "-broker" + program.parent.mkdir() + program.write_text(f"#!{sys.executable}\n" + + tool.read_text(encoding="utf-8"), + encoding="utf-8") + os.chmod(program, 0o755) + monkeypatch.setenv("PATH", f"{program.parent}{os.pathsep}" + f"{os.environ.get('PATH', '')}") + return ["-broker"] + if where == "positional-after-double-dash": + shutil.copy(tool, tool.parent / "-broker.py") + monkeypatch.chdir(tool.parent) + return [sys.executable, "--", "-broker.py"] if where == "first-word-on-path": program = served.repo / "bin" / "opref-broker" program.parent.mkdir() @@ -2953,7 +3007,8 @@ def _in_repository_argv(served, where, monkeypatch): IN_REPOSITORY = ("absolute", "relative-to-the-working-directory", "relative-from-deeper-in-it", "bare-word-in-the-working-directory", "module-after-dash-m", - "flag-value", "link-from-outside", "first-word-on-path") + "flag-value", "link-from-outside", "first-word-on-path", + "dash-named-program-on-path", "positional-after-double-dash") @pytest.mark.parametrize("where", IN_REPOSITORY) @@ -2990,11 +3045,13 @@ def test_A2_a_broker_outside_the_repository_is_trusted_as_before( served, capsys): """The rule names files, so what names none is not refused: an option's absolute value outside the repository, a URL, a bare word that names no - file, and options with no value. `trust` records it, and a turn uses - it.""" + file, and options with no value. A member holding a NUL, which no path + can, is judged without failing (Copilot at openDox-code#86, + r4179077018). `trust` records it, and the start declares it.""" trust_mod = _trust_mod() argv = [sys.executable, str(served.broker), "--config=/etc/opref.conf", - "--issuer=https://auth.example/v1", "-v", "--quiet", "plain-word"] + "--issuer=https://auth.example/v1", "-v", "--quiet", "plain-word", + "--profile=/etc/opref\x00.conf"] served.hand_write(served.record("broker", broker_argv=argv)) binding = served.declared() assert trust_mod.in_repository_program(binding, root=served.repo) is None @@ -3824,3 +3881,343 @@ def test_N2_an_unreadable_declarations_document_is_refused_by_name( assert said in str(refused.value) # the console's start reads it as declaring nothing pending assert not intake_mod.pending_binding_ids(served.repo) + + +# =========================================================================== +# 7. Copilot's first review of openDox-code#86 (review 5407887563), and A2 +# EXTENDED (RULED by Brett Heap, openxFactory#656 comment 5983805990, +# "Refuse inline scripts (Recommended)"). Each fails at `3e4958ab`. +# =========================================================================== + + +def test_A11_a_failed_edit_never_overwrites_a_trust_recorded_meanwhile( + served, capsys, monkeypatch): + """r4179076901. Edit A records its form, edit B records and writes + another, and A's write then fails: A's undoing must not trust its stale + form over B's. It is undone only while the store still holds A's + form, under the store's lock.""" + assert _cli(*served.add_argv("env")) == 0 + capsys.readouterr() + before = served.declared() + meanwhile = dataclasses.replace(before, label="Recorded meanwhile") + + def edit(store, binding): + served.trust.record(meanwhile, root=served.repo) # edit B, recorded + raise OSError(28, "No space left on device") + + monkeypatch.setattr(binding_mod.BindingStore, "edit", edit) + editing = served.add_argv("env") + editing[1] = "edit" + editing[editing.index("--label") + 1] = "Renamed" + assert _cli(*editing) == 1 + assert "could not be written" in capsys.readouterr().err + assert served.trust.verdict(meanwhile, root=served.repo).trusted + assert not served.trust.verdict(before, root=served.repo).trusted + + +def test_A11_a_write_that_fails_part_way_leaves_the_document_as_it_was( + served, capsys, monkeypatch): + """r4179076956. A full disk part way through the write: the document + is replaced atomically, so it reads as it did, no partial copy is left + beside it, and the refusal's "nothing in it changed" is true. Both + stores write so.""" + trust_mod = _trust_mod() + assert _cli(*served.add_argv("env")) == 0 + capsys.readouterr() + _propose(served.repo, "first-model") + document = binding_mod.bindings_path(served.repo) + declarations = intake_mod.declarations_path(served.repo) + held, kept = document.read_bytes(), declarations.read_bytes() + before = served.declared() + + def half_then_full(handle, text): + handle.write(text[:len(text) // 2]) + raise OSError(28, "No space left on device") + + monkeypatch.setattr(binding_mod, "_write_all", half_then_full) + editing = served.add_argv("env") + editing[1] = "edit" + editing[editing.index("--label") + 1] = "Renamed" + assert _cli(*editing) == 1 + err = capsys.readouterr().err + assert "could not be written" in err and "nothing in it changed" in err + assert document.read_bytes() == held + assert trust_mod.verdict_for(before, root=served.repo).trusted + with pytest.raises(OSError): + _propose(served.repo, "second-model") + assert declarations.read_bytes() == kept + assert sorted(path.name for path in document.parent.iterdir()) == sorted( + [declarations.name, document.name]) + + +def test_N1_a_relative_path_that_is_the_default_is_judged_whole( + served, monkeypatch): + """r4179076919. Run from the repository's root, the default path is + exactly as long as the default relative path, and a link at a directory + of it is refused all the same, by both stores.""" + outside = served.tmp / "outside" + outside.mkdir() + (served.repo / "ideation").mkdir() + (served.repo / "ideation" / "dashboard").symlink_to( + outside, target_is_directory=True) + monkeypatch.chdir(served.repo) + with pytest.raises(binding_mod.BindingRefused) as refused: + binding_mod.BindingStore(binding_mod.bindings_path(".")).add( + _a_binding()) + assert "symbolic link" in str(refused.value) + with pytest.raises(intake_mod.IntakeRefused): + _propose(Path("."), BINDING_ID) + assert list(outside.iterdir()) == [] + + +@pytest.mark.parametrize("link", ["document", "its-directory"]) +def test_N1_bindings_named_on_the_command_line_are_never_written_through_a_link( + served, capsys, link): + """r4179076934. `--bindings` was resolved before the store saw it, which + erased the link. It is made absolute instead, so the store refuses it.""" + outside = served.tmp / "outside" + outside.mkdir() + document = binding_mod.bindings_path(served.tmp / "named") + document.parent.parent.mkdir(parents=True) + if link == "document": + document.parent.mkdir() + document.symlink_to(outside / "created-by-add") + else: + document.parent.symlink_to(outside, target_is_directory=True) + adding = served.add_argv("env") + adding[adding.index("--repo-root") + 2:adding.index("--repo-root") + 2] = [ + "--bindings", str(document)] + assert _cli(*adding) == 1 + assert "symbolic link" in capsys.readouterr().err + assert list(outside.iterdir()) == [] + + +def test_N2_every_refusal_of_a_document_prints_its_path_escaped( + served, capsys): + """r4179076973, r4179076986. A checkout whose path holds a terminal + control and a newline: a linked document, and an unreadable one, are + each refused with the path escaped, by the start and by `list`.""" + root = served.fresh_repository("r\x1b[8m\n forged line") + outside = served.tmp / "outside" + outside.mkdir() + document = binding_mod.bindings_path(root) + document.parent.mkdir(parents=True) + for plant in ("link", "not-utf-8"): + if plant == "link": + document.symlink_to(outside / "x.yaml") + else: + document.unlink() + document.write_bytes(b"\xff\xfe") + served.port(root) + assert _cli("model-binding", "list", "--repo-root", str(root)) == 1 + captured = capsys.readouterr() + for text in (captured.out, captured.err): + assert "\x1b" not in text and "\n forged line" not in text, ( + repr(text[:300])) + assert "\\u001b[8m" in captured.err + + +def test_A5_an_intake_broker_the_rules_refuse_is_not_offered(served): + """r4179077029. A host that admits the intake, and a declarations + document naming a broker inside the served repository, or an inline + script: every hand-off is refused before the host is asked, so the + surface is not offered, and says why.""" + from opendox import column_seams + + trust_mod = _trust_mod() + tool = served.repo / "tools" / "broker.py" + tool.parent.mkdir() + shutil.copy(served.broker, tool) + store = intake_mod.DeclarationStore(intake_mod.declarations_path( + served.repo)) + snapshot = served.tmp / "out" / "snapshot.json" + snapshot.parent.mkdir() + snapshot.write_text(json.dumps({"schema_version": 1}), encoding="utf-8") + trust_mod.unregister() + trust_mod.register(_AdmitsTheIntake()) + column_seams.gate.unregister() + column_seams.gate.register(_HostGate()) + try: + for argv in ((sys.executable, str(tool)), + ("/bin/sh", "-c", "exec ./tools/broker.py")): + store.declare_broker(intake_mod.BrokerDeclaration(argv=argv)) + surface = _intake_surface(served) + assert surface.get("offered") is False, surface + assert surface.get("reason") == trust_mod.INTAKE_BROKER_REFUSED + store.declare_broker(intake_mod.BrokerDeclaration( + argv=(sys.executable, str(served.broker)))) + assert _intake_surface(served).get("offered") is True + finally: + column_seams.gate.unregister() + + +INLINE = { + "sh-c": ["/bin/sh", "-c", "exec ./tools/broker.py"], + "bash-lc": ["bash", "-lc", "exec ./tools/broker.py"], + "sh-ec": ["sh", "-ec", "exec ./tools/broker.py"], + "zsh-c": ["zsh", "-c", "x"], + "dash-c": ["dash", "-c", "x"], + "python-c": [sys.executable, "-c", "import runpy"], + "python3-Sc": ["python3", "-Sc", "x"], + "node-e": ["node", "-e", "x"], + "node-eval": ["node", "--eval=x"], + "node-p": ["node", "-p", "x"], + "perl-e": ["perl", "-e", "x"], + "perl-ne": ["perl", "-ne", "x"], + "ruby-e": ["ruby", "-e", "x"], + "php-r": ["php", "-r", "x"], + "pwsh-command": ["pwsh", "-Command", "x"], + "env-wrapped": ["/usr/bin/env", "sh", "-c", "x"], + "env-split-string": ["env", "-S", "sh -c x"], + "timeout-wrapped": ["timeout", "5", "bash", "-c", "x"], + "busybox-wrapped": ["busybox", "sh", "-c", "x"], + "awk-program": ["awk", "NR == 1"], + "sed-program": ["sed", "-n", "1p"], + "deno-eval": ["deno", "eval", "x"], +} + + +@pytest.mark.parametrize("case", sorted(INLINE)) +def test_A2_a_shell_or_interpreter_given_an_inline_script_is_refused( + served, capsys, case): + """RULED by Brett Heap, openxFactory#656 comment 5983805990, "Refuse + inline scripts (Recommended)". The script is text, not a file a review + can pin, and it can run whatever the repository holds, so it is refused + BY NAME where trust is recorded (`trust`, `add`) and where it is + checked (the verdict under any policy, and the gate beneath), with the + remedy: the program itself, or a script kept outside the repository.""" + trust_mod = _trust_mod() + argv = INLINE[case] + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + assert trust_mod.broker_refusal(binding, root=served.repo) == ( + trust_mod.REASON_INLINE_SCRIPT) + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + err = capsys.readouterr().err + assert trust_mod.REASON_INLINE_SCRIPT in err + assert trust_mod.REMEDY_INLINE_SCRIPT in err + _no_trust_command_in(err) + assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() + for policy in (served.trust, _TrustsEveryBinding()): + trust_mod.unregister() + trust_mod.register(policy) + verdict = trust_mod.verdict_for(binding, root=served.repo) + assert not verdict.trusted + assert verdict.reason == trust_mod.REASON_INLINE_SCRIPT + admitted = trust_mod.TrustVerdict.trusted_for( + binding, root=served.repo, basis=trust_mod.BASIS_HOST) + with pytest.raises(trust_mod.BindingUntrusted) as beneath: + trust_mod.require_admitted(binding, admitted) + assert trust_mod.REASON_INLINE_SCRIPT in str(beneath.value) + document = binding_mod.bindings_path(served.repo) + document.unlink() + adding = served.add_argv("broker") + adding = adding[:adding.index("--") + 1] + argv + assert _cli(*adding) == 1 + assert trust_mod.REMEDY_INLINE_SCRIPT in capsys.readouterr().err + assert not document.exists() + served.nothing_was_touched() + + +def test_A2_an_inline_script_trusted_before_the_rule_never_runs( + served, capsys, monkeypatch): + """The ruling's own case, `["/bin/sh", "-c", "exec ./tools/broker.py"]`, + from a console started in the repository, with its trust recorded + straight into the store, as a store written before the rule: the start + refuses it, the gate refuses even a verdict that admits it, and the + repository's script never runs.""" + trust_mod = _trust_mod() + tool = served.repo / "tools" / "broker.py" + tool.parent.mkdir() + canary = served.tmp / "CANARY" + tool.write_text(f"#!{sys.executable}\nopen({str(canary)!r}, 'w')" + ".close()\n", encoding="utf-8") + os.chmod(tool, 0o755) + monkeypatch.chdir(served.repo) + argv = ["/bin/sh", "-c", "exec ./tools/broker.py"] + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + served.trust.record(binding, root=served.repo) + port = served.port() + assert isinstance(port, trust_mod.UntrustedBindingPort) + with pytest.raises(trust_mod.BindingUntrusted) as refused: + port.dispatch(_Envelope()) + assert trust_mod.REMEDY_INLINE_SCRIPT in str(refused.value) + _no_trust_command_in(str(refused.value), capsys.readouterr().err) + admitted = trust_mod.TrustVerdict.trusted_for( + binding, root=served.repo, basis=trust_mod.BASIS_HOST) + with pytest.raises(binding_mod.BindingRefused): + provider_mod.mint(binding, trust=admitted) + assert not canary.exists() + served.nothing_was_touched() + + +INLINE_CONTROLS = { + "a-shell-script-file": lambda served: ["/bin/sh", str(served.broker)], + "the-program-itself": lambda served: ["pass", "show", "key"], + "an-interpreter-and-a-file": lambda served: [ + sys.executable, "-B", str(served.broker)], + "env-and-a-file": lambda served: ["env", "OPREF_PROFILE=work", + sys.executable, str(served.broker)], + "a-file-named-after-double-dash": lambda served: [ + "/bin/sh", "--", str(served.broker)], + "awk-given-a-file": lambda served: ["awk", "-f", str(served.broker)], + "an-option-after-the-scripts-double-dash": lambda served: [ + "/bin/sh", str(served.broker), "--", "-c"], +} + + +@pytest.mark.parametrize("case", sorted(INLINE_CONTROLS)) +def test_A2_a_program_given_a_file_is_not_an_inline_script(served, case): + """The other side of the ruling: a shell or an interpreter given a FILE + outside the repository, the program itself, and a wrapper of one, are + not refused.""" + trust_mod = _trust_mod() + argv = INLINE_CONTROLS[case](served) + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + assert trust_mod.broker_refusal(binding, root=served.repo) is None + trust_mod.recorded_for(binding, root=served.repo) + assert trust_mod.verdict_for(binding, root=served.repo).trusted + + +def test_A2_an_interpreter_behind_a_name_of_its_own_is_refused(served): + """A program is judged by the file it resolves to as well as by the name + it is called by, so a link named like a broker that reaches an + interpreter is an interpreter given an inline script.""" + trust_mod = _trust_mod() + disguise = served.tmp / "bin" / "opref-broker" + disguise.parent.mkdir() + disguise.symlink_to(Path(sys.executable).resolve()) + served.hand_write(served.record( + "broker", broker_argv=[str(disguise), "-c", "import runpy"])) + verdict = trust_mod.verdict_for(served.declared(), root=served.repo) + assert verdict.reason == trust_mod.REASON_INLINE_SCRIPT + + +def test_A2_every_broker_starts_outside_the_served_repository( + served, capsys, monkeypatch): + """RULED, openxFactory#656 comment 5983805990, item 2: defence in depth. + A console started inside the served repository starts its broker in a + working directory outside it (`BROKER_WORKING_DIRECTORY`), so nothing + the broker finds relative to it is a file a pull changes.""" + where = served.tmp / "broker-cwd" + recording = served.tmp / "recording-broker.py" + recording.write_text( + f"import os\nopen({str(where)!r}, 'w').write(os.getcwd())\n" + + served.broker.read_text(encoding="utf-8"), encoding="utf-8") + monkeypatch.chdir(served.repo) + adding = served.add_argv("broker") + adding[-1] = str(recording) + assert _cli(*adding) == 0 + capsys.readouterr() + port = served.port() + assert isinstance(port, provider_mod.BrokeredProviderPort) + with contextlib.suppress(Exception): + port.dispatch(_Envelope()) + ran_in = Path(where.read_text(encoding="utf-8")) + assert ran_in == Path(provider_mod.BROKER_WORKING_DIRECTORY) + assert served.repo.resolve() not in (ran_in.resolve(), + *ran_in.resolve().parents) + From 96ae8816b9c1025019101423560b22bfea778019 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:09:35 +0000 Subject: [PATCH 03/16] T100 follow-on: launchers are unwrapped to the program they start (plan 034) This implements the holder's ruling on openxFactory#656 comment 5984069416, which implements Brett Heap's 5983805990 ("Refuse inline scripts"). 1. The common launchers are unwrapped to the program they start, each read by its own grammar (doxbench_trust._LAUNCHERS, _unwrapped): env (with -S, NAME=value and -C), nice, nohup, timeout, stdbuf, setsid, chrt, ionice and taskset, and wrappers of the same class: time, xargs, busybox, flock, sudo and doas. The in-repository rule judges the launcher's own program, the values that could name a file (env -C, and each directory of an assigned search path), and the command it starts, whose program is found as PATH finds it. The inline-script rule asks the unwrapped command as well as every member as written, so ["/usr/bin/env","python3","-c",...], env -S "python3 -c ...", busybox sh -c and xargs sh -c are refused. An env -S string that does not split is refused too. flock -c, su -c and runuser -c are inline scripts. 2. A broker's environment is doxbench_provider.broker_environment: the harness allowlist without PWD and OLDPWD, whatever that allowlist comes to hold. 3. The accepted limit (item 4) is stated where the rule is documented: a general program that runs code from its own arguments (awk, sed, find -exec) is not judged. The awk and sed rule the previous commit carried is removed accordingly. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 19 ++- src/opendox/doxbench_trust.py | 223 ++++++++++++++++++++++++++---- tests/test_model_binding_trust.py | 160 ++++++++++++++++++++- 3 files changed, 372 insertions(+), 30 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 715c6219..50420518 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -318,6 +318,23 @@ #: `doxbench_trust.broker_refusal`, which refuses such a command outright. BROKER_WORKING_DIRECTORY = os.path.abspath(os.sep) +#: The variables that name a working directory, which a broker's +#: environment never carries (the holder's ruling, openxFactory#656 comment +#: 5984069416, item 2): a shell, or a program that trusts `$PWD` over its +#: real working directory, would otherwise read the directory the console +#: was started from, which may be the served repository. +WORKING_DIRECTORY_VARIABLES: frozenset[str] = frozenset({"PWD", "OLDPWD"}) + + +def broker_environment(base) -> dict: + """A broker's whole environment: the harness child's allowlist + (`doxbench_bridge.child_environment`), without the variables that name a + working directory (`WORKING_DIRECTORY_VARIABLES`), whatever that + allowlist comes to hold.""" + return {name: value + for name, value in bridge_mod.child_environment(base).items() + if name not in WORKING_DIRECTORY_VARIABLES} + FIXED_DIAGNOSTICS: frozenset[str] = frozenset({ DIAG_BROKER_UNREACHABLE, DIAG_BROKER_REFUSED, DIAG_BROKER_MALFORMED, DIAG_BROKER_TIMEOUT, DIAG_PROVIDER_UNREACHABLE, @@ -614,7 +631,7 @@ def _run_broker(argv, *, source, timeout: float, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - env=bridge_mod.child_environment(os.environ), + env=broker_environment(os.environ), # OUTSIDE EVERY SERVED REPOSITORY (BROKER_WORKING_DIRECTORY). cwd=BROKER_WORKING_DIRECTORY, text=True, diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 67196f35..d0a30415 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -118,6 +118,7 @@ import threading from collections.abc import Mapping from pathlib import Path +from typing import NamedTuple from typing import Any from opendox import doxbench_binding as binding_mod @@ -299,7 +300,12 @@ #: (Recommended)"). The script is text, not a file a review can pin, and it #: can run whatever the repository holds (`/bin/sh -c "exec #: ./tools/broker.py"`), so the program must be a real file outside the -#: served repository. +#: served repository. A launcher (`env`, `nice`, `timeout`, ...) is unwrapped +#: to the program it starts, and that program is judged (the holder's +#: ruling, openxFactory#656 comment 5984069416). THE ACCEPTED LIMIT (same +#: ruling, item 4): a general program that runs code from its own arguments +#: (`awk`, `sed`, `find -exec`, and the like) is not judged as an inline +#: script. REASON_INLINE_SCRIPT = ( "its broker command gives a shell or an interpreter an inline script, " "which is no file a review can pin and can run whatever the repository " @@ -969,9 +975,25 @@ def in_repository_program(binding, *, root: Path | str) -> str | None: def in_repository_argv(members, *, root: Path | str) -> str | None: """The member of a broker command `members` that names a file inside the served repository, or None: `in_repository_program`'s rule, for a - command no binding carries yet (the console intake's broker).""" + command no binding carries yet (the console intake's broker). + + Its launchers are unwrapped first (`_unwrapped`; the holder's ruling, + openxFactory#656 comment 5984069416): each launcher's own program, and + each value of its that could name a file (`env -C DIR`, `env + NAME=VALUE`), are judged, and the command it starts is judged as a + command of its own, so its program is found as `PATH` finds it.""" served = Path(resolved_root(root)) - members = tuple(members) + unwrapped = _unwrapped(members, root=served) + for launcher in unwrapped.launchers: + if any(path == served or served in path.parents + for path in _program_path(launcher, first=True, root=served)): + return launcher + for value in unwrapped.values: + if value and any(path == served or served in path.parents + for path in _program_path(value, first=False, + root=served)): + return value + members = unwrapped.command positional = False for index, member in enumerate(members): if index and member == "--" and not positional: @@ -998,13 +1020,14 @@ def in_repository_argv(members, *, root: Path | str) -> str | None: #: Each interpreter, by its file name without a version suffix, and the #: letters of a short option cluster that give it an inline script -#: (`python -c`, `perl -e`, `node -e`/`-p`, `php -r`, `env -S`). +#: (`python -c`, `perl -e`, `node -e`/`-p`, `php -r`, `flock FILE -c`, +#: `su -c`). _INLINE_LETTERS: dict[str, str] = { **{shell: "c" for shell in _SHELLS}, "python": "c", "pypy": "c", "jython": "c", "perl": "eE", "ruby": "e", "php": "r", "lua": "e", "luajit": "e", "node": "ep", "nodejs": "ep", "bun": "ep", "osascript": "e", - "env": "S", + "flock": "c", "su": "c", "runuser": "c", } #: The long options that give an interpreter an inline script, as a member @@ -1012,19 +1035,157 @@ def in_repository_argv(members, *, root: Path | str) -> str | None: _INLINE_LONG: dict[str, tuple[str, ...]] = { "node": ("--eval", "--print"), "nodejs": ("--eval", "--print"), "bun": ("--eval", "--print"), "fish": ("--command",), - "env": ("--split-string",), + "flock": ("--command",), "su": ("--command",), "runuser": ("--command",), "pwsh": ("-c", "-command", "--command", "-e", "-ec", "-encodedcommand", "--encodedcommand", "-cwa", "-commandwithargs"), } _INLINE_LONG["powershell"] = _INLINE_LONG["pwsh"] -#: Programs whose own first operand IS a script, unless a file is named for -#: it (`-f FILE`), judged where they are the command's program. -_SCRIPT_OPERAND = frozenset({"awk", "gawk", "mawk", "nawk", "sed"}) - _SHORT_CLUSTER = re.compile(r"-[A-Za-z]+") +class _Launcher(NamedTuple): + """How one launcher reads its own arguments before the command it + starts: the short options and the long options that take a value, and + how many operands precede the command (`timeout`'s duration).""" + + short: str = "" + long: frozenset[str] = frozenset() + operands: int = 0 + + +#: THE COMMON LAUNCHERS (the holder's ruling, openxFactory#656 comment +#: 5984069416, items 1 and 3), each unwrapped to the program it starts so +#: the rules judge that program: `env` (with `-S` and `NAME=value`), +#: `nice`, `nohup`, `timeout`, `stdbuf`, `setsid`, `chrt`, `ionice`, +#: `taskset`, and wrappers of the same class: `time`, `xargs`, `busybox` +#: (whose first operand is the applet it runs), `flock`, `sudo`, `doas`. +_LAUNCHERS: dict[str, _Launcher] = { + "env": _Launcher("uCS", frozenset({"--unset", "--chdir", + "--split-string"})), + "nice": _Launcher("n", frozenset({"--adjustment"})), + "nohup": _Launcher(), + "timeout": _Launcher("sk", frozenset({"--signal", "--kill-after"}), 1), + "stdbuf": _Launcher("ioe", frozenset({"--input", "--output", "--error"})), + "setsid": _Launcher(), + "chrt": _Launcher("TPD", frozenset({"--sched-runtime", "--sched-period", + "--sched-deadline"}), 1), + "ionice": _Launcher("cnpPu", frozenset({"--class", "--classdata", + "--pid", "--pgid", "--uid"})), + "taskset": _Launcher("", frozenset(), 1), + "time": _Launcher("fo", frozenset({"--format", "--output"})), + "xargs": _Launcher("adEILnPs", frozenset({ + "--arg-file", "--delimiter", "--max-lines", "--max-args", + "--max-procs", "--max-chars", "--process-slot-var"})), + "busybox": _Launcher(), + "flock": _Launcher("wE", frozenset({"--timeout", "--wait", + "--conflict-exit-code"}), 1), + "sudo": _Launcher("CDghpRrtTUu", frozenset({ + "--close-from", "--chdir", "--group", "--host", "--prompt", + "--chroot", "--role", "--type", "--command-timeout", "--other-user", + "--user"})), + "doas": _Launcher("Cu"), +} + +#: `env NAME=value`: an assignment, whose value is judged as a path. +_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=") + +class _Unwrapped(NamedTuple): + """A broker command with its launchers unwrapped: the launchers' + programs, the values of their options and assignments that could name a + file (`env -C DIR`, `env NAME=VALUE`), and the command they start. + `unreadable` is an `env -S` string that does not split as a shell + would, which is judged as an inline script.""" + + launchers: tuple[str, ...] + values: tuple[str, ...] + command: tuple[str, ...] + unreadable: str | None = None + + +def _launcher_name(member: str, *, first: bool, root: Path) -> str | None: + """The launcher `member` runs, by the name it is called by or the file + it resolves to, or None.""" + for name in _program_names(member, first=first, root=root): + if name in _LAUNCHERS: + return name + return None + + +def _unwrapped(members, *, root: Path) -> _Unwrapped: + """`members` with every leading launcher unwrapped (at most 32 deep), + each read by its own grammar (`_LAUNCHERS`).""" + command = tuple(members) + launchers: list[str] = [] + values: list[str] = [] + for _depth in range(32): + if not command: + break + name = _launcher_name(command[0], first=True, root=root) + if name is None: + break + grammar = _LAUNCHERS[name] + launchers.append(command[0]) + rest = command[1:] + split: list[str] | None = None + index = 0 + while index < len(rest): + member = rest[index] + if member == "--": + index += 1 + break + if not member.startswith("-") or member == "-": + break + value = None + if member.startswith("--"): + option, equals, given = member.partition("=") + if option in grammar.long: + if equals: + value = given + elif index + 1 < len(rest): + index += 1 + value = rest[index] + option_name = option + else: + option_name = None + else: + option_name = None + for at, letter in enumerate(member[1:], start=1): + if letter in grammar.short: + option_name = "-" + letter + if member[at + 1:]: + value = member[at + 1:] + elif index + 1 < len(rest): + index += 1 + value = rest[index] + break + index += 1 + if value is None or option_name is None: + continue + if name == "env" and option_name in ("-S", "--split-string"): + try: + split = shlex.split(value) + except ValueError: + return _Unwrapped(tuple(launchers), tuple(values), (), + unreadable=value) + elif name == "env" and option_name in ("-C", "--chdir"): + values.append(value) + if split is not None: + # `env -S STRING`: STRING's words are env's own arguments, read + # again by env's grammar, before what followed them. + launchers.pop() + command = (command[0],) + tuple(split) + rest[index:] + continue + if name == "env": + while index < len(rest) and _ASSIGNMENT.match(rest[index]): + # A search path's every directory is judged (`PATH=a:b`). + values.extend(rest[index].split("=", 1)[1].split(os.pathsep)) + index += 1 + index += grammar.operands + command = rest[index:] + return _Unwrapped(tuple(launchers), tuple(values), command) + + def _unversioned(name: str) -> str: """A program's file name without a version suffix: `python3.12` and `python3` are `python`, `perl5.36` is `perl`.""" @@ -1050,11 +1211,6 @@ def _gives_an_inline_script(name: str, rest: tuple[str, ...], *, if member == "--": break options.append(member) - if name in _SCRIPT_OPERAND: - return first and bool(rest) and not any( - member in ("-f", "--file") or member.startswith("--file=") - or (_SHORT_CLUSTER.fullmatch(member) and "f" in member[1:]) - for member in rest) if name == "deno": return bool(rest) and rest[0] == "eval" longs = _INLINE_LONG.get(name, ()) @@ -1077,20 +1233,33 @@ def inline_script(members, *, root: Path | str | None = None) -> str | None: extended; RULED by Brett Heap, openxFactory#656 comment 5983805990, "Refuse inline scripts (Recommended)"). - Every member is asked, not the program alone, so a wrapper (`env sh -c`, - `timeout 5 bash -c`, `busybox sh -c`) hides none. A member's name is its - own file name and, where it resolves to a file, that file's, each - without a version suffix (`_program_names`).""" - members = tuple(members) + The command is asked with its launchers unwrapped (`_unwrapped`; the + holder's ruling, openxFactory#656 comment 5984069416), so an `env -S` + string is split and read as the command it is, and an `env -S` string + that does not split is refused. Every member of the command as written + is asked too, not the program alone, so a wrapper of the same class that + is not in `_LAUNCHERS` (`busybox sh -c`, `xargs sh -c`, `sudo bash -c`) + hides none. A member's name is its own file name and, where it resolves + to a file, that file's, each without a version suffix + (`_program_names`). + + THE ACCEPTED LIMIT (the same ruling, item 4): a general program that + runs code from its own arguments, such as `awk 'PROGRAM'`, `sed` or + `find -exec`, is not judged as an inline script.""" where = Path(resolved_root(root)) if root is not None else Path( os.path.abspath(os.sep)) - for index, member in enumerate(members): - if not member or (index and member.startswith("-")): - continue - for name in _program_names(member, first=index == 0, root=where): - if _gives_an_inline_script(name, members[index + 1:], - first=index == 0): - return member + unwrapped = _unwrapped(members, root=where) + if unwrapped.unreadable is not None: + return unwrapped.unreadable + for command in (tuple(members), unwrapped.command): + for index, member in enumerate(command): + if not member or (index and member.startswith("-")): + continue + for name in _program_names(member, first=index == 0, + root=where): + if _gives_an_inline_script(name, command[index + 1:], + first=index == 0): + return member return None diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 355194d1..f5997611 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -4071,8 +4071,13 @@ def test_A5_an_intake_broker_the_rules_refuse_is_not_offered(served): "env-split-string": ["env", "-S", "sh -c x"], "timeout-wrapped": ["timeout", "5", "bash", "-c", "x"], "busybox-wrapped": ["busybox", "sh", "-c", "x"], - "awk-program": ["awk", "NR == 1"], - "sed-program": ["sed", "-n", "1p"], + "unknown-wrapper": ["/opt/opendox-test/wrap", "sh", "-c", "x"], + "xargs-wrapped": ["xargs", "-0", "sh", "-c", "x"], + "sudo-wrapped": ["sudo", "-u", "bob", "bash", "-c", "x"], + "env-python": ["/usr/bin/env", "python3", "-c", "x"], + "env-split-python": ["env", "-S", "python3 -c x"], + "env-split-unreadable": ["env", "-S", "a 'b"], + "flock-command": ["flock", "/tmp/opendox-lock", "-c", "x"], "deno-eval": ["deno", "eval", "x"], } @@ -4163,6 +4168,10 @@ def test_A2_an_inline_script_trusted_before_the_rule_never_runs( "a-file-named-after-double-dash": lambda served: [ "/bin/sh", "--", str(served.broker)], "awk-given-a-file": lambda served: ["awk", "-f", str(served.broker)], + "env-split-a-program": lambda served: [ + "env", "-S", f"{sys.executable} {served.broker}"], + "timeout-and-a-program": lambda served: [ + "timeout", "-s", "KILL", "30", sys.executable, str(served.broker)], "an-option-after-the-scripts-double-dash": lambda served: [ "/bin/sh", str(served.broker), "--", "-c"], } @@ -4221,3 +4230,150 @@ def test_A2_every_broker_starts_outside_the_served_repository( assert served.repo.resolve() not in (ran_in.resolve(), *ran_in.resolve().parents) + +# =========================================================================== +# 8. LAUNCHERS (the holder's ruling, openxFactory#656 comment 5984069416, +# implementing Brett Heap's 5983805990). Each fails at `46ac0a0f`. +# =========================================================================== + +#: Each launcher, starting `PROG`: a broker program on a `PATH` entry inside +#: the served repository, which the launched command names by a bare word. +LAUNCHED = { + "env": ["env", "PROG"], + "env-ignore-and-assign": ["env", "-i", "OPREF_PROFILE=work", "PROG"], + "env-split-string": ["env", "-S", "OPREF_PROFILE=work PROG --flag"], + "nice": ["nice", "-n", "5", "PROG"], + "nice-adjustment": ["nice", "--adjustment=5", "PROG"], + "nohup": ["nohup", "PROG"], + "timeout": ["timeout", "-s", "KILL", "5", "PROG"], + "timeout-long-option": ["timeout", "--signal", "KILL", "5", "PROG"], + "stdbuf": ["stdbuf", "-oL", "PROG"], + "setsid": ["setsid", "-w", "PROG"], + "chrt": ["chrt", "-o", "0", "PROG"], + "ionice": ["ionice", "-c", "3", "PROG"], + "taskset": ["taskset", "0x1", "PROG"], + "time": ["time", "-f", "%e", "PROG"], + "xargs": ["xargs", "-0", "PROG"], + "busybox": ["busybox", "env", "PROG"], + "flock": ["flock", "-w", "5", "/tmp/opendox-lock", "PROG"], + "sudo": ["sudo", "-u", "bob", "PROG"], + "doas": ["doas", "-u", "bob", "PROG"], + "nested": ["env", "nice", "-n", "1", "timeout", "5", "PROG"], +} + + +@pytest.mark.parametrize("case", sorted(LAUNCHED)) +def test_A2_a_launcher_is_unwrapped_to_the_program_it_starts( + served, capsys, monkeypatch, case): + """Item 1 and 3 of the ruling: a launcher is unwrapped, with its own + options and operands, to the program it starts, and that program is + judged as a command's program is, so it is found as `PATH` finds it, + here inside the repository.""" + trust_mod = _trust_mod() + program = served.repo / "bin" / "opref-in-repository" + program.parent.mkdir() + program.write_text(f"#!{sys.executable}\n", encoding="utf-8") + os.chmod(program, 0o755) + monkeypatch.setenv("PATH", f"{program.parent}{os.pathsep}" + f"{os.environ.get('PATH', '')}") + argv = [member.replace("PROG", program.name) for member in LAUNCHED[case]] + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + assert trust_mod.broker_refusal(binding, root=served.repo) == ( + trust_mod.REASON_IN_REPOSITORY), argv + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + assert trust_mod.REMEDY_IN_REPOSITORY in capsys.readouterr().err + served.nothing_was_touched() + + +def _launcher_values(served): + tool = served.repo / "tools" / "broker.py" + tool.parent.mkdir(exist_ok=True) + shutil.copy(served.broker, tool) + launcher = served.repo / "tools" / "env" + launcher.write_text("#!/bin/sh\nexec \"$@\"\n", encoding="utf-8") + os.chmod(launcher, 0o755) + return { + "env-assigns-a-module-path": [ + "env", "PYTHONPATH=tools", sys.executable, "-m", "broker"], + "env-assigns-a-search-path": [ + "env", f"PATH=/usr/bin{os.pathsep}{tool.parent}", "opref-broker"], + "env-changes-directory": [ + "env", "-C", str(served.repo), sys.executable, str(served.broker)], + "env-changes-directory-long": [ + "env", f"--chdir={served.repo}", sys.executable, + str(served.broker)], + "a-launcher-inside-the-repository": [ + str(launcher), sys.executable, str(served.broker)], + } + + +@pytest.mark.parametrize("case", ["env-assigns-a-module-path", + "env-assigns-a-search-path", + "env-changes-directory", + "env-changes-directory-long", + "a-launcher-inside-the-repository"]) +def test_A2_what_a_launcher_is_given_is_judged_too(served, case): + """A launcher's own program, and each of its values that could name a + file (`env NAME=VALUE`, every directory of a search path, `env -C DIR`), + are judged, so none of them may lie inside the repository.""" + trust_mod = _trust_mod() + argv = _launcher_values(served)[case] + served.hand_write(served.record("broker", broker_argv=argv)) + assert trust_mod.broker_refusal(served.declared(), root=served.repo) == ( + trust_mod.REASON_IN_REPOSITORY), argv + + +@pytest.mark.parametrize("argv", [["awk", "NR == 1"], + ["sed", "-n", "1p"], + ["find", "/nonexistent", "-exec", "true", + ";"]], + ids=["awk", "sed", "find-exec"]) +def test_A2_a_general_program_running_its_arguments_is_the_accepted_limit( + served, argv): + """Item 4 of the ruling: a general program that runs code from its own + arguments (`awk`, `sed`, `find -exec`) is the recorded ACCEPTED limit. + It is not judged as an inline script, so its binding is trusted as any + program outside the repository is. This case records the limit; it + asks for nothing more.""" + trust_mod = _trust_mod() + served.hand_write(served.record("broker", broker_argv=argv)) + assert trust_mod.broker_refusal(served.declared(), + root=served.repo) is None + + +def test_A2_a_broker_never_inherits_a_working_directory_variable( + served, capsys, monkeypatch): + """Item 2 of the ruling: `PWD` and `OLDPWD` never reach a broker, even + where the harness's allowlist, which a broker's environment starts from, + came to hold them, so nothing that trusts `$PWD` over its real working + directory reads the directory the console was started in.""" + from opendox import doxbench_bridge + + monkeypatch.setattr(doxbench_bridge, "INHERITED_ENVIRONMENT", + (*doxbench_bridge.INHERITED_ENVIRONMENT, "PWD", + "OLDPWD")) + # the serving process's environment, as the provider reads it + served.environ["PWD"] = str(served.repo) + served.environ["OLDPWD"] = str(served.repo) + seen = served.tmp / "broker-environment.json" + recording = served.tmp / "recording-broker.py" + recording.write_text( + "import json, os\n" + f"open({str(seen)!r}, 'w').write(json.dumps(sorted(os.environ)))\n" + + served.broker.read_text(encoding="utf-8"), encoding="utf-8") + adding = served.add_argv("broker") + adding[-1] = str(recording) + assert _cli(*adding) == 0 + capsys.readouterr() + port = served.port() + with contextlib.suppress(Exception): + port.dispatch(_Envelope()) + names = json.loads(seen.read_text(encoding="utf-8")) + assert "PWD" not in names and "OLDPWD" not in names, names + assert "PATH" in names + assert provider_mod.broker_environment( + {"PATH": "/usr/bin", "PWD": "/x", "OLDPWD": "/y"}) == { + "PATH": "/usr/bin"} + From 5ce1abf323e2b21507001446faba9f3589f8c788 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:04:07 +0000 Subject: [PATCH 04/16] T100 follow-on: Copilot's second review, and the review of #86, C1-C5 (plan 034) Copilot's second review of openDox-code#86 (review 5408265138, at 96ae8816), all 8 threads, and the adversarial review of #86 at 96ae8816, under the holder's ruling on openxFactory#656 comment 5985046107 ("FIX ALL FIVE"). Copilot, round 2: 1. A broker command is judged as it runs (r4179241532, r4179366288): from BROKER_WORKING_DIRECTORY (now doxbench_trust's, which the provider aliases), on the search path the broker inherits (_Context). A relative path is joined to that directory only, a relative PATH entry is read from it (_which replaces shutil.which), and the launchers change both as they do at run time: env -C/--chdir and sudo -D move the directory; env PATH=... sets the search path; env -i, env -, --ignore-environment and -u PATH leave the platform default. 2. Every name on the way to a path is judged, each in its resolved directory, and the end resolved (_traversed; r4179241566): a link inside the repository is refused wherever it points. 3. A module after -m is judged by its whole dotted name, every package on the way included (r4179241555). 4. Interpreters' options are read by each one's grammar (_Interpreter, _INTERPRETERS): a short cluster letter by letter, so an attached script (python -cprint(1)) is read (r4179241583), and only until the script or module operand, counting options that take a value, so python /opt/broker.py -c profile is not refused (r4179241614). 5. Launchers left past the depth are refused, under the new REASON_UNREADABLE_COMMAND (remedy REMEDY_INLINE_SCRIPT), as is an env -S string that does not split (r4179366319). 6. Both stores' preflight (the link and presence checks) refuses by name a directory this user cannot search (r4179241603), and presence reads only ENOENT and ENOTDIR as absence (document_present). The review of #86 (ruling 5985046107): C1. Each launcher is read by its own getopt grammar (_launcher, _launcher_options): long options by GNU getopt_long's unambiguous prefix, in both value forms, and short clusters getopt-style (-iS..., -vC/dir, -0u NAME). An ambiguous or unknown option, a flag given a value or a missing value is refused, fail-closed, under REASON_UNREADABLE_COMMAND, and so are env --argv0, sudo --chroot and sudo -i, after which what runs cannot be judged from its words. env -S is split only where its string holds no backslash, '$' or '#'. C2. A failed add or edit takes back the trust it recorded: the earlier form trusted again where it was, the new form's trust withdrawn where it was not (MachineTrust.restore with no earlier form, restored_for, cli_model_binding._undone). Where that fails, the refusal says so and how to recover; a host's policy cannot be asked to withdraw (REASON_NO_WITHDRAWAL). C3. read_settings_document refuses a constructor's ValueError ("is not readable YAML"), after UnicodeDecodeError, which keeps its words. C4. Every value and operand of a launcher is judged (xargs -a in every spelling, time -o, flock's file), and every member by every file it could name (_candidates): an option's value, attached or after '=', and every absolute path it holds (-I, PERL5OPT=-I). A file a launcher writes inside the repository is refused too, an accepted strictness. C5. A directory, FIFO or socket in the store's place is refused for what it is, with the move-aside recovery, not blamed on another user. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/cli_model_binding.py | 62 ++- src/opendox/doxbench_binding.py | 44 +- src/opendox/doxbench_intake.py | 11 +- src/opendox/doxbench_provider.py | 6 +- src/opendox/doxbench_trust.py | 895 +++++++++++++++++++++--------- tests/test_model_binding_trust.py | 671 +++++++++++++++++++++- 6 files changed, 1375 insertions(+), 314 deletions(-) diff --git a/src/opendox/cli_model_binding.py b/src/opendox/cli_model_binding.py index b667cd5a..8c90a46d 100644 --- a/src/opendox/cli_model_binding.py +++ b/src/opendox/cli_model_binding.py @@ -88,6 +88,34 @@ def _cannot_write(store: "binding_mod.BindingStore", error: OSError) -> str: "nothing in it changed") +#: How to recover where a failed write's trust could not be taken back. +RECOVER_FAILED_UNDOING = ( + "This machine now trusts a form of the binding that no document " + "declares: run the same command again once the document can be written, " + "which declares that form") + + +def _undone(refusal: "binding_mod.BindingRefused", *, earlier, written, + args: argparse.Namespace) -> "binding_mod.BindingRefused": + """`refusal`, for a write that failed after trust was recorded for + `written`, once that trust is taken back (the holder's ruling, + openxFactory#656 comment 5985046107, C2; T100 follow-on, A11): the + earlier form trusted again where it was trusted (`earlier`), and the + new form's trust withdrawn where it was not (`earlier` None), so a failed + write leaves no trust for a form no document declares. Where that + cannot be done, the refusal says so, and how to recover.""" + try: + trust_mod.restored_for(earlier, replacing=written, + root=_repo_root(args)) + except binding_mod.BindingRefused as undoing: + what = ("the trust its earlier form held could not be restored" + if earlier is not None else + "the trust just recorded for it could not be withdrawn") + return binding_mod.BindingRefused( + f"{refusal}; and {what}: {undoing}. {RECOVER_FAILED_UNDOING}") + return refusal + + 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)}") @@ -257,8 +285,9 @@ def cmd_model_binding_add(args: argparse.Namespace) -> int: 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.""" + that fails after it WITHDRAWS that trust (the holder's ruling, + openxFactory#656 comment 5985046107, C2), so no form no document + declares stays trusted (`_undone`).""" store = _binding_store(args) try: binding = _declared_binding(args) @@ -267,9 +296,11 @@ def cmd_model_binding_add(args: argparse.Namespace) -> int: verdict = _record_trust(binding, args) try: store.add(binding) - except OSError as error: - raise binding_mod.BindingRefused( - _cannot_write(store, error)) from None + except (binding_mod.BindingRefused, OSError) as error: + refusal = (binding_mod.BindingRefused(_cannot_write(store, error)) + if isinstance(error, OSError) else error) + raise _undone(refusal, earlier=None, written=binding, + args=args) from None except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 @@ -288,11 +319,13 @@ def cmd_model_binding_edit(args: argparse.Namespace) -> int: A WRITE THAT FAILS AFTER THE TRUST WAS RECORDED UNDOES IT (T100 follow-on, A11). The store holds one form per binding, so recording the new form untrusted the old one; where the old form was trusted, it is - trusted again, so a failed edit changes neither the document (its write - is atomic) nor what this machine trusts. The undoing never overwrites a - trust recorded meanwhile (`doxbench_trust.restored_for`, Copilot at + trusted again, and where it was not, the new form's trust is withdrawn + (the holder's ruling, openxFactory#656 comment 5985046107, C2), so a + failed edit changes neither the document (its write is atomic) nor what + this machine trusts (`_undone`). The undoing never overwrites a trust + recorded meanwhile (`doxbench_trust.restored_for`, Copilot at openDox-code#86, r4179076901). The refusal names the write's cause, - never a raw error.""" + never a raw error, and says so where the undoing failed too.""" store = _binding_store(args) try: binding = _declared_binding(args) @@ -307,15 +340,8 @@ def cmd_model_binding_edit(args: argparse.Namespace) -> int: except (binding_mod.BindingRefused, OSError) as error: refusal = (binding_mod.BindingRefused(_cannot_write(store, error)) if isinstance(error, OSError) else error) - if was_trusted: - try: - trust_mod.restored_for(existing, replacing=binding, - root=_repo_root(args)) - except binding_mod.BindingRefused as restoring: - raise binding_mod.BindingRefused( - f"{refusal}; and the trust its earlier form held " - f"could not be restored: {restoring}") from None - raise refusal from None + raise _undone(refusal, earlier=existing if was_trusted else None, + written=binding, args=args) from None except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index 6d135dd0..4b532af2 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -616,20 +616,46 @@ def write_settings_document(path: Path, text: str) -> None: raise +def cannot_read(path: Path, what: str, error: OSError) -> str: + """The refusal of a settings document the system will not read for + this user, by the system's own short word for why.""" + return (f"the {what} at {shown_path(path)} cannot be read " + f"({error.strerror or type(error).__name__})") + + +def document_present(path: Path) -> bool: + """Whether a settings document is there: a regular file at `path`. Only + "no such file", or a file where a directory belongs on the way, is its + absence; any other failure to look, such as a directory on the way this + user cannot search, raises, so the caller refuses it by name (Copilot at + openDox-code#86, r4179241603) on every Python, where `Path.is_file` + swallows some of them.""" + try: + return stat.S_ISREG(path.stat().st_mode) + except (FileNotFoundError, NotADirectoryError): + return False + + def read_settings_document(path: Path, *, what: str, yaml, refused): """The YAML document at `path`, parsed, or a refusal BY NAME (`refused`, the caller's own refusal class) for one that cannot be read (T100 follow-on, N2): one the system will not read for this user, not UTF-8, nested past what the parser can descend, or not YAML. A console's start - reads it, so none of these may surface as a raw error there.""" + reads it, so none of these may surface as a raw error there. A + document that parses but holds a value its constructor rejects (a + timestamp such as `2024-13-01`) is not readable YAML either (the + holder's ruling, openxFactory#656 comment 5985046107, C3).""" shown = shown_path(path) try: return yaml.safe_load(path.read_text(encoding="utf-8")) except OSError as error: - raise refused(f"the {what} at {shown} cannot be read " - f"({error.strerror or type(error).__name__})") from None + raise refused(cannot_read(path, what, error)) from None except UnicodeDecodeError: + # before ValueError, of which it is a kind, so it keeps its words raise refused(f"the {what} at {shown} is not UTF-8 text") from None + except ValueError: + raise refused(f"the {what} at {shown} is not readable " + "YAML") from None except RecursionError: raise refused(f"the {what} at {shown} nests too deeply to " "read") from None @@ -1146,8 +1172,16 @@ def _refuse_a_link(self) -> None: link=shown_path(link))) def _load(self) -> list[ModelProviderBinding]: - self._refuse_a_link() - if not self.path.is_file(): + try: + # The look before the read refuses BY NAME too (Copilot at + # openDox-code#86, r4179241603): a directory on the way that + # this user cannot search fails `lstat` and `stat` themselves. + self._refuse_a_link() + present = document_present(self.path) + except OSError as error: + raise BindingRefused(cannot_read( + self.path, "bindings document", error)) from None + if not present: # THE HOSTED PATH, and the reason the import below is lazy: an # install with no bindings document answers here and never needs a # YAML parser at all. diff --git a/src/opendox/doxbench_intake.py b/src/opendox/doxbench_intake.py index cd5b56bf..838d2fd0 100644 --- a/src/opendox/doxbench_intake.py +++ b/src/opendox/doxbench_intake.py @@ -679,8 +679,15 @@ def _refuse_a_link(self) -> None: link=binding_mod.shown_path(link))) def _load(self) -> tuple[BrokerDeclaration | None, list[ModelDeclaration]]: - self._refuse_a_link() - if not self.path.is_file(): + try: + # Refused BY NAME before the read too (Copilot at + # openDox-code#86, r4179241603). + self._refuse_a_link() + present = binding_mod.document_present(self.path) + except OSError as error: + raise IntakeRefused(binding_mod.cannot_read( + self.path, "declarations document", error)) from None + if not present: # THE HOSTED PATH, and the reason the import below is lazy: an # install with no declarations answers here and never needs a YAML # parser at all. diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 50420518..74bea8bc 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -315,8 +315,10 @@ #: interpreter it runs, finds relative to its working directory (a relative #: path, `python -m`'s first import) can be a file a pull changes, whatever #: directory the console was started from. Defence in depth beneath -#: `doxbench_trust.broker_refusal`, which refuses such a command outright. -BROKER_WORKING_DIRECTORY = os.path.abspath(os.sep) +#: `doxbench_trust.broker_refusal`, which refuses such a command outright, +#: and judges it from this very directory (one constant, the trust +#: module's, so the two cannot disagree). +BROKER_WORKING_DIRECTORY = trust_mod.BROKER_WORKING_DIRECTORY #: The variables that name a working directory, which a broker's #: environment never carries (the holder's ruling, openxFactory#656 comment diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index d0a30415..0db31a8a 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -112,7 +112,6 @@ import os import re import shlex -import shutil import stat import sys import threading @@ -137,6 +136,7 @@ "BASIS_HOST", "BASIS_MACHINE_TRUST", "BASIS_REPOSITORY", + "BROKER_WORKING_DIRECTORY", "BindingUntrusted", "MachineTrust", "TRUST_FILENAME", @@ -151,6 +151,7 @@ "INTAKE_BROKER_REFUSED", "INTAKE_NOT_ADMISSIBLE", "REASON_INLINE_SCRIPT", + "REASON_UNREADABLE_COMMAND", "REASON_IN_REPOSITORY", "REASON_RECORD_FORM", "REMEDY_INLINE_SCRIPT", @@ -158,6 +159,7 @@ "REMEDY_NOT_BY_TRUST", "UNTRUSTABLE_TURN_MESSAGE", "REASON_UNSERVABLE", + "REASON_NO_WITHDRAWAL", "REMEDY_UNSERVABLE", "binding_digest", "command_safe_id", @@ -311,6 +313,23 @@ "which is no file a review can pin and can run whatever the repository " "holds") +#: Why a binding whose broker command cannot be read to the program it runs +#: is never trusted (T100 follow-on, A2 and its rulings; Copilot at +#: openDox-code#86, r4179366319; the holder's ruling, openxFactory#656 +#: comment 5985046107, C1): an option its launcher does not have, or has by +#: more than one name; an option after which what runs cannot be judged +#: from its words (`env --argv0`, `sudo --chroot`, `sudo -i`); an `env -S` +#: string env would not split as a shell does (a backslash, a `$` or a +#: `#`); or launchers nested past what is unwrapped. What it runs cannot be +#: judged, so it is refused FAIL-CLOSED, as an inline script is, with the +#: inline-script remedy. +REASON_UNREADABLE_COMMAND = ( + "its broker command cannot be read to the program it runs (a launcher " + "option it does not have, or has by more than one name, an option after " + "which what runs cannot be judged, an env -S string env would not split " + "as a shell does, or launchers nested too deeply), so what it runs " + "cannot be judged") + #: What an operator is told to do about such a binding. REMEDY_INLINE_SCRIPT = ( "Trusting it cannot make it usable: name the broker program itself, for " @@ -727,7 +746,7 @@ def trust_remedy(binding_id: str, root: str | None, return REMEDY_UNSERVABLE if reason == REASON_IN_REPOSITORY: return REMEDY_IN_REPOSITORY - if reason == REASON_INLINE_SCRIPT: + if reason in (REASON_INLINE_SCRIPT, REASON_UNREADABLE_COMMAND): return REMEDY_INLINE_SCRIPT if reason is not None and not trust_can_repair(reason): return REMEDY_NOT_BY_TRUST @@ -882,67 +901,119 @@ def unservable_because(binding) -> str | None: return None -#: A URL's scheme and authority (`https://`): a value that names no file, -#: whatever separators it carries. -_URL = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://") +#: The working directory every broker starts in (T100 follow-on, A2 +#: extended; RULED by Brett Heap, openxFactory#656 comment 5983805990, +#: "Refuse inline scripts (Recommended)", item 2): the file system's root, +#: which lies outside every served repository. `doxbench_provider` starts +#: every broker here, and the rules below judge a broker command from here, +#: as it runs (Copilot at openDox-code#86, r4179241532, r4179366288). +BROKER_WORKING_DIRECTORY = os.path.abspath(os.sep) + + +class _Context(NamedTuple): + """Where a broker command runs: its working directory, and the search + path its program is found on (None: the platform's default, + `os.defpath`), as the child process sees them (Copilot at + openDox-code#86, r4179241532, r4179366288). A launcher can change both + for the command it starts (`env -C`, `env PATH=...`, `env -i`).""" + + cwd: str + path: str | None + + +def _broker_context() -> _Context: + """The context every broker command starts in: `BROKER_WORKING_DIRECTORY`, + and the search path the broker inherits from this process.""" + return _Context(BROKER_WORKING_DIRECTORY, os.environ.get("PATH")) + + +def _which(name: str, context: _Context) -> str | None: + """`name` as the child finds it on its search path: the first executable + file among the path's directories, a relative directory (or an empty + entry) taken from the context's working directory, as the child takes + it, never from this process's (r4179366288).""" + search = context.path if context.path is not None else os.defpath + for entry in search.split(os.pathsep): + candidate = os.path.join(context.cwd, entry, name) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None -def _program_path(candidate: str, *, first: bool, - root: Path) -> list[Path]: - """The files one argv member could name, resolved, links followed: a - path (with a separator) as written, made absolute against this process's - working directory, which the broker inherits, and against the served - root, since a broker may be run from either, whether or not a file is - there yet (a pull could add one); a bare word as the program `PATH` - finds, for the command's first member; and any other bare word that - names an existing file in either directory. A URL names no file.""" - found: list[str] = [] - if _URL.match(candidate): - return [] +def _resolved_path(path: str) -> str: + """`path` with every link followed and `..` taken as the system takes it, + or, where it cannot be resolved (an embedded NUL, which no program can + be run with anyway; r4179077018), normalized as written.""" + try: + return os.path.realpath(path) + except (OSError, ValueError): + return os.path.normpath(path) + + +def _traversed(path: str) -> list[Path]: + """Every name the system looks up on the way to the absolute `path`, + each in the directory it is looked up in, that directory resolved + (links followed, `..` taken as the system takes it), and last `path` + itself, resolved (Copilot at openDox-code#86, r4179241566). A link + inside the repository is a name the repository controls, which a pull + can point elsewhere, whether it points inside or out; a link outside it + that points in reaches the repository's file.""" + names: list[Path] = [] + real = Path(os.sep) + for part in Path(path).parts[1:]: + if part == os.pardir: + real = real.parent + continue + names.append(real / part) + real = Path(_resolved_path(str(real / part))) + names.append(real) + return names + + +def _located(candidate: str, *, first: bool, context: _Context) -> list[str]: + """The file one argv member could name, as an absolute path, or + nothing: a path (with a separator) joined to the context's working + directory, whether or not a file is there yet (a pull could add one); a + bare word as the program the context's search path finds (`_which`), + for the command's program; and any other bare word that names an + existing file in the working directory.""" if candidate in (".", "..") or os.sep in candidate or ( os.altsep and os.altsep in candidate): - if os.path.isabs(candidate): - found.append(candidate) - else: - found += [os.path.join(os.getcwd(), candidate), - os.path.join(str(root), candidate)] - elif first: - located = shutil.which(candidate) - if located is not None: - found.append(located) - else: - found += [here for here in (os.path.join(os.getcwd(), candidate), - os.path.join(str(root), candidate)) - if os.path.lexists(here)] - return _resolved(found) - - -def _module_paths(name: str, *, root: Path) -> list[Path]: - """The files a module named after `-m` could be imported from, resolved: - its top-level package or module (`a` or `a.py` for `a.b`) in this - process's working directory, which an interpreter run with `-m` imports - from first and which the broker inherits, and in the served root.""" - top = name.split(".", 1)[0] - if not top: - return [] - return _resolved([here for directory in (os.getcwd(), str(root)) - for here in (os.path.join(directory, top), - os.path.join(directory, top + ".py")) - if os.path.lexists(here)]) - - -def _resolved(found: list[str]) -> list[Path]: - """Each path resolved, links followed, or as written, made absolute, - where it cannot be resolved: a loop, or an embedded NUL, which no - program can be run with anyway (Copilot at openDox-code#86, - r4179077018).""" - paths: list[Path] = [] - for path in found: - try: - paths.append(Path(path).resolve()) - except (OSError, RuntimeError, ValueError): - paths.append(Path(os.path.abspath(path))) - return paths + return [os.path.join(context.cwd, candidate)] + if first: + located = _which(candidate, context) + return [located] if located is not None else [] + here = os.path.join(context.cwd, candidate) + return [here] if os.path.lexists(here) else [] + + +def _candidates(member: str, *, option: bool) -> list[str]: + """What one member could name a file by, fail-closed: the member itself + or, for an option, its value, after `=` and, for a single-dash option, + the rest after its letter (`-I`, `-a`; the holder's ruling, + openxFactory#656 comment 5985046107, C4); and, in either, every absolute + path it holds (`-vI/r/lib`, `PERL5OPT=-I/r/lib -Mx`).""" + found = [] if option else [member] + if option: + if "=" in member: + found.append(member.split("=", 1)[1]) + if not member.startswith("--"): + found.append(member[2:]) + found += [member[at:] for at in range(1, len(member)) + if member[at] in (os.sep, os.altsep)] + return [candidate for candidate in found if candidate] + + +def _module_paths(name: str, *, context: _Context) -> list[str]: + """The files a module named after `-m` could be imported from: its + whole dotted name as a path under the context's working directory, + which `python -m` imports from first, as a package (`a/b/c`) and as a + module (`a/b/c.py`), whether or not a file is there yet (Copilot at + openDox-code#86, r4179241555). A namespace package needs no + `__init__`, so a dotted name can reach any directory under the working + directory; every package on the way is a name `_traversed` judges.""" + base = os.path.join(context.cwd, *name.split(".")) + return [base, base + ".py"] def in_repository_program(binding, *, root: Path | str) -> str | None: @@ -962,11 +1033,11 @@ def in_repository_program(binding, *, root: Path | str) -> str | None: member that is an option (`-v`, `--config=VALUE`), only its value is. The program itself is never an option, whatever its name, and nothing after a `--` member is one (Copilot at openDox-code#86, r4179076944). A - member names a file inside - the repository where what it resolves to (`_program_path`) is the served - root or lies under it, and so does a module named after `-m` that would - be imported from there (`_module_paths`). A binding no broker answers - has no command.""" + member names a file inside the repository where, as the broker runs it + (`in_repository_argv`), any name on the way to what it names is the + served root or lies under it (`_traversed`), and so does a module named + after `-m` that could be imported from there (`_module_paths`). A + binding no broker answers has no command.""" if binding.credential_source() != binding_mod.CREDENTIAL_FROM_BROKER: return None return in_repository_argv(binding.substituted_argv(), root=root) @@ -977,39 +1048,54 @@ def in_repository_argv(members, *, root: Path | str) -> str | None: the served repository, or None: `in_repository_program`'s rule, for a command no binding carries yet (the console intake's broker). - Its launchers are unwrapped first (`_unwrapped`; the holder's ruling, - openxFactory#656 comment 5984069416): each launcher's own program, and - each value of its that could name a file (`env -C DIR`, `env - NAME=VALUE`), are judged, and the command it starts is judged as a - command of its own, so its program is found as `PATH` finds it.""" + It is judged as it runs (`_Context`; Copilot at openDox-code#86, + r4179241532, r4179366288): from `BROKER_WORKING_DIRECTORY`, on the + search path the broker inherits. A member names a file inside the + repository where any name on the way to it (`_traversed`) is the + served root or lies under it, for every file the member could name + (`_candidates`): an option's value, attached or after `=`, and every + absolute path it holds (the holder's ruling, openxFactory#656 comment + 5985046107, C4). Its launchers are unwrapped first (`_unwrapped`; the + holder's ruling, openxFactory#656 comment 5984069416): each launcher's + own program, and each value and operand of its (`env -C DIR`, `env + NAME=VALUE`, `xargs -a FILE`, `time -o FILE`), are judged in the + context they are read in, and the command it starts is judged as a + command of its own, in the context the launchers left it. A file a + launcher writes inside the repository is refused too, an accepted + strictness.""" served = Path(resolved_root(root)) - unwrapped = _unwrapped(members, root=served) - for launcher in unwrapped.launchers: - if any(path == served or served in path.parents - for path in _program_path(launcher, first=True, root=served)): + + def inside(found: list[str]) -> bool: + return any(name == served or served in name.parents + for path in found for name in _traversed(path)) + + def named(member: str, *, option: bool, first: bool, + context: _Context) -> list[str]: + return [path for candidate in _candidates(member, option=option) + for path in _located(candidate, + first=first and candidate == member, + context=context)] + + unwrapped = _unwrapped(members) + for launcher, context in unwrapped.launchers: + if inside(_located(launcher, first=True, context=context)): return launcher - for value in unwrapped.values: - if value and any(path == served or served in path.parents - for path in _program_path(value, first=False, - root=served)): + for value, context in unwrapped.values: + if inside(named(value, option=value.startswith("-"), first=False, + context=context)): return value members = unwrapped.command + context = unwrapped.context positional = False for index, member in enumerate(members): if index and member == "--" and not positional: positional = True continue - if index and member.startswith("-") and not positional: - candidates = ([member.split("=", 1)[1]] if "=" in member - else []) - else: - candidates = [member] - paths = [path for candidate in candidates if candidate - for path in _program_path(candidate, first=index == 0, - root=served)] + found = named(member, option=bool(index) and member.startswith("-") + and not positional, first=index == 0, context=context) if index and members[index - 1] == "-m" and not positional: - paths += _module_paths(member, root=served) - if any(path == served or served in path.parents for path in paths): + found += _module_paths(member, context=context) + if inside(found): return member return None @@ -1018,40 +1104,132 @@ def in_repository_argv(members, *, root: Path | str) -> str | None: _SHELLS = frozenset({"sh", "bash", "rbash", "zsh", "dash", "ksh", "mksh", "pdksh", "ash", "yash", "posh", "fish", "csh", "tcsh"}) -#: Each interpreter, by its file name without a version suffix, and the -#: letters of a short option cluster that give it an inline script -#: (`python -c`, `perl -e`, `node -e`/`-p`, `php -r`, `flock FILE -c`, -#: `su -c`). -_INLINE_LETTERS: dict[str, str] = { - **{shell: "c" for shell in _SHELLS}, - "python": "c", "pypy": "c", "jython": "c", - "perl": "eE", "ruby": "e", "php": "r", "lua": "e", "luajit": "e", - "node": "ep", "nodejs": "ep", "bun": "ep", "osascript": "e", - "flock": "c", "su": "c", "runuser": "c", -} -#: The long options that give an interpreter an inline script, as a member -#: or as `--option=VALUE`. PowerShell's are matched without regard to case. -_INLINE_LONG: dict[str, tuple[str, ...]] = { - "node": ("--eval", "--print"), "nodejs": ("--eval", "--print"), - "bun": ("--eval", "--print"), "fish": ("--command",), - "flock": ("--command",), "su": ("--command",), "runuser": ("--command",), - "pwsh": ("-c", "-command", "--command", "-e", "-ec", "-encodedcommand", - "--encodedcommand", "-cwa", "-commandwithargs"), +class _Interpreter(NamedTuple): + """How a shell or an interpreter reads its own options, as far as the + inline-script rule needs (Copilot at openDox-code#86, r4179241583, + r4179241614): the short letters that give it an inline script, those + that take a value (attached, or the next member), those whose value is + only ever attached (the rest of the cluster), and those after which only + the program's own operands follow (`python -m`); any other letter, or a + digit (`perl -l0e`), is read past; the long options that give an inline script, take + a value, or end its options (`pwsh -File`); how many operands it reads + before its script (`flock FILE`; None reads every member, as `su` and + `pwsh` do); whether `+o` is an option (a shell's); and whether option + names are compared without regard to case (PowerShell's).""" + + inline: str = "" + takes: str = "" + attached: str = "" + ends: str = "" + longs: frozenset[str] = frozenset() + long_values: frozenset[str] = frozenset() + long_ends: frozenset[str] = frozenset() + operands: int | None = 0 + plus: bool = False + folded: bool = False + + +_SHELL = _Interpreter(inline="c", takes="oO", + long_values=frozenset({"--rcfile", "--init-file"}), + plus=True) +_NODE = _Interpreter( + inline="ep", takes="rC", longs=frozenset({"--eval", "--print"}), + long_values=frozenset({ + "--require", "--import", "--loader", "--experimental-loader", + "--conditions", "--input-type", "--env-file", "--title", + "--inspect-port", "--redirect-warnings", "--report-dir", + "--report-filename", "--stack-trace-limit", "--disable-warning", + "--watch-path", "--test-reporter", "--test-reporter-destination", + "--openssl-config", "--icu-data-dir", "--dns-result-order", + "--unhandled-rejections", "--run"})) +_PWSH = _Interpreter( + longs=frozenset({"-c", "-command", "--command", "-e", "-ec", + "-encodedcommand", "--encodedcommand", "-cwa", + "-commandwithargs"}), + long_ends=frozenset({"-f", "-file", "--file"}), operands=None, + folded=True) +_SU = _Interpreter(inline="c", takes="gGswu", + longs=frozenset({"--command", "--session-command"}), + long_values=frozenset({"--shell", "--group", "--supp-group", + "--whitelist-environment", "--user"}), + operands=None) + +#: Each shell and interpreter, by its file name without a version suffix. +_INTERPRETERS: dict[str, _Interpreter] = { + **{shell: _SHELL for shell in _SHELLS}, + "fish": _Interpreter( + inline="cC", longs=frozenset({"--command", "--init-command"}), + long_values=frozenset({"--features", "--debug", "--debug-output", + "--profile", "--profile-startup"})), + "python": _Interpreter(inline="c", takes="WX", ends="m", + long_values=frozenset( + {"--check-hash-based-pycs"})), + "pypy": _Interpreter(inline="c", takes="WX", ends="m"), + "jython": _Interpreter(inline="c", takes="WX", ends="m"), + "perl": _Interpreter(inline="eE", takes="IMm", attached="ixdDC"), + "ruby": _Interpreter(inline="e", takes="IrCE", attached="FKTxW"), + "php": _Interpreter(inline="rRBE", takes="cdzt", ends="f"), + "lua": _Interpreter(inline="e", takes="l"), + "luajit": _Interpreter(inline="e", takes="lj", attached="O", ends="b"), + "node": _NODE, "nodejs": _NODE, "bun": _NODE, + "osascript": _Interpreter(inline="e", takes="ls"), + "flock": _Interpreter(inline="c", takes="wE", + longs=frozenset({"--command"}), + long_values=frozenset({"--timeout", "--wait", + "--conflict-exit-code"}), + operands=1), + "su": _SU, "runuser": _SU, + "pwsh": _PWSH, "powershell": _PWSH, } -_INLINE_LONG["powershell"] = _INLINE_LONG["pwsh"] -_SHORT_CLUSTER = re.compile(r"-[A-Za-z]+") + +class _Option(NamedTuple): + """One option of a launcher: the option it is another name for (its + short letter, or its own long name), and what it takes, in getopt's own + spelling: "" nothing, ":" a value (attached, or the next member), "::" + a value only where attached (after `=` for a long option).""" + + key: str + takes: str class _Launcher(NamedTuple): """How one launcher reads its own arguments before the command it - starts: the short options and the long options that take a value, and - how many operands precede the command (`timeout`'s duration).""" + starts, as its getopt does (the holder's ruling, openxFactory#656 + comment 5985046107, C1): its short options and its long options, how + many operands precede the command (`timeout`'s duration), and whether a + dash and a number is an option (`nice -5`).""" - short: str = "" - long: frozenset[str] = frozenset() + short: dict[str, str] + long: dict[str, _Option] operands: int = 0 + numeric: bool = False + + +def _launcher(short: str, longs: str = "", *, operands: int = 0, + numeric: bool = False) -> _Launcher: + """A launcher's grammar from getopt's own spellings: `short` as getopt's + option string (`C:` takes a value, `e::` one only attached), and + `longs` as its long options, each suffixed as `short` is and followed by + `/x` where it is short option x's other name. `--help` and `--version` + run no command, so every launcher reads them.""" + letters: dict[str, str] = {} + at = 0 + while at < len(short): + letter = short[at] + at += 1 + takes = "" + while at < len(short) and short[at] == ":": + takes += ":" + at += 1 + letters[letter] = takes + named: dict[str, _Option] = {} + for word in ("help version " + longs).split(): + name, _slash, key = word.partition("/") + bare = name.rstrip(":") + named[bare] = _Option(key or bare, name[len(bare):]) + return _Launcher(letters, named, operands, numeric) #: THE COMMON LAUNCHERS (the holder's ruling, openxFactory#656 comment @@ -1060,116 +1238,237 @@ class _Launcher(NamedTuple): #: `nice`, `nohup`, `timeout`, `stdbuf`, `setsid`, `chrt`, `ionice`, #: `taskset`, and wrappers of the same class: `time`, `xargs`, `busybox` #: (whose first operand is the applet it runs), `flock`, `sudo`, `doas`. +#: Each is read by its own getopt grammar, the GNU coreutils, findutils +#: and util-linux ones where there are several (C1). _LAUNCHERS: dict[str, _Launcher] = { - "env": _Launcher("uCS", frozenset({"--unset", "--chdir", - "--split-string"})), - "nice": _Launcher("n", frozenset({"--adjustment"})), - "nohup": _Launcher(), - "timeout": _Launcher("sk", frozenset({"--signal", "--kill-after"}), 1), - "stdbuf": _Launcher("ioe", frozenset({"--input", "--output", "--error"})), - "setsid": _Launcher(), - "chrt": _Launcher("TPD", frozenset({"--sched-runtime", "--sched-period", - "--sched-deadline"}), 1), - "ionice": _Launcher("cnpPu", frozenset({"--class", "--classdata", - "--pid", "--pgid", "--uid"})), - "taskset": _Launcher("", frozenset(), 1), - "time": _Launcher("fo", frozenset({"--format", "--output"})), - "xargs": _Launcher("adEILnPs", frozenset({ - "--arg-file", "--delimiter", "--max-lines", "--max-args", - "--max-procs", "--max-chars", "--process-slot-var"})), - "busybox": _Launcher(), - "flock": _Launcher("wE", frozenset({"--timeout", "--wait", - "--conflict-exit-code"}), 1), - "sudo": _Launcher("CDghpRrtTUu", frozenset({ - "--close-from", "--chdir", "--group", "--host", "--prompt", - "--chroot", "--role", "--type", "--command-timeout", "--other-user", - "--user"})), - "doas": _Launcher("Cu"), + "env": _launcher( + "a:C:iS:u:v0", + "argv0:/a chdir:/C debug/v ignore-environment/i null/0 " + "split-string:/S unset:/u block-signal:: default-signal:: " + "ignore-signal:: list-signal-handling"), + "nice": _launcher("n:", "adjustment:/n", numeric=True), + "nohup": _launcher(""), + "timeout": _launcher( + "fk:ps:v", "foreground/f kill-after:/k preserve-status/p signal:/s " + "verbose/v", operands=1), + "stdbuf": _launcher("i:o:e:", "input:/i output:/o error:/e"), + "setsid": _launcher("cfhVw", "ctty/c fork/f help/h version/V wait/w"), + "chrt": _launcher( + "abdD:efhimoP:pRrT:vV", + "all/a batch/b deadline/d ext/e fifo/f help/h idle/i max/m other/o " + "pid/p reset-on-fork/R rr/r sched-deadline:/D sched-period:/P " + "sched-runtime:/T verbose/v version/V", operands=1), + "ionice": _launcher( + "c:hn:p:P:tu:V", "class:/c classdata:/n help/h ignore/t pid:/p " + "pgid:/P uid:/u version/V"), + "taskset": _launcher("achpV", "all-tasks/a cpu-list/c help/h pid/p " + "version/V", operands=1), + "time": _launcher("af:o:pqvV", "append/a format:/f output:/o " + "portability/p quiet/q verbose/v version/V"), + "xargs": _launcher( + "0a:d:E:e::hI:i::L:l::n:oprs:txP:", + "null/0 arg-file:/a delimiter:/d eof::/e replace::/I max-lines::/l " + "max-args:/n open-tty/o interactive/p no-run-if-empty/r " + "max-chars:/s verbose/t show-limits exit/x max-procs:/P " + "process-slot-var: help/h"), + "busybox": _launcher(""), + "flock": _launcher( + "eE:Fhnosuw:xV", + "shared/s exclusive/x unlock/u nonblocking/n nb/n timeout:/w " + "wait:/w conflict-exit-code:/E close/o no-fork/F verbose help/h " + "version/V", operands=1), + "sudo": _launcher( + "Aa:BbC:c:D:Eeg:Hh::iKklNnPp:R:r:SsT:t:U:u:Vv", + "askpass/A auth-type:/a background/b bell/B close-from:/C " + "login-class:/c chdir:/D preserve-env:: edit/e group:/g set-home/H " + "help/h host: login/i remove-timestamp/K reset-timestamp/k list/l " + "non-interactive/n no-update/N preserve-groups/P prompt:/p " + "chroot:/R role:/r stdin/S shell/s type:/t command-timeout:/T " + "other-user:/U user:/u version/V validate/v"), + "doas": _launcher("a:C:Lnsu:"), } +#: The options after which what a command names can no longer be judged +#: from its words (C1, fail-closed): a program told another name to run as +#: (`env --argv0`), a new root every path is read in (`sudo --chroot`), and +#: a login shell's working directory, the target user's home (`sudo -i`). +#: Each makes the command unreadable. +_UNREADABLE_OPTIONS = frozenset({("env", "a"), ("sudo", "R"), ("sudo", "i")}) + +#: How deep launchers are unwrapped. A command that still starts with one +#: past this is refused (`REASON_UNREADABLE_COMMAND`; Copilot at +#: openDox-code#86, r4179366319). +_LAUNCHER_DEPTH = 32 + #: `env NAME=value`: an assignment, whose value is judged as a path. _ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=") +#: `nice -5`, `nice --5`, `nice -+5`: an adjustment, nice's own option. +_NUMERIC_OPTION = re.compile(r"-[-+]?[0-9]+") + +#: What `env -S` splits only where its string holds none of them (C1): a +#: backslash escape (`\_` is a space to env and not to a shell's split), a +#: `${VAR}` env expands, and a `#` that begins env's comment. +_UNSPLITTABLE = frozenset("\\$#") + + class _Unwrapped(NamedTuple): - """A broker command with its launchers unwrapped: the launchers' - programs, the values of their options and assignments that could name a - file (`env -C DIR`, `env NAME=VALUE`), and the command they start. - `unreadable` is an `env -S` string that does not split as a shell - would, which is judged as an inline script.""" - - launchers: tuple[str, ...] - values: tuple[str, ...] + """A broker command with its launchers unwrapped: each launcher's + program, and each value and operand of theirs (which could name a file: + `env -C DIR`, `env NAME=VALUE`, `xargs -a FILE`), with the context each + is read in; the command they start, and the context it runs in. + `unreadable` is a member that cannot be read to the program it runs: an + option its launcher does not have, or has by more than one name, an + option after which a name cannot be judged, an `env -S` string env would + not split as a shell does, or a launcher still left at + `_LAUNCHER_DEPTH`.""" + + launchers: tuple[tuple[str, _Context], ...] + values: tuple[tuple[str, _Context], ...] command: tuple[str, ...] + context: _Context unreadable: str | None = None -def _launcher_name(member: str, *, first: bool, root: Path) -> str | None: +class _Unreadable(Exception): + """A launcher's argument that cannot be read (`_Unwrapped.unreadable`).""" + + def __init__(self, member: str): + super().__init__(member) + self.member = member + + +def _long_option(grammar: _Launcher, spelled: str) -> _Option | None: + """The long option `--spelled` names, as GNU getopt_long reads it: its + exact name, or the one option it is an unambiguous beginning of (`--chd` + is `--chdir`). None where it names none, or more than one (`--d` may be + `--debug` or `--default-signal`).""" + exact = grammar.long.get(spelled) + if exact is not None: + return exact + matches = {option for name, option in grammar.long.items() + if name.startswith(spelled)} + return matches.pop() if len(matches) == 1 else None + + +def _launcher_name(member: str, *, context: _Context) -> str | None: """The launcher `member` runs, by the name it is called by or the file it resolves to, or None.""" - for name in _program_names(member, first=first, root=root): + for name in _program_names(member, first=True, context=context): if name in _LAUNCHERS: return name return None -def _unwrapped(members, *, root: Path) -> _Unwrapped: - """`members` with every leading launcher unwrapped (at most 32 deep), - each read by its own grammar (`_LAUNCHERS`).""" +def _launcher_options(name: str, rest: tuple[str, ...], context: _Context, + values: list[tuple[str, _Context]]): + """Read launcher `name`'s options from `rest`, as its getopt does: + returns how many members they took, the context they leave, and, for + `env -S`, the words its string splits into (else None). Each value is + added to `values` in the context it is read in. Raises `_Unreadable`.""" + grammar = _LAUNCHERS[name] + + def given(key: str, value: str | None) -> list[str] | None: + nonlocal context + if (name, key) in _UNREADABLE_OPTIONS: + raise _Unreadable(value if value is not None else key) + if name == "env" and key == "i": + context = context._replace(path=None) + if value is None: + return None + if name == "env" and key == "S": + if _UNSPLITTABLE & set(value): + raise _Unreadable(value) + try: + return shlex.split(value) + except ValueError: + raise _Unreadable(value) from None + values.append((value, context)) + if (name, key) in (("env", "C"), ("sudo", "D")): + # the directory as the system enters it, links followed + context = context._replace(cwd=_resolved_path( + os.path.join(context.cwd, value))) + elif name == "env" and key == "u" and value == "PATH": + context = context._replace(path=None) + return None + + index = 0 + while index < len(rest): + member = rest[index] + if member == "--": + return index + 1, context, None + if name == "env" and member == "-": # `env -`: `-i` + context = context._replace(path=None) + return index + 1, context, None + if not member.startswith("-") or member == "-": + break + index += 1 + if grammar.numeric and _NUMERIC_OPTION.fullmatch(member): + continue + if member.startswith("--"): + spelled, equals, value = member[2:].partition("=") + option = _long_option(grammar, spelled) + if option is None or (equals and not option.takes): + raise _Unreadable(member) + if not equals: + value = None + if option.takes == ":": + if index == len(rest): + raise _Unreadable(member) + value = rest[index] + index += 1 + split = given(option.key, value) + if split is not None: + return index, context, split + continue + at = 1 + while at < len(member): + letter = member[at] + at += 1 + takes = grammar.short.get(letter) + if takes is None: + raise _Unreadable(member) + if not takes: + given(letter, None) + continue + value = member[at:] or None + if value is None and takes == ":": + if index == len(rest): + raise _Unreadable(member) + value = rest[index] + index += 1 + split = given(letter, value) + if split is not None: + return index, context, split + break + return index, context, None + + +def _unwrapped(members) -> _Unwrapped: + """`members` with every leading launcher unwrapped (at most + `_LAUNCHER_DEPTH` deep), each read by its own grammar (`_LAUNCHERS`), + from the context every broker starts in, as each changes it: `env -C` + and `sudo -D`/`--chdir` move the working directory, and `env PATH=...`, + `env -i` and `env -u PATH` change the search path. Each launcher's + operands are judged as its values are.""" command = tuple(members) - launchers: list[str] = [] - values: list[str] = [] - for _depth in range(32): + context = _broker_context() + launchers: list[tuple[str, _Context]] = [] + values: list[tuple[str, _Context]] = [] + for _depth in range(_LAUNCHER_DEPTH): if not command: break - name = _launcher_name(command[0], first=True, root=root) + name = _launcher_name(command[0], context=context) if name is None: break - grammar = _LAUNCHERS[name] - launchers.append(command[0]) + launchers.append((command[0], context)) rest = command[1:] - split: list[str] | None = None - index = 0 - while index < len(rest): - member = rest[index] - if member == "--": - index += 1 - break - if not member.startswith("-") or member == "-": - break - value = None - if member.startswith("--"): - option, equals, given = member.partition("=") - if option in grammar.long: - if equals: - value = given - elif index + 1 < len(rest): - index += 1 - value = rest[index] - option_name = option - else: - option_name = None - else: - option_name = None - for at, letter in enumerate(member[1:], start=1): - if letter in grammar.short: - option_name = "-" + letter - if member[at + 1:]: - value = member[at + 1:] - elif index + 1 < len(rest): - index += 1 - value = rest[index] - break - index += 1 - if value is None or option_name is None: - continue - if name == "env" and option_name in ("-S", "--split-string"): - try: - split = shlex.split(value) - except ValueError: - return _Unwrapped(tuple(launchers), tuple(values), (), - unreadable=value) - elif name == "env" and option_name in ("-C", "--chdir"): - values.append(value) + try: + index, context, split = _launcher_options(name, rest, context, + values) + except _Unreadable as unreadable: + return _Unwrapped(tuple(launchers), tuple(values), (), context, + unreadable=unreadable.member) if split is not None: # `env -S STRING`: STRING's words are env's own arguments, read # again by env's grammar, before what followed them. @@ -1178,12 +1477,22 @@ def _unwrapped(members, *, root: Path) -> _Unwrapped: continue if name == "env": while index < len(rest) and _ASSIGNMENT.match(rest[index]): + variable, assigned = rest[index].split("=", 1) # A search path's every directory is judged (`PATH=a:b`). - values.extend(rest[index].split("=", 1)[1].split(os.pathsep)) + values.extend((part, context) + for part in assigned.split(os.pathsep)) + if variable == "PATH": + context = context._replace(path=assigned) index += 1 - index += grammar.operands - command = rest[index:] - return _Unwrapped(tuple(launchers), tuple(values), command) + operands = rest[index:index + _LAUNCHERS[name].operands] + values.extend((operand, context) for operand in operands) + command = rest[index + len(operands):] + else: + if command and _launcher_name(command[0], + context=context) is not None: + return _Unwrapped(tuple(launchers), tuple(values), command, + context, unreadable=command[0]) + return _Unwrapped(tuple(launchers), tuple(values), command, context) def _unversioned(name: str) -> str: @@ -1192,38 +1501,73 @@ def _unversioned(name: str) -> str: return re.sub(r"[-.\d]+$", "", name) or name -def _program_names(member: str, *, first: bool, root: Path) -> set[str]: +def _program_names(member: str, *, first: bool, + context: _Context) -> set[str]: """The names `member` could run as: its own file name and, where it - resolves to a file, that file's (`/bin/sh` may be `dash`, and a link - named `broker` may be `python3`), each without a version suffix.""" + names a file (`_located`), that file's, links followed (`/bin/sh` may be + `dash`, and a link named `broker` may be `python3`), each without a + version suffix.""" names = {Path(member).name} - names.update(path.name for path in _program_path(member, first=first, - root=root)) + names.update(Path(_resolved_path(path)).name for path in _located( + member, first=first, context=context)) return {_unversioned(name) for name in names if name} -def _gives_an_inline_script(name: str, rest: tuple[str, ...], *, - first: bool) -> bool: +def _gives_an_inline_script(name: str, rest: tuple[str, ...]) -> bool: """Whether a program named `name`, followed by `rest`, is given an inline - script. Only its options are read, up to a `--` member.""" - options = [] - for member in rest: - if member == "--": - break - options.append(member) + script, read by its own grammar (`_INTERPRETERS`): a short cluster + letter by letter, so an attached script (`-cprint(1)`) and a cluster + (`-Sc`, `-nle`) are read, and an option's value is skipped; and only + until its script's operand, so an option given to the script itself + (`python /opt/broker.py -c profile`) is the script's (Copilot at + openDox-code#86, r4179241583, r4179241614).""" if name == "deno": return bool(rest) and rest[0] == "eval" - longs = _INLINE_LONG.get(name, ()) - letters = _INLINE_LETTERS.get(name, "") - for member in options: - spelled = member.lower() if name in ("pwsh", "powershell") else member - if spelled in longs or any( - long.startswith("--") and spelled.startswith(long + "=") - for long in longs): - return True - if letters and _SHORT_CLUSTER.fullmatch(member) and any( - letter in member[1:] for letter in letters): - return True + spec = _INTERPRETERS.get(name) + if spec is None: + return False + operands = 0 + index = 0 + while index < len(rest): + member = rest[index] + index += 1 + if member == "--": + return False + spelled = member.lower() if spec.folded else member + if spelled.startswith("--") or (spec.folded and spelled.startswith( + "-") and len(spelled) > 1): + option, equals, _given = spelled.partition("=") + if option in spec.longs: + return True + if option in spec.long_ends: + return False + if option in spec.long_values and not equals: + index += 1 + continue + if len(member) > 1 and (member[0] == "-" or ( + spec.plus and member[0] == "+")): + letters = member[1:] + at = 0 + while at < len(letters): + letter = letters[at] + if letter in spec.inline: + return True + if letter in spec.ends: + return False + if letter in spec.takes: + if at + 1 == len(letters): + index += 1 # its value is the next member + break + if letter in spec.attached: + break + at += 1 + continue + if spec.operands is None: + continue + if operands < spec.operands: + operands += 1 + continue + return False # the script's own operand return False @@ -1235,30 +1579,26 @@ def inline_script(members, *, root: Path | str | None = None) -> str | None: The command is asked with its launchers unwrapped (`_unwrapped`; the holder's ruling, openxFactory#656 comment 5984069416), so an `env -S` - string is split and read as the command it is, and an `env -S` string - that does not split is refused. Every member of the command as written - is asked too, not the program alone, so a wrapper of the same class that - is not in `_LAUNCHERS` (`busybox sh -c`, `xargs sh -c`, `sudo bash -c`) - hides none. A member's name is its own file name and, where it resolves - to a file, that file's, each without a version suffix - (`_program_names`). + string is split and read as the command it is. Every member of the + command as written is asked too, not the program alone, so a wrapper of + the same class that is not in `_LAUNCHERS` (`busybox sh -c`, `xargs sh + -c`, `sudo bash -c`) hides none. A member's name is its own file name + and, where it resolves to a file, that file's, each without a version + suffix (`_program_names`). `root` is not needed: the rule is the same + for every repository. THE ACCEPTED LIMIT (the same ruling, item 4): a general program that runs code from its own arguments, such as `awk 'PROGRAM'`, `sed` or `find -exec`, is not judged as an inline script.""" - where = Path(resolved_root(root)) if root is not None else Path( - os.path.abspath(os.sep)) - unwrapped = _unwrapped(members, root=where) - if unwrapped.unreadable is not None: - return unwrapped.unreadable - for command in (tuple(members), unwrapped.command): + unwrapped = _unwrapped(members) + for command, context in ((tuple(members), _broker_context()), + (unwrapped.command, unwrapped.context)): for index, member in enumerate(command): if not member or (index and member.startswith("-")): continue for name in _program_names(member, first=index == 0, - root=where): - if _gives_an_inline_script(name, command[index + 1:], - first=index == 0): + context=context): + if _gives_an_inline_script(name, command[index + 1:]): return member return None @@ -1266,11 +1606,14 @@ def inline_script(members, *, root: Path | str | None = None) -> str | None: def broker_command_refused(members, *, root: Path | str | None) -> str | None: """Why the broker command `members` may never be trusted, or None: it + cannot be read to the program it runs (`REASON_UNREADABLE_COMMAND`), it gives a shell or an interpreter an inline script (`REASON_INLINE_SCRIPT`), or, at a known `root`, it names a file inside the served repository (`REASON_IN_REPOSITORY`). Asked where trust is recorded and wherever it is judged, and of the console intake's broker.""" + if _unwrapped(members).unreadable is not None: + return REASON_UNREADABLE_COMMAND if inline_script(members, root=root) is not None: return REASON_INLINE_SCRIPT if root is not None and in_repository_argv(members, @@ -1408,19 +1751,35 @@ def recorded_for(binding, *, root: Path | str) -> TrustVerdict: "on this machine and nothing was written") +#: Why a host's policy cannot take back a trust it recorded: the trust +#: policy interface records and judges trust, and withdraws none. +REASON_NO_WITHDRAWAL = ( + "the trust policy registered here records trust but offers no way to " + "withdraw it") + + def restored_for(binding, *, replacing, root: Path | str) -> None: - """After a write that failed, trust `binding` (the form the document - still holds) again in place of `replacing` (the form trust was recorded - for, for that write), where `binding` was trusted before (T100 - follow-on, A11). openDox's own store does it only while it still holds - `replacing`'s digest, under its lock (`MachineTrust.restore`), so a - trust another process recorded meanwhile is never overwritten (Copilot - at openDox-code#86, r4179076901). A host's policy is asked to record it - again, as `recorded_for` asks. Refused BY NAME where it cannot be done.""" + """After a write that failed, take back the trust recorded for + `replacing` (the form that write would have declared): trust `binding`, + the earlier form the document still holds, again in its place where it + was trusted before (T100 follow-on, A11), and, where `binding` is None + (none was trusted, or the binding is new), withdraw it (the holder's + ruling, openxFactory#656 comment 5985046107, C2), so a failed write + leaves no trust for a form no document declares. + + openDox's own store does it only while it still holds `replacing`'s + digest, under its lock (`MachineTrust.restore`), so a trust another + process recorded meanwhile is never overwritten (Copilot at + openDox-code#86, r4179076901). A host's policy is asked to record the + earlier form again, as `recorded_for` asks; one cannot be asked to + withdraw a trust (`REASON_NO_WITHDRAWAL`). Refused BY NAME where it + cannot be done.""" registered = _registered_now() if type(registered) is MachineTrust: registered.restore(binding, root=root, replacing=replacing) return + if binding is None: + raise TrustNotRecorded(REASON_NO_WITHDRAWAL) recorded_for(binding, root=root) @@ -1478,15 +1837,22 @@ def intake_verdict_for(binding, *, root: Path | str) -> TrustVerdict: # --------------------------------------------------------------------------- +#: Why a path of the store's tree is the wrong kind of thing for its place, +#: where it is no link: what it IS, not what another user could do with it +#: (the holder's ruling, openxFactory#656 comment 5985046107, C5). +KIND_NOT_A_DIRECTORY = "is not a directory" +KIND_NOT_A_REGULAR_FILE = "is not a regular file" + + 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" + return KIND_NOT_A_DIRECTORY if not directory and not stat.S_ISREG(mode): - return "is not a regular file" + return KIND_NOT_A_REGULAR_FILE return None @@ -1557,7 +1923,13 @@ def _store_failed(state: Path | str, error: OSError, *, def _store_refused(path: Path | str, reason: str) -> TrustStoreRefused: """A store refused for what ANOTHER USER could do with it: a link, an owner or a mode (the tree checks). Only those say so (T100 follow-on, - A7); every other refusal names its own cause (`_store_unusable`).""" + A7); every other refusal names its own cause (`_store_unusable`). So a + path that is the wrong kind of thing for its place and no link (a + directory, a FIFO or a socket where the store belongs) names what it is, + with the move-aside recovery (the holder's ruling, openxFactory#656 + comment 5985046107, C5).""" + if reason in (KIND_NOT_A_DIRECTORY, KIND_NOT_A_REGULAR_FILE): + return _store_unusable(path, reason, RECOVER_MOVE_ASIDE) return TrustStoreRefused( f"the model-binding trust store refuses {shown(str(path))}: it " f"{reason}, so " @@ -1953,12 +2325,13 @@ def record(self, binding, *, root: Path | str) -> TrustVerdict: basis=BASIS_MACHINE_TRUST) def restore(self, binding, *, root: Path | str, replacing) -> bool: - """Trust `binding` at `root` again IN PLACE OF `replacing`, and only - while the store still holds `replacing`'s digest for its id: one - read, comparison and write under the store's lock (Copilot at - openDox-code#86, r4179076901). A failed edit asks this, so a trust - another process recorded meanwhile stands. Returns whether the - store was changed.""" + """Trust `binding` at `root` again IN PLACE OF `replacing`, or, where + `binding` is None, withdraw `replacing`'s trust (C2), and only while + the store still holds `replacing`'s digest for its id: one read, + comparison and write under the store's lock (Copilot at + openDox-code#86, r4179076901). A failed add or edit asks this, so a + trust another process recorded meanwhile stands. Returns whether + the store was changed.""" key_root = resolved_root(root) held_for = (key_root, replacing.id) with self._lock: @@ -1972,7 +2345,9 @@ def restore(self, binding, *, root: Path | str, replacing) -> bool: if entries.get(held_for) != binding_digest(replacing): return False del entries[held_for] - entries[(key_root, binding.id)] = binding_digest(binding) + if binding is not None: + entries[(key_root, binding.id)] = binding_digest( + binding) self._write(state, entries) except OSError as error: raise _store_failed(state, error, writing=True) from None diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index f5997611..662f4018 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -2951,28 +2951,119 @@ def test_A1_each_reason_trust_repairs_prints_the_command_that_repairs_it( def _in_repository_argv(served, where, monkeypatch): """A broker command naming a file inside the served repository, `where` - each way a command can name one. The repository holds a copy of the - marker broker at `tools/broker.py`.""" + each way a command can name one, as the broker runs it: from + `BROKER_WORKING_DIRECTORY`, whatever directory the console was started + in (here, an empty one outside the repository), unless a launcher moves + it. The repository holds a copy of the marker broker at + `tools/broker.py`.""" tool = served.repo / "tools" / "broker.py" tool.parent.mkdir(exist_ok=True) shutil.copy(served.broker, tool) + console = served.tmp / "console" + console.mkdir() + monkeypatch.chdir(console) + broker_cwd = provider_mod.BROKER_WORKING_DIRECTORY + beside = served.repo.parent # the repository's own parent if where == "absolute": return [sys.executable, str(tool)] - if where == "relative-to-the-working-directory": - monkeypatch.chdir(served.repo) - return [sys.executable, "tools/broker.py"] - if where == "relative-from-deeper-in-it": - # as written from the working directory, and from no other - monkeypatch.chdir(served.repo / "tools") - return [sys.executable, "../tools/broker.py"] - if where == "bare-word-in-the-working-directory": - monkeypatch.chdir(served.repo / "tools") - return [sys.executable, "broker.py"] - if where == "module-after-dash-m": - monkeypatch.chdir(served.repo) - return [sys.executable, "-m", "tools.broker"] + if where == "relative-to-the-broker-directory": + return [sys.executable, os.path.relpath(tool, broker_cwd)] + if where == "relative-climbing-from-the-broker-directory": + # Copilot at openDox-code#86, r4179241532: `..` from the file + # system's root is the root, so this names the repository's file + # there, and from neither the console's directory nor the root's. + return [sys.executable, + os.path.join(os.pardir, os.path.relpath(tool, broker_cwd))] + if where == "relative-after-env-changes-directory": + # r4179366288: `env -C` moves the directory the rest is read from. + deeper = beside / "a" / "b" + deeper.mkdir(parents=True) + return ["env", "-C", str(deeper), sys.executable, + f"../../{served.repo.name}/tools/broker.py"] + if where == "relative-after-env-chdir-abbreviated": + # C1: `--chd` is `--chdir` to GNU getopt_long + deeper = beside / "a" / "b" + deeper.mkdir(parents=True) + return ["env", f"--chd={deeper}", sys.executable, + f"../../{served.repo.name}/tools/broker.py"] + if where == "relative-after-env-chdir-in-a-cluster": + # C1: `-vC/dir` is `-v -C /dir` + deeper = beside / "a" / "b" + deeper.mkdir(parents=True) + return ["env", f"-vC{deeper}", sys.executable, + f"../../{served.repo.name}/tools/broker.py"] + if where == "relative-after-sudo-changes-directory": + deeper = beside / "a" / "b" + deeper.mkdir(parents=True) + return ["sudo", "-D", str(deeper), sys.executable, + f"../../{served.repo.name}/tools/broker.py"] + if where == "bare-word-after-env-changes-directory": + elsewhere = served.tmp / "elsewhere" + elsewhere.mkdir() + (elsewhere / "broker.py").symlink_to(tool) + return ["env", "-C", str(elsewhere), sys.executable, "broker.py"] + if where == "dotted-module-after-env-changes-directory": + # r4179241555: a dotted name reaches the repository through + # namespace packages from the directory `-m` imports from first. + return ["env", "-C", str(beside), sys.executable, "-m", + f"{served.repo.name}.tools.broker"] + if where == "dotted-module-through-a-package-link": + # r4179241555: the top-level package lies outside, and the module + # the whole name reaches lies inside. + package = served.tmp / "pkg" + package.mkdir() + (package / "inner").symlink_to(tool.parent) + return ["env", "-C", str(served.tmp), sys.executable, "-m", + "pkg.inner.broker"] + if where == "symlink-in-the-repository-to-outside": + # r4179241566: the repository owns the link, and a pull can point it + # at another program without changing the binding. + link = served.repo / "tools" / "outside-broker.py" + link.symlink_to(served.broker) + return [sys.executable, str(link)] + if where == "climbing-out-of-a-link": + # `..` is taken as the system takes it: from where the link leads. + (served.tmp / "up").symlink_to(tool.parent) + return [sys.executable, + str(served.tmp / "up" / os.pardir / "tools" / "broker.py")] + if where == "first-word-past-a-file-that-cannot-run": + # a search path's file that cannot be run is passed over, as the + # system passes it over + program = served.repo / "bin" / "opref-runnable" + program.parent.mkdir() + program.write_text(f"#!{sys.executable}\n", encoding="utf-8") + os.chmod(program, 0o755) + inert = served.tmp / "inert" + inert.mkdir() + (inert / program.name).write_text("", encoding="utf-8") + monkeypatch.setenv("PATH", f"{inert}{os.pathsep}{program.parent}" + f"{os.pathsep}{os.environ.get('PATH', '')}") + return [program.name] + if where == "relative-search-path-entry": + # r4179366288: a relative `PATH` entry is read from the broker's + # directory, not the console's. + program = served.repo / "bin" / "opref-relative" + program.parent.mkdir() + program.write_text(f"#!{sys.executable}\n", encoding="utf-8") + os.chmod(program, 0o755) + monkeypatch.setenv("PATH", os.path.relpath(program.parent, broker_cwd) + + os.pathsep + os.environ.get("PATH", "")) + return [program.name] + if where == "search-path-assigned-through-a-link": + # r4179366288: `env PATH=...` is the path the program is found on. + program = served.repo / "bin" / "opref-linked" + program.parent.mkdir() + program.write_text(f"#!{sys.executable}\n", encoding="utf-8") + os.chmod(program, 0o755) + links = served.tmp / "links" + links.mkdir() + (links / program.name).symlink_to(program) + return ["env", f"PATH={links}", program.name] if where == "flag-value": return [sys.executable, str(served.broker), f"--config={tool}"] + if where == "relative-flag-value-after-env-changes-directory": + return ["env", "-C", str(beside), sys.executable, str(served.broker), + f"--config={served.repo.name}/tools/broker.py"] if where == "link-from-outside": link = served.tmp / "outside-link.py" link.symlink_to(tool) @@ -2988,9 +3079,11 @@ def _in_repository_argv(served, where, monkeypatch): f"{os.environ.get('PATH', '')}") return ["-broker"] if where == "positional-after-double-dash": - shutil.copy(tool, tool.parent / "-broker.py") - monkeypatch.chdir(tool.parent) - return [sys.executable, "--", "-broker.py"] + elsewhere = served.tmp / "elsewhere" + elsewhere.mkdir() + (elsewhere / "-broker.py").symlink_to(tool) + return ["env", "-C", str(elsewhere), sys.executable, "--", + "-broker.py"] if where == "first-word-on-path": program = served.repo / "bin" / "opref-broker" program.parent.mkdir() @@ -3004,10 +3097,23 @@ def _in_repository_argv(served, where, monkeypatch): raise AssertionError(where) -IN_REPOSITORY = ("absolute", "relative-to-the-working-directory", - "relative-from-deeper-in-it", - "bare-word-in-the-working-directory", "module-after-dash-m", - "flag-value", "link-from-outside", "first-word-on-path", +IN_REPOSITORY = ("absolute", "relative-to-the-broker-directory", + "relative-climbing-from-the-broker-directory", + "relative-after-env-changes-directory", + "relative-after-sudo-changes-directory", + "relative-after-env-chdir-abbreviated", + "relative-after-env-chdir-in-a-cluster", + "bare-word-after-env-changes-directory", + "dotted-module-after-env-changes-directory", + "dotted-module-through-a-package-link", + "symlink-in-the-repository-to-outside", + "relative-search-path-entry", + "climbing-out-of-a-link", + "first-word-past-a-file-that-cannot-run", + "search-path-assigned-through-a-link", + "flag-value", + "relative-flag-value-after-env-changes-directory", + "link-from-outside", "first-word-on-path", "dash-named-program-on-path", "positional-after-double-dash") @@ -3831,7 +3937,13 @@ def test_N1_the_declarations_document_is_never_written_through_a_link( UNREADABLE = {"not-utf-8": (b"\xff\xfe\x00schema_version: 1\n", "not UTF-8"), "nested": (b"[" * 1000 + b"]" * 1000, "nests too deeply"), - "no-permission": (b"schema_version: 1\n", "cannot be read")} + "no-permission": (b"schema_version: 1\n", "cannot be read"), + # the holder's ruling, openxFactory#656 comment 5985046107, C3: + # valid YAML whose timestamp cannot be constructed + "unconstructable": ( + b"schema_version: 1\nkind: model-provider-bindings\n" + b"bindings: []\nnote: 2024-13-01\n", + "is not readable YAML")} @contextlib.contextmanager @@ -3883,6 +3995,65 @@ def test_N2_an_unreadable_declarations_document_is_refused_by_name( assert not intake_mod.pending_binding_ids(served.repo) +@contextlib.contextmanager +def _unsearchable(directory: Path): + """`directory` with a mode this user cannot search, restored after.""" + directory.mkdir(parents=True, exist_ok=True) + os.chmod(directory, 0) + try: + yield + finally: + os.chmod(directory, 0o755) + + +def test_N2_a_bindings_document_behind_an_unsearchable_directory_is_refused( + served, capsys): + """The look before the read refuses BY NAME too (Copilot at + openDox-code#86, r4179241603): a directory on the way to the bindings + document that this user cannot search fails the link check and the + presence check themselves, and the console's start still reads it as + declaring no binding and says why.""" + from opendox import doxbench_model + + document = binding_mod.bindings_path(served.repo) + with _unsearchable(document.parent): + # The presence check raises rather than read it as absent, so a + # Python whose `Path.is_symlink` swallows the error refuses it too. + with pytest.raises(PermissionError): + binding_mod.document_present(document) + with pytest.raises(binding_mod.BindingRefused) as refused: + binding_mod.BindingStore(document).list() + assert "cannot be read" in str(refused.value) + assert served.port() is doxbench_model.NO_MODEL_CONFIGURED + assert "cannot be read" in capsys.readouterr().err + assert _cli("model-binding", "list", "--repo-root", + str(served.repo)) == 1 + assert "cannot be read" in capsys.readouterr().err + + +def test_N2_a_declarations_document_behind_an_unsearchable_directory_is_refused( + served): + document = intake_mod.declarations_path(served.repo) + with _unsearchable(document.parent): + with pytest.raises(intake_mod.IntakeRefused) as refused: + intake_mod.DeclarationStore(document).get(BINDING_ID) + assert "cannot be read" in str(refused.value) + # the console's start reads it as declaring nothing pending + assert not intake_mod.pending_binding_ids(served.repo) + + +def test_N2_a_missing_settings_document_still_declares_nothing(served): + """The hosted path is kept: a document that is not there, or a file + where a directory belongs on the way to it, is absent, not refused.""" + for document in (binding_mod.bindings_path(served.repo), + intake_mod.declarations_path(served.repo)): + assert not binding_mod.document_present(document) + blocker = served.tmp / "a-file" + blocker.write_text("", encoding="utf-8") + assert not binding_mod.document_present(blocker / "bindings.yaml") + assert not binding_mod.BindingStore(blocker / "bindings.yaml").list() + + # =========================================================================== # 7. Copilot's first review of openDox-code#86 (review 5407887563), and A2 # EXTENDED (RULED by Brett Heap, openxFactory#656 comment 5983805990, @@ -4076,7 +4247,31 @@ def test_A5_an_intake_broker_the_rules_refuse_is_not_offered(served): "sudo-wrapped": ["sudo", "-u", "bob", "bash", "-c", "x"], "env-python": ["/usr/bin/env", "python3", "-c", "x"], "env-split-python": ["env", "-S", "python3 -c x"], - "env-split-unreadable": ["env", "-S", "a 'b"], + # Copilot at openDox-code#86, r4179241583: an attached script, and an + # inline option after options that take values, read by grammar. + "python-attached": [sys.executable, "-cprint(1)"], + "python-cluster-attached": ["python3", "-Bcprint(1)"], + "python-value-then-c": ["python3", "-W", "ignore", "-c", "x"], + "ruby-attached": ["ruby", "-eputs(1)"], + "perl-digits-then-e": ["perl", "-l0e", "x"], + "php-B": ["php", "-B", "x"], + "lua-e": ["lua", "-e", "x"], + "osascript-e": ["osascript", "-e", "x"], + "node-require-then-e": ["node", "--require", "mod", "-e", "x"], + "su-c": ["su", "bob", "-c", "x"], + "runuser-command": ["runuser", "-u", "bob", "--command=x"], + "pwsh-policy-then-command": ["pwsh", "-ExecutionPolicy", "Bypass", + "-Command", "x"], + "pwsh-encoded": ["powershell", "-EncodedCommand", "eAA="], + "fish-C": ["fish", "-C", "x"], + "bash-plus-o-then-c": ["bash", "+o", "posix", "-c", "x"], + # The holder's ruling, openxFactory#656 comment 5985046107, C1: env's + # long options by any unambiguous beginning, and its clusters. + "env-split-string-abbreviated": ["env", "--split=sh -c x"], + "env-split-string-abbreviated-next": ["env", "--spl", "sh -c x"], + "env-split-string-bundled": ["env", "-iS", "sh -c x"], + "env-split-string-bundled-attached": ["env", "-iSsh -c x"], + "env-unset-bundled": ["env", "-0u", "NAME", "sh", "-c", "x"], "flock-command": ["flock", "/tmp/opendox-lock", "-c", "x"], "deno-eval": ["deno", "eval", "x"], } @@ -4174,6 +4369,36 @@ def test_A2_an_inline_script_trusted_before_the_rule_never_runs( "timeout", "-s", "KILL", "30", sys.executable, str(served.broker)], "an-option-after-the-scripts-double-dash": lambda served: [ "/bin/sh", str(served.broker), "--", "-c"], + # Copilot at openDox-code#86, r4179241614: an interpreter's options end + # at its script or module, so what follows is the script's own. + "an-option-given-to-the-script": lambda served: [ + sys.executable, str(served.broker), "-c", "profile"], + "a-value-then-the-script": lambda served: [ + sys.executable, "-W", "ignore", str(served.broker), "-c", "x"], + "a-module-then-its-own-option": lambda served: [ + sys.executable, "-m", "json.tool", "-c", "x"], + "a-shell-given-a-file-then-c": lambda served: [ + "/bin/sh", str(served.broker), "-c", "x"], + "perl-include-then-the-script": lambda served: [ + "perl", "-I", "/opt/opendox-test/lib", str(served.broker), "-e", + "x"], + "pwsh-file-then-command": lambda served: [ + "pwsh", "-File", str(served.broker), "-Command", "x"], + "flock-given-a-program": lambda served: [ + "flock", "/tmp/opendox-lock", sys.executable, str(served.broker), + "-c", "x"], + "su-given-a-shell-file": lambda served: [ + "su", "bob", "-s", str(served.broker)], + "a-module-attached-then-its-own-option": lambda served: [ + sys.executable, "-mjson.tool", "-c", "x"], + "an-option-like-file-after-double-dash": lambda served: [ + "/bin/sh", "--", "-c"], + "perl-module-attached": lambda served: [ + "perl", "-MExporter", str(served.broker)], + "perl-in-place-extension": lambda served: [ + "perl", "-ie", str(served.broker)], + "perl-unicode-features-attached": lambda served: [ + "perl", "-CE", str(served.broker)], } @@ -4226,6 +4451,11 @@ def test_A2_every_broker_starts_outside_the_served_repository( with contextlib.suppress(Exception): port.dispatch(_Envelope()) ran_in = Path(where.read_text(encoding="utf-8")) + # the file system's root, which lies outside every served repository, + # and the one directory the rules judge a command from + assert provider_mod.BROKER_WORKING_DIRECTORY == os.path.abspath(os.sep) + assert provider_mod.BROKER_WORKING_DIRECTORY is ( + _trust_mod().BROKER_WORKING_DIRECTORY) assert ran_in == Path(provider_mod.BROKER_WORKING_DIRECTORY) assert served.repo.resolve() not in (ran_in.resolve(), *ran_in.resolve().parents) @@ -4240,13 +4470,16 @@ def test_A2_every_broker_starts_outside_the_served_repository( #: the served repository, which the launched command names by a bare word. LAUNCHED = { "env": ["env", "PROG"], - "env-ignore-and-assign": ["env", "-i", "OPREF_PROFILE=work", "PROG"], + "env-ignore-and-assign": ["env", "-i", "OPREF_PROFILE=work", + "PATH=BIN", "PROG"], "env-split-string": ["env", "-S", "OPREF_PROFILE=work PROG --flag"], "nice": ["nice", "-n", "5", "PROG"], "nice-adjustment": ["nice", "--adjustment=5", "PROG"], "nohup": ["nohup", "PROG"], "timeout": ["timeout", "-s", "KILL", "5", "PROG"], "timeout-long-option": ["timeout", "--signal", "KILL", "5", "PROG"], + "timeout-abbreviated": ["timeout", "--sig", "KILL", "5", "PROG"], + "stdbuf-abbreviated": ["stdbuf", "--out", "L", "PROG"], "stdbuf": ["stdbuf", "-oL", "PROG"], "setsid": ["setsid", "-w", "PROG"], "chrt": ["chrt", "-o", "0", "PROG"], @@ -4276,7 +4509,8 @@ def test_A2_a_launcher_is_unwrapped_to_the_program_it_starts( os.chmod(program, 0o755) monkeypatch.setenv("PATH", f"{program.parent}{os.pathsep}" f"{os.environ.get('PATH', '')}") - argv = [member.replace("PROG", program.name) for member in LAUNCHED[case]] + argv = [member.replace("BIN", str(program.parent)).replace( + "PROG", program.name) for member in LAUNCHED[case]] served.hand_write(served.record("broker", broker_argv=argv)) binding = served.declared() assert trust_mod.broker_refusal(binding, root=served.repo) == ( @@ -4296,9 +4530,18 @@ def _launcher_values(served): os.chmod(launcher, 0o755) return { "env-assigns-a-module-path": [ - "env", "PYTHONPATH=tools", sys.executable, "-m", "broker"], + "env", f"PYTHONPATH={tool.parent}", sys.executable, "-m", + "broker"], + "env-assigns-a-relative-module-path": [ + "env", "PYTHONPATH=" + os.path.relpath( + tool.parent, provider_mod.BROKER_WORKING_DIRECTORY), + sys.executable, "-m", "broker"], "env-assigns-a-search-path": [ "env", f"PATH=/usr/bin{os.pathsep}{tool.parent}", "opref-broker"], + "env-assigns-a-relative-search-path-entry": [ + "env", "-C", str(served.repo.parent), + f"PATH=/usr/bin{os.pathsep}{served.repo.name}/tools", + "opref-broker"], "env-changes-directory": [ "env", "-C", str(served.repo), sys.executable, str(served.broker)], "env-changes-directory-long": [ @@ -4310,7 +4553,9 @@ def _launcher_values(served): @pytest.mark.parametrize("case", ["env-assigns-a-module-path", + "env-assigns-a-relative-module-path", "env-assigns-a-search-path", + "env-assigns-a-relative-search-path-entry", "env-changes-directory", "env-changes-directory-long", "a-launcher-inside-the-repository"]) @@ -4325,6 +4570,378 @@ def test_A2_what_a_launcher_is_given_is_judged_too(served, case): trust_mod.REASON_IN_REPOSITORY), argv +def test_A2_a_cleared_search_path_is_the_default_one(served, monkeypatch): + """`env -i`, `env -`, `env --ignore-environment` and `env -u PATH` leave + the program they start to be found on the platform's default search + path, as the launched child finds it (Copilot at openDox-code#86, + r4179366288), not on the console's: a program only the console's `PATH` + reaches is not the one that runs. Where `PATH` is assigned again, that + is the path.""" + trust_mod = _trust_mod() + program = served.repo / "bin" / "opref-console-only" + program.parent.mkdir() + program.write_text(f"#!{sys.executable}\n", encoding="utf-8") + os.chmod(program, 0o755) + monkeypatch.setenv("PATH", f"{program.parent}{os.pathsep}" + f"{os.environ.get('PATH', '')}") + for launcher in (["env", "-i"], ["env", "-"], + ["env", "--ignore-environment"], ["env", "-u", "PATH"], + ["env", "--unset=PATH"], ["env", "-iv"]): + argv = [*launcher, program.name] + assert trust_mod.broker_command_refused( + argv, root=served.repo) is None, argv + links = served.tmp / "links" + links.mkdir() + (links / program.name).symlink_to(program) + argv = ["env", "-i", f"PATH={links}", program.name] + assert trust_mod.broker_command_refused(argv, root=served.repo) == ( + trust_mod.REASON_IN_REPOSITORY) + # and a console whose `PATH` reaches it, with no launcher, still is + assert trust_mod.broker_command_refused( + [program.name], root=served.repo) == trust_mod.REASON_IN_REPOSITORY + + +def _unreadable_command(served, monkeypatch, case): + program = served.repo / "bin" / "opref-broker" + program.parent.mkdir() + program.write_text(f"#!{sys.executable}\n", encoding="utf-8") + os.chmod(program, 0o755) + monkeypatch.setenv("PATH", f"{program.parent}{os.pathsep}" + f"{os.environ.get('PATH', '')}") + if case == "launchers-nested-past-the-depth": + return ["env"] * 33 + [program.name] + if case == "split-string-that-does-not-split": + return ["env", "-S", "a 'b"] + return UNREADABLE_COMMANDS[case] + + +#: The holder's ruling, openxFactory#656 comment 5985046107, C1: what a +#: launcher's getopt would read otherwise than its words say is refused +#: FAIL-CLOSED: an ambiguous or an unknown option, a flag given a value, a +#: value that is missing, an option after which what runs cannot be judged, +#: and an `env -S` string env would not split as a shell does. +UNREADABLE_COMMANDS = { + "ambiguous-d": ["env", "--d", "sh"], + "ambiguous-i": ["env", "--i", "sh"], + "unknown-long-option": ["env", "--frobnicate", "sh"], + "unknown-short-option": ["nice", "-z", "/bin/true"], + "a-flag-given-a-value": ["env", "--debug=1", "/bin/true"], + "a-value-missing": ["env", "-C"], + "a-long-value-missing": ["env", "--chdir"], + "split-string-escape": ["env", "-S", "sh\\_-c\\_id"], + "split-string-variable": ["env", "-S", "sh -c $OPREF_SCRIPT"], + "split-string-comment": ["env", "-S", "sh #", "-c", "x"], + "env-argv0": ["env", "--argv0=sh", "/opt/opendox-test/multi-call", + "-c", "x"], + "sudo-chroot": ["sudo", "--chroot=/srv", "/bin/true"], + "sudo-login": ["sudo", "-i", "/bin/true"], +} + + +@pytest.mark.parametrize("case", ["launchers-nested-past-the-depth", + "split-string-that-does-not-split", + *sorted(UNREADABLE_COMMANDS)]) +def test_A2_a_command_that_cannot_be_read_is_refused_by_name( + served, capsys, monkeypatch, case): + """A broker command that cannot be read to the program it runs (Copilot + at openDox-code#86, r4179366319): launchers nested past what is + unwrapped, where the program the last one starts was never judged, or + an `env -S` string that does not split as a shell would. What it runs + cannot be judged, so it is refused BY NAME where trust is recorded and + where it is checked, with the inline-script remedy.""" + trust_mod = _trust_mod() + argv = _unreadable_command(served, monkeypatch, case) + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + refused = trust_mod.broker_refusal(binding, root=served.repo) + assert refused is not None and refused != ( + trust_mod.REASON_INLINE_SCRIPT), refused + assert refused == trust_mod.REASON_UNREADABLE_COMMAND + assert not trust_mod.trust_can_repair(refused) + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + err = capsys.readouterr().err + assert trust_mod.REASON_UNREADABLE_COMMAND in err + assert trust_mod.REMEDY_INLINE_SCRIPT in err + _no_trust_command_in(err) + for policy in (served.trust, _TrustsEveryBinding()): + trust_mod.unregister() + trust_mod.register(policy) + verdict = trust_mod.verdict_for(binding, root=served.repo) + assert verdict.reason == trust_mod.REASON_UNREADABLE_COMMAND + admitted = trust_mod.TrustVerdict.trusted_for( + binding, root=served.repo, basis=trust_mod.BASIS_HOST) + with pytest.raises(trust_mod.BindingUntrusted) as beneath: + trust_mod.require_admitted(binding, admitted) + assert trust_mod.REASON_UNREADABLE_COMMAND in str(beneath.value) + served.nothing_was_touched() + + +def test_A2_launchers_within_the_depth_are_unwrapped_whole(served): + """The control: launchers nested within the depth are unwrapped to the + program they start, which is judged, and admitted where it is a file + outside the repository.""" + trust_mod = _trust_mod() + argv = ["env"] * 31 + [sys.executable, str(served.broker)] + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + assert trust_mod.broker_refusal(binding, root=served.repo) is None + trust_mod.recorded_for(binding, root=served.repo) + assert trust_mod.verdict_for(binding, root=served.repo).trusted + + +def test_C1_a_split_string_env_would_expand_is_refused(): + """`${VAR}`, which env -S expands and a shell's split does not: refused + as unreadable. A binding cannot declare it at all (a placeholder outside + its vocabulary), so the console intake's broker is where it is asked.""" + trust_mod = _trust_mod() + assert trust_mod.broker_command_refused( + ["env", "-S", "sh -c ${OPREF_SCRIPT}"], root=None) == ( + trust_mod.REASON_UNREADABLE_COMMAND) + + +def test_C1_a_launchers_own_options_are_read_as_its_getopt_reads_them( + served): + """The controls: an unambiguous beginning of a long option, a cluster, + nice's own `-5`, `--help`, and an option taking its value after `=`, + each unwrapped to the program it starts, here a file outside the + repository, so nothing is refused.""" + trust_mod = _trust_mod() + program = [sys.executable, str(served.broker)] + for argv in (["env", "--ignore-env", *program], + ["env", "-iv", *program], + ["env", f"--chd={served.tmp}", *program], + ["env", "-0u", "OPREF_PROFILE", *program], + ["nice", "-5", *program], + ["nice", "--adj=5", *program], + ["timeout", "--sig=KILL", "5", *program], + ["stdbuf", "--out", "L", *program], + ["xargs", "--max-a", "1", *program], + ["sudo", "--user=bob", *program], + ["env", "--default-signal", *program], + ["env", "--help"]): + assert trust_mod.broker_command_refused( + argv, root=served.repo) is None, argv + + +# --- C4: every file a launcher or an option could name --------------------- + + +def _launcher_files(served): + arguments = served.repo / "ideation" / "arguments" + arguments.parent.mkdir(parents=True, exist_ok=True) + arguments.write_text("x\n", encoding="utf-8") + library = served.repo / "lib" + library.mkdir() + script = "/opt/opendox-test/broker.pl" + return { + "xargs-arg-file": ["xargs", "-a", str(arguments), "printf"], + "xargs-arg-file-attached": ["xargs", f"-a{arguments}", "printf"], + "xargs-arg-file-long": ["xargs", f"--arg-file={arguments}", + "printf"], + "xargs-arg-file-abbreviated": ["xargs", "--arg", str(arguments), + "printf"], + "xargs-arg-file-in-a-cluster": ["xargs", f"-0a{arguments}", + "printf"], + "an-option-with-an-attached-path": ["perl", f"-I{library}", script], + "an-attached-path-in-a-cluster": ["perl", f"-wI{library}", script], + "a-relative-path-attached-to-an-option": [ + "env", "-C", str(served.repo.parent), "perl", + f"-I{served.repo.name}/lib", script], + "an-option-in-an-assignment": ["env", f"PERL5OPT=-I{library}", + "perl", script], + "a-path-inside-an-assignment": [ + "env", f"PERL5OPT=-Mstrict -I{library}", "perl", script], + "an-output-file": ["time", "-o", str(served.repo / "timing"), + "/bin/true"], + "a-launchers-operand": ["flock", str(served.repo / ".lock"), + "/bin/true"], + } + + +@pytest.mark.parametrize("case", ["xargs-arg-file", "xargs-arg-file-attached", + "xargs-arg-file-long", + "xargs-arg-file-abbreviated", + "xargs-arg-file-in-a-cluster", + "an-option-with-an-attached-path", + "an-attached-path-in-a-cluster", + "a-relative-path-attached-to-an-option", + "an-option-in-an-assignment", + "a-path-inside-an-assignment", + "an-output-file", "a-launchers-operand"]) +def test_C4_every_file_a_launcher_or_an_option_names_is_judged(served, case): + """The holder's ruling, openxFactory#656 comment 5985046107, C4: xargs's + argument file in every spelling, a path attached to a single-dash + option, a path inside an assignment's value, and a launcher's operands, + each judged where the broker reads it. Inside the repository is refused, + fail-closed, and so is a file a launcher writes there (`time -o`), an + accepted strictness.""" + trust_mod = _trust_mod() + argv = _launcher_files(served)[case] + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + assert trust_mod.broker_refusal(binding, root=served.repo) == ( + trust_mod.REASON_IN_REPOSITORY), argv + trust_mod.unregister() + trust_mod.register(_TrustsEveryBinding()) + assert trust_mod.verdict_for(binding, root=served.repo).reason == ( + trust_mod.REASON_IN_REPOSITORY) + + +# --- C5: a wrong kind in the store's place names what it is ---------------- + + +@pytest.mark.parametrize("kind", ["directory", "fifo", "socket"]) +def test_C5_a_wrong_kind_in_the_stores_place_names_what_it_is( + served, monkeypatch, kind): + """The holder's ruling, openxFactory#656 comment 5985046107, C5: a + directory, a FIFO or a socket where the store belongs is no link, owner + or mode, so it is not blamed on another user (A7): it is refused for + what it is, with the move-aside recovery, by the verdict and by + `record`.""" + import socket + + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + served.state_dir.mkdir(mode=0o700) + store = served.state_dir / trust_mod.TRUST_FILENAME + listening = None + if kind == "directory": + store.mkdir(mode=0o700) + elif kind == "fifo": + os.mkfifo(store, 0o600) + else: + monkeypatch.chdir(served.state_dir) # a socket's path is short + listening = socket.socket(socket.AF_UNIX) + listening.bind(store.name) + try: + verdict = served.trust.verdict(served.declared(), root=served.repo) + with pytest.raises(trust_mod.TrustStoreRefused) as refused: + served.trust.record(served.declared(), root=served.repo) + finally: + if listening is not None: + listening.close() + assert not verdict.trusted + for words in (verdict.reason, str(refused.value)): + assert "is not a regular file" in words, words + assert "another user could change" not in words, words + assert trust_mod.RECOVER_MOVE_ASIDE in words, words + + +# --- C2: a failed write takes back the trust it recorded ------------------- + + +def _held_entries(served) -> list: + """The trust store's entries for the binding, as the store holds them.""" + store = served.state_dir / _trust_mod().TRUST_FILENAME + if not store.exists(): + return [] + return [entry for entry in json.loads(store.read_text( + encoding="utf-8"))["entries"] if entry["binding_id"] == BINDING_ID] + + +def _failing(monkeypatch, verb, error=None): + def fails(store, binding): + raise error or OSError(28, "No space left on device") + + monkeypatch.setattr(binding_mod.BindingStore, verb, fails) + + +@pytest.mark.parametrize("failure", ["the-system", "the-store"]) +def test_C2_an_add_whose_write_fails_withdraws_the_trust_it_recorded( + served, capsys, monkeypatch, failure): + """The holder's ruling, openxFactory#656 comment 5985046107, C2: the + trust `add` records first is withdrawn when the document's write fails, + by the system (a full disk) or by the store's own refusal, so this + machine trusts no form no document declares, and "nothing in it + changed" is true of both.""" + if failure == "the-system": + _failing(monkeypatch, "add") + said = "nothing in it changed" + else: + said = "the store refused the write" + _failing(monkeypatch, "add", binding_mod.BindingRefused(said)) + assert _cli(*served.add_argv("env")) == 1 + err = capsys.readouterr().err + assert said in err + assert "could not be withdrawn" not in err + assert not binding_mod.bindings_path(served.repo).exists() + assert not _held_entries(served), "a trust stayed for no declared form" + + +def test_C2_an_edit_of_an_untrusted_binding_whose_write_fails_trusts_nothing( + served, capsys, monkeypatch): + """The review's case: an edit of a binding never trusted records trust + for its new form, the write fails, and that trust is withdrawn, so the + earlier form still reads "never trusted", not "changed".""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + before = served.declared() + assert not served.trust.verdict(before, root=served.repo).trusted + _failing(monkeypatch, "edit") + editing = served.add_argv("env") + editing[1] = "edit" + editing[editing.index("--label") + 1] = "Renamed" + assert _cli(*editing) == 1 + err = capsys.readouterr().err + assert "could not be written" in err and "nothing in it changed" in err + assert served.declared() == before + assert served.trust.verdict(before, root=served.repo).reason == ( + trust_mod.REASON_NEVER_TRUSTED) + assert not _held_entries(served), "a trust stayed for no declared form" + + +@pytest.mark.parametrize("trusted", [False, True], + ids=["untrusted", "trusted"]) +def test_C2_an_undoing_that_fails_says_so_and_how_to_recover( + served, capsys, monkeypatch, trusted): + """Where the trust cannot be taken back (the store refuses), the refusal + says so, after the write's own cause, and how to recover: run the same + command again once the document can be written.""" + from opendox import cli_model_binding + + trust_mod = _trust_mod() + if trusted: + assert _cli(*served.add_argv("env")) == 0 + capsys.readouterr() + else: + served.hand_write(served.record("env")) + + def refuses(self, binding, *, root, replacing): + raise trust_mod.TrustStoreRefused("the store refused the undoing") + + monkeypatch.setattr(trust_mod.MachineTrust, "restore", refuses) + _failing(monkeypatch, "edit") + editing = served.add_argv("env") + editing[1] = "edit" + editing[editing.index("--label") + 1] = "Renamed" + assert _cli(*editing) == 1 + err = capsys.readouterr().err + assert "could not be written" in err + said = ("the trust its earlier form held could not be restored" + if trusted else + "the trust just recorded for it could not be withdrawn") + assert said in err and "the store refused the undoing" in err, err + assert cli_model_binding.RECOVER_FAILED_UNDOING in err + + +def test_C2_a_host_policy_cannot_be_asked_to_withdraw_and_says_so( + served, capsys, monkeypatch): + """A host's policy records and judges trust and withdraws none, so a + failed add under one says the trust could not be withdrawn, and why.""" + from opendox import cli_model_binding + + trust_mod = _trust_mod() + trust_mod.unregister() + trust_mod.register(_TrustsEveryBinding()) + _failing(monkeypatch, "add") + assert _cli(*served.add_argv("env")) == 1 + err = capsys.readouterr().err + assert "the trust just recorded for it could not be withdrawn" in err + assert trust_mod.REASON_NO_WITHDRAWAL in err + assert cli_model_binding.RECOVER_FAILED_UNDOING in err + + @pytest.mark.parametrize("argv", [["awk", "NR == 1"], ["sed", "-n", "1p"], ["find", "/nonexistent", "-exec", "true", From 371c94b2275efd7d9b731756a0d4256e48909bd2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:21:00 +0000 Subject: [PATCH 05/16] T100 follow-on: a remedy true under every policy, and the host's own intake refusal (plan 034) The holder's ruling on openxFactory#656 comment 5985490378, an addendum to 5985046107, from lane 4's T094 check of openxFactory's GovernedBindingTrust against this PR at 96ae8816. D2. REMEDY_NOT_BY_TRUST's last sentence is policy-neutral: "Then list its bindings again: it is shown trusted, or with the command that trusts it". Under a host whose approval trusts the binding, list shows it trusted and prints no command. D3. The console intake's hand-off refused by the host's own registered policy (basis host, as for a pending binding) answers its own FIXED sentence, INTAKE_HOST_NOT_ADMITTED: "the host's trust policy does not admit this hand-off; model-binding list shows why" (doxbench_trust.intake_refusal_reason). Every other basis keeps INTAKE_BROKER_UNTRUSTED unchanged. FIXED_DIAGNOSTICS is the broker's closed set and holds neither sentence, so it is unchanged. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 28 +++++++++++++-- src/opendox/serve_workbench.py | 6 ++-- tests/test_model_binding_trust.py | 58 +++++++++++++++++++++++++++++-- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 0db31a8a..529e219a 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -148,6 +148,7 @@ "UNTRUSTED_TURN_MESSAGE", "UntrustedBindingPort", "INTAKE_BROKER_UNTRUSTED", + "INTAKE_HOST_NOT_ADMITTED", "INTAKE_BROKER_REFUSED", "INTAKE_NOT_ADMISSIBLE", "REASON_INLINE_SCRIPT", @@ -169,6 +170,7 @@ "inline_script", "in_repository_program", "intake_admissible", + "intake_refusal_reason", "restored_for", "trust_can_repair", "current", @@ -340,11 +342,13 @@ #: What every refusal says, in place of a command, where `trust` itself would #: be refused for the same reason (T100 follow-on, A1): the store cannot be #: used, the platform cannot keep it, or a host's policy declines. No command -#: is printed that could not succeed. +#: is printed that could not succeed. Its last sentence holds under every +#: policy (the holder's ruling, openxFactory#656 comment 5985490378, D2): a +#: host whose approval trusts the binding lists it trusted, with no command. REMEDY_NOT_BY_TRUST = ( "Resolve the cause above first: until it is resolved, trusting this " "binding would be refused for the same reason. Then list its bindings " - "again, which prints the command that trusts it") + "again: it is shown trusted, or with the command that trusts 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 @@ -565,6 +569,26 @@ def reason_policy_failed(error: BaseException) -> str: "was read. No command trusts an intake declaration's broker; a host's own " "trust policy (opendox.doxbench_trust.register) may admit it") +#: What the console intake's hand-off is refused with when the HOST's own +#: trust policy, registered here, is what does not admit it (a pending +#: binding, say): `INTAKE_BROKER_UNTRUSTED`'s "a host's own trust policy may +#: admit it" would send the operator to the very policy that refused (the +#: holder's ruling, openxFactory#656 comment 5985490378, D3). A FIXED +#: sentence, as that one is. +INTAKE_HOST_NOT_ADMITTED = ( + "the host's trust policy does not admit this hand-off; model-binding " + "list shows why") + + +def intake_refusal_reason(verdict: TrustVerdict) -> str: + """The FIXED sentence the console intake's hand-off is refused with for + `verdict`, which does not admit it: the host's own where the host's + policy refused (`BASIS_HOST`, D3), and `INTAKE_BROKER_UNTRUSTED` + otherwise.""" + if verdict.basis == BASIS_HOST: + return INTAKE_HOST_NOT_ADMITTED + return INTAKE_BROKER_UNTRUSTED + class BindingUntrusted(binding_mod.BindingRefused): """A binding is not trusted on this machine, so it is not used. diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index db2b9db5..3d8aa3a9 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -1194,7 +1194,9 @@ def _handle_workbench_model_intake(self) -> None: # (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. + # unread, in a FIXED sentence: the host's own where the host's policy + # refused (`doxbench_trust.intake_refusal_reason`; the holder's + # ruling, openxFactory#656 comment 5985490378, D3). from opendox import doxbench_trust verdict = doxbench_trust.intake_verdict_for( binding, root=Path(self.checkout_root)) @@ -1202,7 +1204,7 @@ def _handle_workbench_model_intake(self) -> None: if length > 0: _drain_refused_body(self.rfile, length) self._intake_refusal(DOXBENCH_ERR_INTAKE_REFUSED, - doxbench_trust.INTAKE_BROKER_UNTRUSTED) + doxbench_trust.intake_refusal_reason(verdict)) return accepts_secret = ( declared["kind"] == doxbench_binding.AUTH_KIND_API_KEY) diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 662f4018..00b700bc 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -1649,7 +1649,8 @@ def test_a_hosts_own_policy_may_admit_the_console_intake(served): 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 + # the host's own policy refused it, so it says so (5985490378, D3) + assert answer.get("reason") == _trust_mod().INTAKE_HOST_NOT_ADMITTED assert not served.marker.exists() served.marker.unlink(missing_ok=True) shutil.rmtree(served.tmp / "out") @@ -1686,6 +1687,59 @@ def test_trusting_a_lookalike_binding_never_admits_the_console_intake( # --- what a policy answers is held to the binding asked about --------------- +@pytest.mark.parametrize("basis", ["default", "host"]) +def test_D3_the_intakes_refusal_names_the_policy_that_refused(served, basis): + """The holder's ruling, openxFactory#656 comment 5985490378, D3: where + the host's own registered policy refuses the hand-off (here, as for a + pending binding), the refusal says so in its own FIXED sentence, not + "a host's own trust policy may admit it"; under openDox's own trust the + sentence is unchanged. No broker runs either way.""" + trust_mod = _trust_mod() + + class _DeclinesTheIntake(_Declines): + def intake_verdict(self, binding, *, root): + return self.verdict(binding, root=root) + + if basis == "default": + _caps, answer = _served_intake(served) + said = trust_mod.INTAKE_BROKER_UNTRUSTED + else: + _caps, answer = _served_intake(served, + host_policy=_DeclinesTheIntake()) + said = trust_mod.INTAKE_HOST_NOT_ADMITTED + assert answer.get("error") == "intake_refused", answer + assert answer.get("reason") == said + assert not served.marker.exists() + assert trust_mod.INTAKE_HOST_NOT_ADMITTED == ( + "the host's trust policy does not admit this hand-off; " + "model-binding list shows why") + + +def test_D3_each_basis_has_its_sentence(): + trust_mod = _trust_mod() + binding = _a_binding() + for basis, said in ((trust_mod.BASIS_HOST, + trust_mod.INTAKE_HOST_NOT_ADMITTED), + (trust_mod.BASIS_MACHINE_TRUST, + trust_mod.INTAKE_BROKER_UNTRUSTED), + (trust_mod.BASIS_REPOSITORY, + trust_mod.INTAKE_BROKER_UNTRUSTED)): + verdict = trust_mod.TrustVerdict.untrusted_for( + binding, root="/srv/opendox-test", basis=basis, reason="no") + assert trust_mod.intake_refusal_reason(verdict) == said, basis + + +def test_D2_the_remedy_where_trust_cannot_help_holds_under_every_policy(): + """The holder's ruling, openxFactory#656 comment 5985490378, D2: the + remedy's last sentence is true under a host whose approval trusts the + binding, which lists it trusted and prints no command.""" + remedy = _trust_mod().REMEDY_NOT_BY_TRUST + assert remedy.endswith( + "Then list its bindings again: it is shown trusted, or with the " + "command that trusts it") + assert "which prints the command" not in remedy + + class _Declines: """A host policy whose `record` DECLINES, by answering an untrusted verdict, as openxFactory's governed policy does for a binding whose @@ -1813,7 +1867,7 @@ def intake_verdict(self, binding, *, root): if question == "intake": _caps, answer = _served_intake(served, host_policy=_AnswersForAnotherRoot()) - assert answer.get("reason") == trust_mod.INTAKE_BROKER_UNTRUSTED + assert answer.get("reason") == trust_mod.INTAKE_HOST_NOT_ADMITTED assert not served.marker.exists() return trust_mod.unregister() From d35879106fdd031c89702db98144971ed78c5ed2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:44:25 +0000 Subject: [PATCH 06/16] T100 follow-on: F16.1 as T007 batch P amends it, case by case (plan 034) openxFactory#1230 (T007 batch P) writes F16.1's cases for the holder's rulings on openxFactory#656 (5982436447 item 2, 5983805990, 5984069416, 5985046107 and 5985553609): its named test file asserts, one test per case, that each command is refused by name by add, edit, trust and set-credential, and before any spawn, with no marker file. 1. An env -S string env would not split as a shell does (a backslash, a '$', a '#', or a quote left open) is refused as an inline script (REASON_INLINE_SCRIPT), as F16.1 names it. REASON_UNREADABLE_COMMAND stays for launcher options that cannot be read and launchers past the depth. 2. A relative path whose first name the served root holds (or `.`, `..`) is judged from the root as well, fail-closed, as the repository's author wrote it (["python3", "tools/broker.py"]), and so is a bare argument that names a file there; the program's own bare name is still found on the search path, as the child finds it. A word whose first name the root does not hold (a URL, the rest of an option cluster) is judged where the broker runs alone. A name on the way counts only inside the root, so a path that passes through the root and leaves it is not refused. 3. A broker's environment carries no path inside the served root: broker_environment(base, root=) drops each path-list entry that names one, and a variable whose whole value does (doxbench_trust.names_a_path_inside), beside PWD and OLDPWD. 4. tests/test_model_binding_trust.py, section 9: every case of the amended F16.1 (44 commands through all four commands and the spawn), the outside broker's working directory and environment, the bare program-name control, the root join's controls, and the environment's path-list reading, and a trust recorded while the binding's paths resolved outside, which admits nothing once a link or a PATH entry leads inside. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 32 +- src/opendox/doxbench_trust.py | 125 ++++++-- tests/test_model_binding_trust.py | 472 +++++++++++++++++++++++++++++- 3 files changed, 579 insertions(+), 50 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 74bea8bc..7ada607e 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -328,14 +328,28 @@ WORKING_DIRECTORY_VARIABLES: frozenset[str] = frozenset({"PWD", "OLDPWD"}) -def broker_environment(base) -> dict: +def broker_environment(base, *, root=None) -> dict: """A broker's whole environment: the harness child's allowlist (`doxbench_bridge.child_environment`), without the variables that name a working directory (`WORKING_DIRECTORY_VARIABLES`), whatever that - allowlist comes to hold.""" - return {name: value - for name, value in bridge_mod.child_environment(base).items() - if name not in WORKING_DIRECTORY_VARIABLES} + allowlist comes to hold. Given the served `root`, it carries no path + inside it either (F16.1 as T007 batch P amends it): an entry of a path + list (`PATH`) that names one is dropped, and so is a variable whose + whole value does (`doxbench_trust.names_a_path_inside`), so nothing the + broker finds through its environment is a file a pull changes.""" + environment: dict = {} + for name, value in bridge_mod.child_environment(base).items(): + if name in WORKING_DIRECTORY_VARIABLES: + continue + if root is not None: + kept = [part for part in value.split(os.pathsep) + if not (part and trust_mod.names_a_path_inside( + part, root=root))] + if not any(kept) and value: + continue + value = os.pathsep.join(kept) + environment[name] = value + return environment FIXED_DIAGNOSTICS: frozenset[str] = frozenset({ DIAG_BROKER_UNREACHABLE, DIAG_BROKER_REFUSED, DIAG_BROKER_MALFORMED, @@ -600,8 +614,8 @@ def _close_quietly(stream) -> None: pass -def _run_broker(argv, *, source, timeout: float, - read=None) -> tuple[object | None, str | None]: +def _run_broker(argv, *, source, timeout: float, read=None, + root=None) -> tuple[object | None, str | None]: """The work of `subprocess_broker_runner`: `(answer, None)`, or `(None, sentence)` for a refusal. It raises no refusal itself, so no refusal keeps its frame, which holds the child and what the child @@ -633,7 +647,7 @@ def _run_broker(argv, *, source, timeout: float, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - env=broker_environment(os.environ), + env=broker_environment(os.environ, root=root), # OUTSIDE EVERY SERVED REPOSITORY (BROKER_WORKING_DIRECTORY). cwd=BROKER_WORKING_DIRECTORY, text=True, @@ -933,7 +947,7 @@ def _broker_operation(binding, operation: str, read, *, runner, trust, if runner is subprocess_broker_runner: result, failure = _run_broker(argv, source=source, timeout=BROKER_TIMEOUT_SECONDS, - read=read) + read=read, root=trust.root) if failure is None: return result raise BrokerRefused(failure, operation=operation) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 529e219a..afbe77fb 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -169,6 +169,7 @@ "in_repository_argv", "inline_script", "in_repository_program", + "names_a_path_inside", "intake_admissible", "intake_refusal_reason", "restored_for", @@ -309,7 +310,10 @@ #: ruling, openxFactory#656 comment 5984069416). THE ACCEPTED LIMIT (same #: ruling, item 4): a general program that runs code from its own arguments #: (`awk`, `sed`, `find -exec`, and the like) is not judged as an inline -#: script. +#: script. An `env -S` string env would not split as a shell does (one +#: holding a backslash, a `$` or a `#`, or quotes that do not close) is +#: refused as one too: what it runs cannot be read (F16.1 as T007 batch P +#: amends it; the holder's ruling, openxFactory#656 comment 5985046107, C1). REASON_INLINE_SCRIPT = ( "its broker command gives a shell or an interpreter an inline script, " "which is no file a review can pin and can run whatever the repository " @@ -320,17 +324,16 @@ #: openDox-code#86, r4179366319; the holder's ruling, openxFactory#656 #: comment 5985046107, C1): an option its launcher does not have, or has by #: more than one name; an option after which what runs cannot be judged -#: from its words (`env --argv0`, `sudo --chroot`, `sudo -i`); an `env -S` -#: string env would not split as a shell does (a backslash, a `$` or a -#: `#`); or launchers nested past what is unwrapped. What it runs cannot be -#: judged, so it is refused FAIL-CLOSED, as an inline script is, with the -#: inline-script remedy. +#: from its words (`env --argv0`, `sudo --chroot`, `sudo -i`); or launchers +#: nested past what is unwrapped. What it runs cannot be judged, so it is +#: refused FAIL-CLOSED, with the inline-script remedy. (An `env -S` string +#: env would not split as a shell does is an inline script: +#: `REASON_INLINE_SCRIPT`.) REASON_UNREADABLE_COMMAND = ( "its broker command cannot be read to the program it runs (a launcher " "option it does not have, or has by more than one name, an option after " - "which what runs cannot be judged, an env -S string env would not split " - "as a shell does, or launchers nested too deeply), so what it runs " - "cannot be judged") + "which what runs cannot be judged, or launchers nested too deeply), so " + "what it runs cannot be judged") #: What an operator is told to do about such a binding. REMEDY_INLINE_SCRIPT = ( @@ -994,6 +997,12 @@ def _traversed(path: str) -> list[Path]: return names +def _has_a_separator(candidate: str) -> bool: + """Whether `candidate` is a path rather than a bare word.""" + return candidate in (".", "..") or os.sep in candidate or bool( + os.altsep and os.altsep in candidate) + + def _located(candidate: str, *, first: bool, context: _Context) -> list[str]: """The file one argv member could name, as an absolute path, or nothing: a path (with a separator) joined to the context's working @@ -1001,8 +1010,7 @@ def _located(candidate: str, *, first: bool, context: _Context) -> list[str]: bare word as the program the context's search path finds (`_which`), for the command's program; and any other bare word that names an existing file in the working directory.""" - if candidate in (".", "..") or os.sep in candidate or ( - os.altsep and os.altsep in candidate): + if _has_a_separator(candidate): return [os.path.join(context.cwd, candidate)] if first: located = _which(candidate, context) @@ -1067,6 +1075,23 @@ def in_repository_program(binding, *, root: Path | str) -> str | None: return in_repository_argv(binding.substituted_argv(), root=root) +def _from_the_root(candidate: str, *, program: bool, served: Path) -> bool: + """Whether a relative `candidate` is judged from the served root as + well: where it reads as a path the repository's author wrote, its first + name being one the root holds (`tools/broker.py`, `tools`) or `.` or + `..`. Not an absolute path, which names one place; not the program's + own bare name, which the search path finds; and not a word whose first + name the root does not hold: a URL (`https:`), or the rest of an option + cluster (`I/usr/lib` of `-wI/usr/lib`).""" + if os.path.isabs(candidate): + return False + if program and not _has_a_separator(candidate): + return False + head = candidate.replace(os.altsep or os.sep, os.sep).split(os.sep)[0] + return head in (os.curdir, os.pardir) or os.path.lexists( + os.path.join(served, head)) + + def in_repository_argv(members, *, root: Path | str) -> str | None: """The member of a broker command `members` that names a file inside the served repository, or None: `in_repository_program`'s rule, for a @@ -1074,7 +1099,11 @@ def in_repository_argv(members, *, root: Path | str) -> str | None: It is judged as it runs (`_Context`; Copilot at openDox-code#86, r4179241532, r4179366288): from `BROKER_WORKING_DIRECTORY`, on the - search path the broker inherits. A member names a file inside the + search path the broker inherits. A relative path whose first name the + served root holds is judged from the root as well, FAIL-CLOSED, as the + repository's author would have written it (F16.1 as T007 batch P amends + it; `_from_the_root`), and so is a bare word, other than the program's + own, that names a file there. A member names a file inside the repository where any name on the way to it (`_traversed`) is the served root or lies under it, for every file the member could name (`_candidates`): an option's value, attached or after `=`, and every @@ -1090,15 +1119,32 @@ def in_repository_argv(members, *, root: Path | str) -> str | None: served = Path(resolved_root(root)) def inside(found: list[str]) -> bool: - return any(name == served or served in name.parents - for path in found for name in _traversed(path)) + # A name on the way counts where it lies INSIDE the root (a link the + # repository holds); the root itself is passed through by every path + # joined to it, so only the end may be the root itself. + for path in found: + *on_the_way, end = _traversed(path) + if end == served or served in end.parents: + return True + if any(served in name.parents for name in on_the_way): + return True + return False + + from_the_root = _Context(str(served), None) def named(member: str, *, option: bool, first: bool, context: _Context) -> list[str]: - return [path for candidate in _candidates(member, option=option) - for path in _located(candidate, - first=first and candidate == member, - context=context)] + found: list[str] = [] + for candidate in _candidates(member, option=option): + program = first and candidate == member + found += _located(candidate, first=program, context=context) + if _from_the_root(candidate, program=program, served=served): + # FAIL-CLOSED: also as the repository's author wrote it, + # from the served root (F16.1 as T007 batch P amends it: + # `["python3", "tools/broker.py"]` is refused) + found += _located(candidate, first=False, + context=from_the_root) + return found unwrapped = _unwrapped(members) for launcher, context in unwrapped.launchers: @@ -1124,6 +1170,17 @@ def named(member: str, *, option: bool, first: bool, return None +def names_a_path_inside(value: str, *, root: Path | str) -> bool: + """Whether `value`, or any entry of it as a path list, names a path + inside the served repository at `root`, by `in_repository_argv`'s rule + for an option's value: from `BROKER_WORKING_DIRECTORY` and from the + served root, every name on the way, links followed. A broker's + environment carries no such value (F16.1 as T007 batch P amends it).""" + return any(part and in_repository_argv( + ["opendox-environment", f"--value={part}"], root=root) is not None + for part in value.split(os.pathsep)) + + #: Shells: an option cluster holding `c` gives one an inline script. _SHELLS = frozenset({"sh", "bash", "rbash", "zsh", "dash", "ksh", "mksh", "pdksh", "ash", "yash", "posh", "fish", "csh", "tcsh"}) @@ -1352,14 +1409,20 @@ class _Unwrapped(NamedTuple): command: tuple[str, ...] context: _Context unreadable: str | None = None + unreadable_because: str = "" class _Unreadable(Exception): - """A launcher's argument that cannot be read (`_Unwrapped.unreadable`).""" + """A launcher's argument that cannot be read (`_Unwrapped.unreadable`), + and the reason it is refused for: `REASON_UNREADABLE_COMMAND`, or, for + an `env -S` string env would not split as a shell does, + `REASON_INLINE_SCRIPT` (F16.1 as T007 batch P amends it, for the + holder's ruling, openxFactory#656 comment 5985046107, C1).""" - def __init__(self, member: str): + def __init__(self, member: str, reason: str | None = None): super().__init__(member) self.member = member + self.reason = reason or REASON_UNREADABLE_COMMAND def _long_option(grammar: _Launcher, spelled: str) -> _Option | None: @@ -1402,11 +1465,11 @@ def given(key: str, value: str | None) -> list[str] | None: return None if name == "env" and key == "S": if _UNSPLITTABLE & set(value): - raise _Unreadable(value) + raise _Unreadable(value, REASON_INLINE_SCRIPT) try: return shlex.split(value) except ValueError: - raise _Unreadable(value) from None + raise _Unreadable(value, REASON_INLINE_SCRIPT) from None values.append((value, context)) if (name, key) in (("env", "C"), ("sudo", "D")): # the directory as the system enters it, links followed @@ -1492,7 +1555,8 @@ def _unwrapped(members) -> _Unwrapped: values) except _Unreadable as unreadable: return _Unwrapped(tuple(launchers), tuple(values), (), context, - unreadable=unreadable.member) + unreadable=unreadable.member, + unreadable_because=unreadable.reason) if split is not None: # `env -S STRING`: STRING's words are env's own arguments, read # again by env's grammar, before what followed them. @@ -1515,7 +1579,8 @@ def _unwrapped(members) -> _Unwrapped: if command and _launcher_name(command[0], context=context) is not None: return _Unwrapped(tuple(launchers), tuple(values), command, - context, unreadable=command[0]) + context, unreadable=command[0], + unreadable_because=REASON_UNREADABLE_COMMAND) return _Unwrapped(tuple(launchers), tuple(values), command, context) @@ -1630,14 +1695,16 @@ def inline_script(members, *, root: Path | str | None = None) -> str | None: def broker_command_refused(members, *, root: Path | str | None) -> str | None: """Why the broker command `members` may never be trusted, or None: it - cannot be read to the program it runs (`REASON_UNREADABLE_COMMAND`), it - gives a shell or an interpreter an inline script - (`REASON_INLINE_SCRIPT`), or, at a known `root`, it names a file inside + cannot be read to the program it runs (`REASON_UNREADABLE_COMMAND`; an + `env -S` string env would not split as a shell does is + `REASON_INLINE_SCRIPT`), it gives a shell or an interpreter an inline + script (`REASON_INLINE_SCRIPT`), or, at a known `root`, it names a file inside the served repository (`REASON_IN_REPOSITORY`). Asked where trust is recorded and wherever it is judged, and of the console intake's broker.""" - if _unwrapped(members).unreadable is not None: - return REASON_UNREADABLE_COMMAND + unwrapped = _unwrapped(members) + if unwrapped.unreadable is not None: + return unwrapped.unreadable_because if inline_script(members, root=root) is not None: return REASON_INLINE_SCRIPT if root is not None and in_repository_argv(members, diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 00b700bc..f998decf 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -4326,6 +4326,13 @@ def test_A5_an_intake_broker_the_rules_refuse_is_not_offered(served): "env-split-string-bundled": ["env", "-iS", "sh -c x"], "env-split-string-bundled-attached": ["env", "-iSsh -c x"], "env-unset-bundled": ["env", "-0u", "NAME", "sh", "-c", "x"], + # A string env would not split as a shell does is refused as an inline + # script (F16.1 as T007 batch P amends it; 5985046107, C1): a backslash + # (env reads `\\_` as a space), a `$`, a `#`, or a quote left open. + "split-string-escape": ["env", "-S", "sh\\_-c\\_id"], + "split-string-variable": ["env", "-S", "$BROKER"], + "split-string-comment": ["env", "-S", "sh #", "-c", "x"], + "split-string-that-does-not-split": ["env", "-S", "a 'b"], "flock-command": ["flock", "/tmp/opendox-lock", "-c", "x"], "deno-eval": ["deno", "eval", "x"], } @@ -4664,16 +4671,14 @@ def _unreadable_command(served, monkeypatch, case): f"{os.environ.get('PATH', '')}") if case == "launchers-nested-past-the-depth": return ["env"] * 33 + [program.name] - if case == "split-string-that-does-not-split": - return ["env", "-S", "a 'b"] return UNREADABLE_COMMANDS[case] #: The holder's ruling, openxFactory#656 comment 5985046107, C1: what a #: launcher's getopt would read otherwise than its words say is refused #: FAIL-CLOSED: an ambiguous or an unknown option, a flag given a value, a -#: value that is missing, an option after which what runs cannot be judged, -#: and an `env -S` string env would not split as a shell does. +#: value that is missing, and an option after which what runs cannot be +#: judged. UNREADABLE_COMMANDS = { "ambiguous-d": ["env", "--d", "sh"], "ambiguous-i": ["env", "--i", "sh"], @@ -4682,9 +4687,6 @@ def _unreadable_command(served, monkeypatch, case): "a-flag-given-a-value": ["env", "--debug=1", "/bin/true"], "a-value-missing": ["env", "-C"], "a-long-value-missing": ["env", "--chdir"], - "split-string-escape": ["env", "-S", "sh\\_-c\\_id"], - "split-string-variable": ["env", "-S", "sh -c $OPREF_SCRIPT"], - "split-string-comment": ["env", "-S", "sh #", "-c", "x"], "env-argv0": ["env", "--argv0=sh", "/opt/opendox-test/multi-call", "-c", "x"], "sudo-chroot": ["sudo", "--chroot=/srv", "/bin/true"], @@ -4693,14 +4695,13 @@ def _unreadable_command(served, monkeypatch, case): @pytest.mark.parametrize("case", ["launchers-nested-past-the-depth", - "split-string-that-does-not-split", *sorted(UNREADABLE_COMMANDS)]) def test_A2_a_command_that_cannot_be_read_is_refused_by_name( served, capsys, monkeypatch, case): """A broker command that cannot be read to the program it runs (Copilot at openDox-code#86, r4179366319): launchers nested past what is unwrapped, where the program the last one starts was never judged, or - an `env -S` string that does not split as a shell would. What it runs + a launcher option its getopt would read otherwise (C1). What it runs cannot be judged, so it is refused BY NAME where trust is recorded and where it is checked, with the inline-script remedy.""" trust_mod = _trust_mod() @@ -4746,12 +4747,13 @@ def test_A2_launchers_within_the_depth_are_unwrapped_whole(served): def test_C1_a_split_string_env_would_expand_is_refused(): """`${VAR}`, which env -S expands and a shell's split does not: refused - as unreadable. A binding cannot declare it at all (a placeholder outside - its vocabulary), so the console intake's broker is where it is asked.""" + as an inline script. A binding cannot declare it at all (a placeholder + outside its vocabulary), so the console intake's broker is where it is + asked.""" trust_mod = _trust_mod() assert trust_mod.broker_command_refused( ["env", "-S", "sh -c ${OPREF_SCRIPT}"], root=None) == ( - trust_mod.REASON_UNREADABLE_COMMAND) + trust_mod.REASON_INLINE_SCRIPT) def test_C1_a_launchers_own_options_are_read_as_its_getopt_reads_them( @@ -5048,3 +5050,449 @@ def test_A2_a_broker_never_inherits_a_working_directory_variable( {"PATH": "/usr/bin", "PWD": "/x", "OLDPWD": "/y"}) == { "PATH": "/usr/bin"} + + +# =========================================================================== +# 9. F16.1 AS T007 BATCH P AMENDS IT (openxFactory#1230; the holder's +# rulings on openxFactory#656, comments 5982436447 item 2, 5983805990, +# 5984069416, 5985046107 and 5985553609). One case per command: each is +# refused BY NAME by `add`, `edit`, `trust` and `set-credential`, and +# before any spawn, with no marker file written. +# =========================================================================== + +_MARKING_PYTHON = "import sys\nopen({marker!r}, 'a').write('ran\\n')\n" + + +def _batch_p_world(served, monkeypatch) -> dict: + """The served repository's own programs and links, and the case's own + scratch directory outside it (`outside`), each of which writes the + marker file when it runs.""" + root = served.repo + marking = _MARKING_PYTHON.format(marker=str(served.marker)) + tools = root / "tools" + tools.mkdir() + for name in ("broker.py", "sitecustomize.py"): + (tools / name).write_text(f"#!{sys.executable}\n" + marking, + encoding="utf-8") + (tools / "broker.sh").write_text( + f"#!/bin/sh\necho ran >> {shlex_quote(str(served.marker))}\n", + encoding="utf-8") + for name in ("broker.py", "broker.sh"): + os.chmod(tools / name, 0o755) + outside = served.tmp / "outside" + outside.mkdir() + broker = outside / "broker" + broker.write_text(f"#!{sys.executable}\n" + marking, encoding="utf-8") + os.chmod(broker, 0o755) + (outside / "broker.py").write_text(marking, encoding="utf-8") + (outside / "broker.pl").write_text( + f"open(my $m, '>>', {json.dumps(str(served.marker))}); " + "print $m \"ran\\n\";\n", encoding="utf-8") + (outside / "alias").symlink_to(tools, target_is_directory=True) + (outside / "link").symlink_to(tools / "broker.py") + (outside / "link.py").symlink_to(tools / "broker.py") + real_out = served.tmp / "real-out" + real_out.mkdir() + (tools / "out").symlink_to(real_out, target_is_directory=True) + (tools / "out-broker").symlink_to(broker) + (tools / "out-broker.py").symlink_to(outside / "broker.py") + bin_ = root / "bin" + bin_.mkdir() + (bin_ / "opref-broker").write_text(f"#!{sys.executable}\n" + marking, + encoding="utf-8") + os.chmod(bin_ / "opref-broker", 0o755) + monkeypatch.setenv("PATH", f"{bin_}{os.pathsep}" + f"{os.environ.get('PATH', '')}") + monkeypatch.chdir(root) # opendox started at the served root + return {"root": str(root), "outside": str(outside)} + + +def shlex_quote(text: str) -> str: + import shlex + + return shlex.quote(text) + + +P_IN_REPO, P_INLINE, P_UNREADABLE = "in-repo", "inline", "unreadable" + +#: Each case of the amended F16.1, as `(argv, reason)`; `{root}` is the +#: served root and `{outside}` the case's scratch directory outside it. +BATCH_P = { + # an in-repository program or script, absolute and relative to the root + "program-absolute": (["{root}/tools/broker.py"], P_IN_REPO), + "program-relative": (["tools/broker.py"], P_IN_REPO), + "python-script-absolute": (["python3", "{root}/tools/broker.py"], + P_IN_REPO), + "python-script-relative": (["python3", "tools/broker.py"], P_IN_REPO), + "sh-script-absolute": (["sh", "{root}/tools/broker.sh"], P_IN_REPO), + "sh-script-relative": (["sh", "tools/broker.sh"], P_IN_REPO), + # an inline script, and one reached through each launcher + "sh-c": (["sh", "-c", "exec ./tools/broker.py"], P_INLINE), + "python-c": (["python3", "-c", "import runpy"], P_INLINE), + "env-i": (["env", "-i", "python3", "-c", "import runpy"], P_INLINE), + "nice": (["nice", "-n", "5", "python3", "-c", "import runpy"], + P_INLINE), + "nohup": (["nohup", "python3", "-c", "import runpy"], P_INLINE), + "timeout": (["timeout", "-s", "KILL", "5", "python3", "-c", + "import runpy"], P_INLINE), + "stdbuf": (["stdbuf", "-oL", "python3", "-c", "import runpy"], + P_INLINE), + "setsid": (["setsid", "-w", "python3", "-c", "import runpy"], + P_INLINE), + "xargs": (["xargs", "-n", "1", "python3", "-c", "import runpy"], + P_INLINE), + "nested": (["env", "nice", "-n", "5", "timeout", "5", "python3", "-c", + "import runpy"], P_INLINE), + "env-split-string": (["env", "-S", "python3 -c 'import runpy'"], + P_INLINE), + # an alias, judged as named and as it resolves + "link-outside-to-inside": (["{outside}/link"], P_IN_REPO), + "link-outside-to-inside-as-script": (["python3", "{outside}/link.py"], + P_IN_REPO), + "bare-name-on-a-path-entry-under-the-root": (["opref-broker"], P_IN_REPO), + "link-inside-to-outside": (["{root}/tools/out-broker"], P_IN_REPO), + "link-inside-to-outside-as-script": ( + ["python3", "{root}/tools/out-broker.py"], P_IN_REPO), + # a launcher's own assignment, and its working-directory option + "env-module-path-relative": ( + ["env", "PYTHONPATH=tools", "python3", "{outside}/broker.py"], + P_IN_REPO), + "env-module-path-through-an-outside-alias": ( + ["env", "PYTHONPATH={outside}/alias", "python3", + "{outside}/broker.py"], P_IN_REPO), + "env-scalar-through-an-outside-alias": ( + ["env", "OPENDOX_TEST_HOME={outside}/alias", "python3", + "{outside}/broker.py"], P_IN_REPO), + "env-module-path-through-an-inside-link": ( + ["env", "PYTHONPATH=tools/out", "python3", "{outside}/broker.py"], + P_IN_REPO), + "env-scalar-through-an-inside-link": ( + ["env", "OPENDOX_TEST_HOME=tools/out", "python3", + "{outside}/broker.py"], P_IN_REPO), + "env-chdir-root": (["env", "--chdir={root}", "python3", + "{outside}/broker.py"], P_IN_REPO), + "env-C-root": (["env", "-C", "{root}", "python3", "{outside}/broker.py"], + P_IN_REPO), + "env-chdir-outside-alias": (["env", "--chdir={outside}/alias", "python3", + "{outside}/broker.py"], P_IN_REPO), + "env-chdir-inside-link": (["env", "--chdir=tools/out", "python3", + "{outside}/broker.py"], P_IN_REPO), + # 5985046107, C1 and C4: a path an option carries, an unreadable split + # string, and a launcher option that cannot be read + "perl-attached-library": (["perl", "-I{root}/lib", "{outside}/broker.pl"], + P_IN_REPO), + "xargs-argument-file": (["xargs", "-a", "{root}/args", + "{outside}/broker"], P_IN_REPO), + "env-chdir-abbreviated": (["env", "--chd={root}", "{outside}/broker"], + P_IN_REPO), + "env-split-string-escape": (["env", "-S", "sh\\_-c\\_id"], P_INLINE), + "option-value": (["{outside}/broker", "--config={root}/conf"], P_IN_REPO), + "output-path": (["{outside}/broker", "-o{root}/out"], P_IN_REPO), + "env-ambiguous-i": (["env", "--i", "{outside}/broker"], P_UNREADABLE), + "env-unknown-option": (["env", "--no-such-option", "{outside}/broker"], + P_UNREADABLE), + "env-ambiguous-d": (["env", "--d", "{outside}/broker"], P_UNREADABLE), + "env-bundled-split-string": (["env", "-iS", "python3 -c 'import runpy'"], + P_INLINE), + "timeout-abbreviated": (["timeout", "--sig", "KILL", "5", "python3", + "-c", "import runpy"], P_INLINE), + "stdbuf-abbreviated": (["stdbuf", "--out=L", "python3", "-c", + "import runpy"], P_INLINE), + "env-split-string-variable": (["env", "-S", "$BROKER"], P_INLINE), +} + + +@pytest.mark.parametrize("case", sorted(BATCH_P)) +def test_F16_1_batch_p_each_command_is_refused_by_name_everywhere( + served, capsys, monkeypatch, case): + """F16.1 as T007 batch P amends it: each command is refused BY NAME by + `add` (nothing written), `edit` (the document as it was), `trust` + (nothing recorded) and `set-credential` (no credential read), each + naming its remedy; with a trust recorded for it before the rule, it + reads untrusted, the catalog lists it `available: false`, a turn naming + it is refused by name before any process is spawned, and so is a + verdict that admits it, at the gate beneath; no marker file exists.""" + trust_mod = _trust_mod() + where = _batch_p_world(served, monkeypatch) + template, kind = BATCH_P[case] + argv = [member.replace("{root}", where["root"]).replace( + "{outside}", where["outside"]) for member in template] + reason, remedy = { + P_IN_REPO: (trust_mod.REASON_IN_REPOSITORY, + trust_mod.REMEDY_IN_REPOSITORY), + P_INLINE: (trust_mod.REASON_INLINE_SCRIPT, + trust_mod.REMEDY_INLINE_SCRIPT), + P_UNREADABLE: (trust_mod.REASON_UNREADABLE_COMMAND, + trust_mod.REMEDY_INLINE_SCRIPT)}[kind] + document = binding_mod.bindings_path(served.repo) + + def refused_by_name(rc: int) -> None: + assert rc == 1, argv + err = capsys.readouterr().err + assert reason in err and remedy in err, err + _no_trust_command_in(err) + + # add: refused, and nothing is written + adding = served.add_argv("broker") + adding = adding[:adding.index("--") + 1] + argv + refused_by_name(_cli(*adding)) + assert not document.exists() + # edit: a binding declared outside the repository, edited to this + # command, is refused, and the document is as it was + served.hand_write(served.record("broker")) + held = document.read_bytes() + editing = list(adding) + editing[1] = "edit" + refused_by_name(_cli(*editing)) + assert document.read_bytes() == held + # trust: declared by hand, as a clone delivers it; nothing is recorded + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + refused_by_name(_cli("model-binding", "trust", "--repo-root", + str(served.repo), BINDING_ID)) + assert not _held_entries(served) + + # set-credential: refused, and the credential is never read + class _MustNotBeRead: + def read(self, *_args): + raise AssertionError("set-credential read a credential for a " + "binding it may not hand one to") + + args = cli_mod.build_parser().parse_args([ + "model-binding", "set-credential", "--repo-root", str(served.repo), + "--id", BINDING_ID]) + rc = cli_mod.cmd_model_binding_set_credential(args, + source=_MustNotBeRead()) + assert rc == 1 + assert reason in capsys.readouterr().err + # a trust recorded before the rule admits nothing: no process spawns + served.trust.record(binding, root=served.repo) + verdict = trust_mod.verdict_for(binding, root=served.repo) + assert not verdict.trusted and verdict.reason == reason, verdict + port = served.port() + assert isinstance(port, trust_mod.UntrustedBindingPort), port + assert [entry.available for entry in port.catalog().entries] == [False] + with pytest.raises(trust_mod.BindingUntrusted) as turned: + port.dispatch(_Envelope()) + assert remedy in str(turned.value) + admitted = trust_mod.TrustVerdict.trusted_for( + binding, root=served.repo, basis=trust_mod.BASIS_HOST) + with pytest.raises(binding_mod.BindingRefused): + provider_mod.mint(binding, trust=admitted) + served.nothing_was_touched() + + +def _names_a_place_inside(value: str, root: Path) -> bool: + """Whether any entry of `value` names a place inside `root`, read from + the file system's root and from the served root, as written and with + its links followed: the test's own reading, not the rule's. A bare word + (`C.UTF-8`) is a path only where a file of that name is there.""" + served_root = root.resolve() + for part in value.split(os.pathsep): + if not part: + continue + for base in (os.sep, str(root)): + if os.sep not in part and not os.path.lexists( + os.path.join(base, part)): + continue + path = os.path.join(base, part) + for spelled in (Path(os.path.normpath(path)), + Path(os.path.realpath(path))): + if spelled == served_root or served_root in spelled.parents: + return True + if spelled == root or root in spelled.parents: + return True + return False + + +def test_F16_1_batch_p_an_outside_broker_runs_with_nothing_inside_the_root( + served, capsys, monkeypatch): + """F16.1 as T007 batch P amends it: a broker outside the served root, + once trusted, runs, and does so in a working directory outside the root + (opendox itself started AT the root), with neither `PWD` nor `OLDPWD`, + no variable whose value is a path inside the root, and no entry inside + the root in a path list: `PATH`, `PYTHONPATH` and `NODE_PATH` each + carry an entry inside it, a scalar variable names a directory inside + it, and each is tried again through an alias outside the root that + leads in, and through a link inside the root that leads out.""" + trust_mod = _trust_mod() + root = served.repo + tools = root / "tools" + tools.mkdir() + outside = served.tmp / "outside" + outside.mkdir() + (outside / "alias").symlink_to(tools, target_is_directory=True) + real_out = served.tmp / "real-out" + real_out.mkdir() + (tools / "out").symlink_to(real_out, target_is_directory=True) + seen = served.tmp / "broker-saw.json" + recording = served.tmp / "recording-broker.py" + recording.write_text( + "import json, os\n" + f"open({str(seen)!r}, 'w').write(json.dumps(" + "{'cwd': os.getcwd(), 'env': dict(os.environ)}))\n" + + served.broker.read_text(encoding="utf-8"), encoding="utf-8") + from opendox import doxbench_bridge + + # every variable below reaches the broker unless the rule drops it + monkeypatch.setattr(doxbench_bridge, "INHERITED_ENVIRONMENT", + (*doxbench_bridge.INHERITED_ENVIRONMENT, "PWD", + "OLDPWD", "PYTHONPATH", "NODE_PATH", + "OPENDOX_TEST_HOME", "OPENDOX_TEST_ALIAS", + "OPENDOX_TEST_OUT")) + inside_entries = [str(tools), str(outside / "alias"), str(tools / "out"), + "tools"] + served.environ["PATH"] = os.pathsep.join( + [*inside_entries, served.environ.get("PATH", "")]) + served.environ["PYTHONPATH"] = os.pathsep.join(inside_entries) + served.environ["NODE_PATH"] = os.pathsep.join(inside_entries) + served.environ["OPENDOX_TEST_HOME"] = str(tools) + served.environ["OPENDOX_TEST_ALIAS"] = str(outside / "alias") + served.environ["OPENDOX_TEST_OUT"] = str(tools / "out") + served.environ["PWD"] = str(root) + served.environ["OLDPWD"] = str(root) + monkeypatch.chdir(root) + adding = served.add_argv("broker") + adding[-1] = str(recording) + assert _cli(*adding) == 0 + capsys.readouterr() + port = served.port() + assert isinstance(port, provider_mod.BrokeredProviderPort) + with contextlib.suppress(Exception): + port.dispatch(_Envelope()) + saw = json.loads(seen.read_text(encoding="utf-8")) + assert Path(saw["cwd"]) == Path(provider_mod.BROKER_WORKING_DIRECTORY) + assert root.resolve() not in Path(saw["cwd"]).resolve().parents + environment = saw["env"] + assert "PWD" not in environment and "OLDPWD" not in environment + for name, value in environment.items(): + assert not _names_a_place_inside(value, root), (name, value) + for name in ("PYTHONPATH", "NODE_PATH", "OPENDOX_TEST_HOME", + "OPENDOX_TEST_ALIAS", "OPENDOX_TEST_OUT"): + assert name not in environment, (name, environment.get(name)) + # what the console's PATH held outside the root is kept + assert environment["PATH"].split(os.pathsep)[-1:] == ( + served.environ["PATH"].split(os.pathsep)[-1:]) + assert trust_mod.verdict_for(served.declared(), root=root).trusted + + +def test_F16_1_batch_p_a_programs_bare_name_is_found_where_it_runs( + served, monkeypatch): + """The other side of judging a relative path from the served root as + well: the PROGRAM's bare name is found on the search path, as the child + finds it, so a file of that name at the root does not make a program + found outside the repository one inside it. A bare ARGUMENT that names + a file at the root is judged, fail-closed.""" + trust_mod = _trust_mod() + tool = served.tmp / "bin" / "opref-tool" + tool.parent.mkdir() + tool.write_text(f"#!{sys.executable}\n", encoding="utf-8") + os.chmod(tool, 0o755) + (served.repo / "opref-tool").write_text("", encoding="utf-8") + (served.repo / "opref-config").write_text("", encoding="utf-8") + monkeypatch.setenv("PATH", f"{tool.parent}{os.pathsep}" + f"{os.environ.get('PATH', '')}") + assert trust_mod.broker_command_refused( + ["opref-tool", "show"], root=served.repo) is None + assert trust_mod.broker_command_refused( + ["opref-tool", "opref-config"], root=served.repo) == ( + trust_mod.REASON_IN_REPOSITORY) + + +def test_F16_1_batch_p_a_relative_word_is_judged_from_the_root_where_it_names_a_place_there( + served): + """Judging a relative path from the served root as well reads it as the + repository's author wrote it: where its first name is one the root + holds, or `..`, it is judged there, and refused where it reaches inside + (`../r/tools/broker.py` from the root is the repository's own file). + A word whose first name the root does not hold is judged where the + broker runs alone: the rest of an option cluster, a format, a URL, a + sibling of the root.""" + trust_mod = _trust_mod() + (served.repo / "tools").mkdir() + for argv in (["perl", "-wI/usr/lib/perl5", "/opt/opendox-test/x.pl"], + ["date", "--format=%Y/%m"], + ["python3", "../sibling/broker.py"], + ["python3", "conf/broker.yaml"], + ["opref-tool", "-o/tmp/opendox-test-out"], + ["opref-tool", "--issuer=https://auth.example/v1"]): + assert trust_mod.broker_command_refused( + argv, root=served.repo) is None, argv + for argv in (["python3", "tools/broker.py"], + ["python3", f"../{served.repo.name}/tools/broker.py"], + ["python3", "./tools/broker.py"]): + assert trust_mod.broker_command_refused( + argv, root=served.repo) == trust_mod.REASON_IN_REPOSITORY, argv + + +def test_F16_1_batch_p_an_environment_value_is_judged_entry_by_entry(served): + """`names_a_path_inside` reads a value as a path list, so an entry + inside the served root is found wherever it stands in the list.""" + trust_mod = _trust_mod() + (served.repo / "tools").mkdir() + inside = str(served.repo / "tools") + for value in (inside, f"/usr/bin{os.pathsep}{inside}", + f"/usr/bin{os.pathsep}/bin{os.pathsep}tools"): + assert trust_mod.names_a_path_inside(value, root=served.repo), value + for value in ("/usr/bin", f"/usr/bin{os.pathsep}/bin", "C.UTF-8", ""): + assert not trust_mod.names_a_path_inside(value, + root=served.repo), value + + +@pytest.mark.parametrize("alias", ["program-link", "script-link", + "path-entry"]) +def test_F16_1_batch_p_a_trust_recorded_outside_admits_nothing_once_it_leads_in( + served, capsys, monkeypatch, alias): + """F16.1 as T007 batch P amends it: a trust recorded, by `trust`, while + the binding's paths resolved outside the served root does not admit it + once a link, or the `PATH` entry, leads inside: it reads untrusted, the + catalog lists it `available: false`, a turn is refused by name before + any spawn, and no marker file exists.""" + trust_mod = _trust_mod() + marking = _MARKING_PYTHON.format(marker=str(served.marker)) + outside = served.tmp / "outside" + outside.mkdir() + real = outside / "broker" + real.write_text(f"#!{sys.executable}\n" + marking, encoding="utf-8") + os.chmod(real, 0o755) + tools = served.repo / "tools" + tools.mkdir() + inside = tools / "broker" + inside.write_text(f"#!{sys.executable}\n" + marking, encoding="utf-8") + os.chmod(inside, 0o755) + link = outside / "link" + if alias == "path-entry": + first = served.tmp / "first" + first.mkdir() + (first / "opref-broker").symlink_to(real) + monkeypatch.setenv("PATH", f"{first}{os.pathsep}" + f"{os.environ.get('PATH', '')}") + argv = ["opref-broker"] + else: + link.symlink_to(real) + argv = ([str(link)] if alias == "program-link" + else ["python3", str(link)]) + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 0 + capsys.readouterr() + assert trust_mod.verdict_for(binding, root=served.repo).trusted + # it now leads inside: the link retargeted, or a PATH entry under the + # root put first + if alias == "path-entry": + (tools / "opref-broker").symlink_to(inside) + monkeypatch.setenv("PATH", f"{tools}{os.pathsep}" + f"{os.environ['PATH']}") + else: + link.unlink() + link.symlink_to(inside) + verdict = trust_mod.verdict_for(binding, root=served.repo) + assert not verdict.trusted + assert verdict.reason == trust_mod.REASON_IN_REPOSITORY + port = served.port() + assert isinstance(port, trust_mod.UntrustedBindingPort), port + assert [entry.available for entry in port.catalog().entries] == [False] + with pytest.raises(trust_mod.BindingUntrusted): + port.dispatch(_Envelope()) + served.nothing_was_touched() From c5cfbc4715bf1b3cdc09b94f65456436b7cd556b Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:26:19 +0000 Subject: [PATCH 07/16] T100 follow-on: Copilot's third review, under the holder's ruling 5986391296 (plan 034) Copilot's third review of openDox-code#86 (review 5409093070, at d3587910), all 6 threads, as the holder ruled on openxFactory#656 comment 5986391296. 1. A new settings document is written whole beside its place and published with a hard link (write_settings_document), so no reader sees part of one and one made meanwhile is never overwritten: that write is refused (File exists). An edit keeps its atomic replace (r4180041167). 2. Links are expanded one path component at a time, as the kernel expands them (_traversed), so a link inside the repository anywhere in a chain is judged (outside -> repository -> outside). A path whose links pass the kernel's own bound (_LINK_HOPS, 40), or loop, is refused as unreadable (REASON_UNREADABLE_COMMAND) (r4180041184). 3. -m is judged with every import suffix (importlib.machinery's: source, bytecode, extension modules) and as a package directory (r4180041203), and read by Python's own option rules: -m X, -mX, -BmX, a value letter (-W, -X) or -c ending the cluster (_module_operands; r4180041213). 4. A closed list of path-list variables (PATH, PYTHONPATH, NODE_PATH, LD_LIBRARY_PATH, PERL5LIB, PERLLIB, RUBYLIB, CLASSPATH, GEM_PATH, MANPATH; doxbench_provider.PATH_LIST_VARIABLES) loses each entry inside the served repository; any other variable is never edited, and is dropped whole where any part of it names a path inside (r4180041233). 5. A failed add or edit skips its undoing where the document now declares exactly the form recorded (ruling (a); r4180041219). The residual race is the accepted release-1 limit: it fails closed. 6. openDox withdraws only what a policy recorded: a policy's record() that answers something falsy recorded nothing, its verdict() is asked instead (doxbench_trust.recording_for, TrustRecording), and a failed write under it says truthfully that nothing changed. A truthy answer is a record; under a host policy, withdrawing it is REASON_NO_WITHDRAWAL. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/cli_model_binding.py | 51 +++- src/opendox/doxbench_binding.py | 27 ++- src/opendox/doxbench_provider.py | 24 +- src/opendox/doxbench_trust.py | 213 ++++++++++++++--- tests/test_model_binding_trust.py | 376 +++++++++++++++++++++++++++++- 5 files changed, 628 insertions(+), 63 deletions(-) diff --git a/src/opendox/cli_model_binding.py b/src/opendox/cli_model_binding.py index 8c90a46d..6b203a41 100644 --- a/src/opendox/cli_model_binding.py +++ b/src/opendox/cli_model_binding.py @@ -58,17 +58,18 @@ def _repo_root(args: argparse.Namespace) -> Path: def _record_trust(binding: "binding_mod.ModelProviderBinding", - args: argparse.Namespace): + args: argparse.Namespace) -> "trust_mod.TrustRecording": """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 + a `BindingRefused` (`doxbench_trust.recording_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)) + written (T007 batch M). Returns the verdict, and whether the policy + recorded anything (ruling 5986391296).""" + return trust_mod.recording_for(binding, root=_repo_root(args)) def _named_document(store: "binding_mod.BindingStore", @@ -95,7 +96,21 @@ def _cannot_write(store: "binding_mod.BindingStore", error: OSError) -> str: "which declares that form") +def _declares(store: "binding_mod.BindingStore", binding) -> bool: + """Whether the document `store` reads now declares exactly `binding`'s + form, by its digest. A document that cannot be read declares nothing + this act can rely on.""" + try: + declared = store.get(binding.id) + except (binding_mod.BindingRefused, OSError): + return False + return declared is not None and trust_mod.binding_digest( + declared) == trust_mod.binding_digest(binding) + + def _undone(refusal: "binding_mod.BindingRefused", *, earlier, written, + recording: "trust_mod.TrustRecording", + store: "binding_mod.BindingStore", args: argparse.Namespace) -> "binding_mod.BindingRefused": """`refusal`, for a write that failed after trust was recorded for `written`, once that trust is taken back (the holder's ruling, @@ -103,7 +118,19 @@ def _undone(refusal: "binding_mod.BindingRefused", *, earlier, written, earlier form trusted again where it was trusted (`earlier`), and the new form's trust withdrawn where it was not (`earlier` None), so a failed write leaves no trust for a form no document declares. Where that - cannot be done, the refusal says so, and how to recover.""" + cannot be done, the refusal says so, and how to recover. + + Nothing is taken back where the policy recorded nothing + (`TrustRecording.recorded`), so the refusal's "nothing changed" is true + of trust too; nor where the document now declares exactly the form + recorded, as another `add` or `edit` of the same form wrote it (the + holder's ruling, openxFactory#656 comment 5986391296, (a); Copilot at + openDox-code#86, r4180041219). THE ACCEPTED LIMIT: two acts of the + same form can still interleave so that one's undoing runs before the + other's write lands; it fails closed, the declared binding reading + untrusted until it is trusted again.""" + if not recording.recorded or _declares(store, written): + return refusal try: trust_mod.restored_for(earlier, replacing=written, root=_repo_root(args)) @@ -293,13 +320,15 @@ def cmd_model_binding_add(args: argparse.Namespace) -> int: 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) + recording = _record_trust(binding, args) + verdict = recording.verdict try: store.add(binding) except (binding_mod.BindingRefused, OSError) as error: refusal = (binding_mod.BindingRefused(_cannot_write(store, error)) if isinstance(error, OSError) else error) raise _undone(refusal, earlier=None, written=binding, + recording=recording, store=store, args=args) from None except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) @@ -334,14 +363,16 @@ def cmd_model_binding_edit(args: argparse.Namespace) -> int: store.edit(binding) # refuses the unknown id, in its own words was_trusted = trust_mod.verdict_for( existing, root=_repo_root(args)).admits(existing) - verdict = _record_trust(binding, args) + recording = _record_trust(binding, args) + verdict = recording.verdict try: store.edit(binding) except (binding_mod.BindingRefused, OSError) as error: refusal = (binding_mod.BindingRefused(_cannot_write(store, error)) if isinstance(error, OSError) else error) raise _undone(refusal, earlier=existing if was_trusted else None, - written=binding, args=args) from None + written=binding, recording=recording, store=store, + args=args) from None except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 @@ -427,7 +458,7 @@ def cmd_model_binding_set_credential(args: argparse.Namespace, *, # 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) + verdict = _record_trust(rewritten, args).verdict 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) @@ -515,7 +546,7 @@ def cmd_model_binding_trust(args: argparse.Namespace) -> int: for line in _trust_disclosure(binding, store, root): print(line) try: - verdict = _record_trust(binding, args) + verdict = _record_trust(binding, args).verdict except binding_mod.BindingRefused as exc: print(str(exc), file=sys.stderr) return 1 diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index 4b532af2..bdb3683d 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -91,6 +91,7 @@ import dataclasses import json import re +import secrets import stat import tempfile from collections.abc import Iterable, Mapping @@ -585,20 +586,26 @@ def write_settings_document(path: Path, text: str) -> None: full disk, an I/O error) leaves the document as it was, and the new file is removed, so a refusal can say nothing in it changed. A document this user cannot write is refused as it always was, by the system's own - error, rather than replaced. A document that does not exist yet is - written in place, and removed again if that write fails.""" + error, rather than replaced. + + A DOCUMENT THAT DOES NOT EXIST YET is written whole to a new file beside + it, created exclusively (so with the mode a new file takes here), and + then published with a hard link (`os.link`), which never replaces a + document made in the meantime (Copilot at openDox-code#86, r4180041167; + the holder's ruling, openxFactory#656 comment 5986391296). So no reader + sees part of one, a write that fails leaves none, and one made meanwhile + refuses this write (`FileExistsError`) rather than being overwritten.""" path.parent.mkdir(parents=True, exist_ok=True) if not path.exists(): + staged = path.parent / (f".{path.name}.{secrets.token_hex(8)}" + ".opendox-new") try: - with path.open("x", encoding="utf-8") as handle: + with staged.open("x", encoding="utf-8") as handle: _write_all(handle, text) - except FileExistsError: - pass # made meanwhile: replaced below instead - except BaseException: - path.unlink(missing_ok=True) - raise - else: - return + path.hardlink_to(staged) + finally: + staged.unlink(missing_ok=True) + return with path.open("a", encoding="utf-8"): pass # this user may write it, or PermissionError mode = stat.S_IMODE(path.stat().st_mode) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 7ada607e..7c7ef554 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -328,26 +328,40 @@ WORKING_DIRECTORY_VARIABLES: frozenset[str] = frozenset({"PWD", "OLDPWD"}) +#: The CLOSED list of variables a broker's environment reads as path lists +#: (the holder's ruling, openxFactory#656 comment 5986391296, on Copilot at +#: openDox-code#86, r4180041233): each loses every entry inside the served +#: repository, and keeps the rest. +PATH_LIST_VARIABLES: frozenset[str] = frozenset({ + "PATH", "PYTHONPATH", "NODE_PATH", "LD_LIBRARY_PATH", "PERL5LIB", + "PERLLIB", "RUBYLIB", "CLASSPATH", "GEM_PATH", "MANPATH"}) + + def broker_environment(base, *, root=None) -> dict: """A broker's whole environment: the harness child's allowlist (`doxbench_bridge.child_environment`), without the variables that name a working directory (`WORKING_DIRECTORY_VARIABLES`), whatever that allowlist comes to hold. Given the served `root`, it carries no path - inside it either (F16.1 as T007 batch P amends it): an entry of a path - list (`PATH`) that names one is dropped, and so is a variable whose - whole value does (`doxbench_trust.names_a_path_inside`), so nothing the - broker finds through its environment is a file a pull changes.""" + inside it either (F16.1 as T007 batch P amends it; ruling 5986391296): + a path-list variable (`PATH_LIST_VARIABLES`) loses each entry that names + one, and is dropped where none is left; any other variable is never + edited, and is dropped WHOLE where any `os.pathsep`-separated part of it + names one (`doxbench_trust.names_a_path_inside`), so a `HOME` or a + `TMPDIR` is kept as it is or not at all, never emptied or cut.""" environment: dict = {} for name, value in bridge_mod.child_environment(base).items(): if name in WORKING_DIRECTORY_VARIABLES: continue - if root is not None: + if root is not None and name in PATH_LIST_VARIABLES: kept = [part for part in value.split(os.pathsep) if not (part and trust_mod.names_a_path_inside( part, root=root))] if not any(kept) and value: continue value = os.pathsep.join(kept) + elif root is not None and trust_mod.names_a_path_inside(value, + root=root): + continue environment[name] = value return environment diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index afbe77fb..b8059de5 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -108,6 +108,7 @@ import dataclasses import errno import hashlib +import importlib.machinery import json import os import re @@ -143,6 +144,7 @@ "TrustPolicyAlreadyRegistered", "TrustPolicyNotRegistered", "TrustStoreRefused", + "TrustRecording", "TrustVerdict", "UNSERVABLE_TURN_MESSAGE", "UNTRUSTED_TURN_MESSAGE", @@ -172,6 +174,7 @@ "names_a_path_inside", "intake_admissible", "intake_refusal_reason", + "recording_for", "restored_for", "trust_can_repair", "current", @@ -324,16 +327,18 @@ #: openDox-code#86, r4179366319; the holder's ruling, openxFactory#656 #: comment 5985046107, C1): an option its launcher does not have, or has by #: more than one name; an option after which what runs cannot be judged -#: from its words (`env --argv0`, `sudo --chroot`, `sudo -i`); or launchers -#: nested past what is unwrapped. What it runs cannot be judged, so it is +#: from its words (`env --argv0`, `sudo --chroot`, `sudo -i`); launchers +#: nested past what is unwrapped; or a path whose symbolic links pass the +#: kernel's own bound, or loop (`_LINK_HOPS`; ruling 5986391296). What it runs cannot be judged, so it is #: refused FAIL-CLOSED, with the inline-script remedy. (An `env -S` string #: env would not split as a shell does is an inline script: #: `REASON_INLINE_SCRIPT`.) REASON_UNREADABLE_COMMAND = ( "its broker command cannot be read to the program it runs (a launcher " "option it does not have, or has by more than one name, an option after " - "which what runs cannot be judged, or launchers nested too deeply), so " - "what it runs cannot be judged") + "which what runs cannot be judged, launchers nested too deeply, or a " + "path whose links go deeper than the system follows), so what it runs " + "cannot be judged") #: What an operator is told to do about such a binding. REMEDY_INLINE_SCRIPT = ( @@ -977,22 +982,62 @@ def _resolved_path(path: str) -> str: return os.path.normpath(path) +#: How many symbolic links one path may pass through before it is refused as +#: unreadable: the kernel's own bound on a path's resolution (Linux's +#: MAXSYMLINKS), past which it would not be run either (the holder's ruling, +#: openxFactory#656 comment 5986391296). +_LINK_HOPS = 40 + + +class _TooManyLinks(Exception): + """A path whose links pass `_LINK_HOPS`, or loop: what it names cannot + be judged, so the command is refused as unreadable.""" + + def __init__(self, member: str): + super().__init__(member) + self.member = member + + def _traversed(path: str) -> list[Path]: """Every name the system looks up on the way to the absolute `path`, - each in the directory it is looked up in, that directory resolved - (links followed, `..` taken as the system takes it), and last `path` - itself, resolved (Copilot at openDox-code#86, r4179241566). A link - inside the repository is a name the repository controls, which a pull - can point elsewhere, whether it points inside or out; a link outside it - that points in reaches the repository's file.""" + each in the directory it is looked up in, and last what `path` comes to + (Copilot at openDox-code#86, r4179241566). A link is expanded ONE PATH + COMPONENT AT A TIME, as the kernel expands it, its target's own names + looked up in turn, relative to the link's directory or from the root, + and `..` taken from where the walk stands (r4179241184; the holder's + ruling, openxFactory#656 comment 5986391296): so every name a chain of + links passes through is judged, a link inside the repository anywhere + in it included (`/outside/entry -> /repo/selected -> /outside/a`). A + link inside the repository is a name the repository controls, which a + pull can point elsewhere, whether it points inside or out; a link + outside it that points in reaches the repository's file. A path whose + links pass `_LINK_HOPS` raises `_TooManyLinks`.""" names: list[Path] = [] real = Path(os.sep) - for part in Path(path).parts[1:]: + pending = list(Path(path).parts[1:]) + hops = 0 + while pending: + part = pending.pop(0) + if part == os.curdir: + continue if part == os.pardir: real = real.parent continue - names.append(real / part) - real = Path(_resolved_path(str(real / part))) + name = real / part + names.append(name) + try: + target = os.readlink(name) + except (OSError, ValueError): + real = name # no link: looked up as it is named + continue + hops += 1 + if hops > _LINK_HOPS: + raise _TooManyLinks(path) + parts = list(Path(target).parts) + if os.path.isabs(target): + real = Path(os.sep) + parts = parts[1:] + pending[:0] = parts names.append(real) return names @@ -1036,16 +1081,59 @@ def _candidates(member: str, *, option: bool) -> list[str]: return [candidate for candidate in found if candidate] +#: Every suffix a module is imported from: sources, bytecode (a sourceless +#: `.pyc`) and extension modules (Copilot at openDox-code#86, r4180041203; +#: the holder's ruling, openxFactory#656 comment 5986391296). +_MODULE_SUFFIXES: tuple[str, ...] = tuple(importlib.machinery.all_suffixes()) + + def _module_paths(name: str, *, context: _Context) -> list[str]: """The files a module named after `-m` could be imported from: its whole dotted name as a path under the context's working directory, - which `python -m` imports from first, as a package (`a/b/c`) and as a - module (`a/b/c.py`), whether or not a file is there yet (Copilot at - openDox-code#86, r4179241555). A namespace package needs no - `__init__`, so a dotted name can reach any directory under the working - directory; every package on the way is a name `_traversed` judges.""" + which `python -m` imports from first, as a package directory (`a/b/c`) + and as a module with every import suffix (`a/b/c.py`, `a/b/c.pyc`, + `a/b/c..so`; `_MODULE_SUFFIXES`), whether or not a file is there + yet (Copilot at openDox-code#86, r4179241555, r4180041203). A namespace + package needs no `__init__`, so a dotted name can reach any directory + under the working directory; every package on the way is a name + `_traversed` judges.""" base = os.path.join(context.cwd, *name.split(".")) - return [base, base + ".py"] + return [base, *(base + suffix for suffix in _MODULE_SUFFIXES)] + + +#: Python's own short options that take a value (`-W arg`, `-X opt`), and +#: the one that ends its options with an inline script (`-c`). +_PYTHON_VALUE_LETTERS = "WXc" + + +def _module_operands(members) -> dict[int, str]: + """Each module a command names after `-m`, by the index of the member + that names it, read by Python's own option rules (Copilot at + openDox-code#86, r4180041213; the holder's ruling, openxFactory#656 + comment 5986391296): `-m X`, `-mX` and a cluster `-BmX`, read letter by + letter up to `m`, whose module is the rest of the cluster or else the + next member; a letter that takes a value (`-W`, `-X`) or `-c` ends the + cluster. Asked of every option member before `--`, whatever the + program, FAIL-CLOSED.""" + modules: dict[int, str] = {} + for index, member in enumerate(members): + if not index: + continue + if member == "--": + break + if not member.startswith("-") or member.startswith("--"): + continue + for at in range(1, len(member)): + letter = member[at] + if letter == "m": + if member[at + 1:]: + modules[index] = member[at + 1:] + elif index + 1 < len(members): + modules[index + 1] = members[index + 1] + break + if letter in _PYTHON_VALUE_LETTERS: + break + return modules def in_repository_program(binding, *, root: Path | str) -> str | None: @@ -1095,7 +1183,9 @@ def _from_the_root(candidate: str, *, program: bool, served: Path) -> bool: def in_repository_argv(members, *, root: Path | str) -> str | None: """The member of a broker command `members` that names a file inside the served repository, or None: `in_repository_program`'s rule, for a - command no binding carries yet (the console intake's broker). + command no binding carries yet (the console intake's broker). A member + whose links cannot be followed to an end (`_LINK_HOPS`) is returned as + well, FAIL-CLOSED; `broker_command_refused` refuses it as unreadable. It is judged as it runs (`_Context`; Copilot at openDox-code#86, r4179241532, r4179366288): from `BROKER_WORKING_DIRECTORY`, on the @@ -1116,6 +1206,15 @@ def in_repository_argv(members, *, root: Path | str) -> str | None: command of its own, in the context the launchers left it. A file a launcher writes inside the repository is refused too, an accepted strictness.""" + try: + return _in_repository_member(members, root=root) + except _TooManyLinks as deep: + return deep.member + + +def _in_repository_member(members, *, root: Path | str) -> str | None: + """`in_repository_argv`'s work. Raises `_TooManyLinks`, naming the + member, for a member whose links pass `_LINK_HOPS`.""" served = Path(resolved_root(root)) def inside(found: list[str]) -> bool: @@ -1146,16 +1245,23 @@ def named(member: str, *, option: bool, first: bool, context=from_the_root) return found + def judged(member: str, found: list[str]) -> bool: + try: + return inside(found) + except _TooManyLinks: + raise _TooManyLinks(member) from None + unwrapped = _unwrapped(members) for launcher, context in unwrapped.launchers: - if inside(_located(launcher, first=True, context=context)): + if judged(launcher, _located(launcher, first=True, context=context)): return launcher for value, context in unwrapped.values: - if inside(named(value, option=value.startswith("-"), first=False, - context=context)): + if judged(value, named(value, option=value.startswith("-"), + first=False, context=context)): return value members = unwrapped.command context = unwrapped.context + modules = _module_operands(members) positional = False for index, member in enumerate(members): if index and member == "--" and not positional: @@ -1163,19 +1269,20 @@ def named(member: str, *, option: bool, first: bool, continue found = named(member, option=bool(index) and member.startswith("-") and not positional, first=index == 0, context=context) - if index and members[index - 1] == "-m" and not positional: - found += _module_paths(member, context=context) - if inside(found): + if index in modules and not positional: + found += _module_paths(modules[index], context=context) + if judged(member, found): return member return None def names_a_path_inside(value: str, *, root: Path | str) -> bool: - """Whether `value`, or any entry of it as a path list, names a path - inside the served repository at `root`, by `in_repository_argv`'s rule - for an option's value: from `BROKER_WORKING_DIRECTORY` and from the + """Whether `value`, or any `os.pathsep`-separated part of it, names a + path inside the served repository at `root`, by `in_repository_argv`'s + rule for an option's value: from `BROKER_WORKING_DIRECTORY` and from the served root, every name on the way, links followed. A broker's - environment carries no such value (F16.1 as T007 batch P amends it).""" + environment carries no such value (F16.1 as T007 batch P amends it; + `doxbench_provider.broker_environment`).""" return any(part and in_repository_argv( ["opendox-environment", f"--value={part}"], root=root) is not None for part in value.split(os.pathsep)) @@ -1707,9 +1814,15 @@ def broker_command_refused(members, *, return unwrapped.unreadable_because if inline_script(members, root=root) is not None: return REASON_INLINE_SCRIPT - if root is not None and in_repository_argv(members, - root=root) is not None: - return REASON_IN_REPOSITORY + if root is not None: + try: + named = _in_repository_member(members, root=root) + except _TooManyLinks: + # links past the kernel's own bound, or a loop: what it names + # cannot be judged (the holder's ruling, #656 5986391296) + return REASON_UNREADABLE_COMMAND + if named is not None: + return REASON_IN_REPOSITORY return None @@ -1783,9 +1896,30 @@ def registered_verdict_for(binding, *, root: Path | str) -> TrustVerdict: return _judged(lambda: registered, binding, root=root) +class TrustRecording(NamedTuple): + """What recording trust came to: the verdict, which admits exactly the + binding, and whether the policy RECORDED anything (the holder's ruling, + openxFactory#656 comment 5986391296): a policy's `record()` that answers + something falsy recorded nothing, and its verdict is asked instead, so + there is nothing to withdraw after a write that fails.""" + + verdict: TrustVerdict + recorded: bool + + def recorded_for(binding, *, root: Path | str) -> TrustVerdict: + """`recording_for`'s verdict: the trust recorded for `binding` at + `root`, which admits exactly `binding`.""" + return recording_for(binding, root=root).verdict + + +def recording_for(binding, *, root: Path | str) -> TrustRecording: """Ask the registered policy to RECORD trust for `binding` at `root`, and - return the verdict, which admits exactly `binding`. + return the verdict, which admits exactly `binding`, and whether the + policy recorded anything. `record()` reports that by what it answers: a + verdict is a record, and something falsy (None, as openxFactory's + governed policy answers) is none, in which case the policy's `verdict` + is asked for the verdict (ruling 5986391296). 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 @@ -1811,11 +1945,16 @@ def recorded_for(binding, *, root: Path | str) -> TrustVerdict: f"machine, and no trust was recorded for it: {refused}. " f"{trust_remedy(binding.id, str(root), refused)}") registered = None + recorded = True try: # INSIDE the refusal net (T100 follow-on, A13): whatever the seam # answers, a refusal by name follows, never a raw error. registered = policy() verdict = registered.record(binding, root=root) + if not verdict: + # NOTHING RECORDED: the policy's own verdict decides + recorded = False + verdict = registered.verdict(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 @@ -1834,7 +1973,7 @@ def recorded_for(binding, *, root: Path | str) -> TrustVerdict: reason=reason_policy_failed(error)) verdict = _held_to(binding, verdict, root=root) if verdict.admits(binding): - return verdict + return TrustRecording(verdict, recorded) raise TrustNotRecorded( f"the trust policy did not record trust for model binding " f"{shown(binding.id)} in the repository at {shown(verdict.root)} " @@ -1864,7 +2003,9 @@ def restored_for(binding, *, replacing, root: Path | str) -> None: openDox-code#86, r4179076901). A host's policy is asked to record the earlier form again, as `recorded_for` asks; one cannot be asked to withdraw a trust (`REASON_NO_WITHDRAWAL`). Refused BY NAME where it - cannot be done.""" + cannot be done. Asked only where the policy recorded something + (`TrustRecording.recorded`): openDox withdraws only what a policy + recorded (ruling 5986391296).""" registered = _registered_now() if type(registered) is MachineTrust: registered.restore(binding, root=root, replacing=replacing) diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index f998decf..46b02e36 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -3069,6 +3069,40 @@ def _in_repository_argv(served, where, monkeypatch): (package / "inner").symlink_to(tool.parent) return ["env", "-C", str(served.tmp), sys.executable, "-m", "pkg.inner.broker"] + if where == "a-link-inside-in-the-middle-of-a-chain": + # r4180041184: outside -> repository -> outside, as the script + return [sys.executable, str(_chain(served, tool, relative=False))] + if where == "a-relative-chain-through-the-repository": + return [sys.executable, str(_chain(served, tool, relative=True))] + if where == "a-chain-through-the-repository-as-the-program": + return [str(_chain(served, tool, relative=False, program=True))] + if where == "sourceless-bytecode-module": + # r4180041203: `-m` imports a sourceless `.pyc` + compiled = tool.parent / "bytecode.pyc" + compiled.write_bytes(b"") + elsewhere = served.tmp / "elsewhere" + elsewhere.mkdir() + (elsewhere / "bytecode.pyc").symlink_to(compiled) + return ["env", "-C", str(elsewhere), sys.executable, "-m", + "bytecode"] + if where == "extension-module": + import importlib.machinery + + suffix = importlib.machinery.EXTENSION_SUFFIXES[0] + extension = tool.parent / f"native{suffix}" + extension.write_bytes(b"") + elsewhere = served.tmp / "elsewhere" + elsewhere.mkdir() + (elsewhere / f"native{suffix}").symlink_to(extension) + return ["env", "-C", str(elsewhere), sys.executable, "-m", "native"] + if where == "module-attached-to-its-option": + # r4180041213: `-mX` + return ["env", "-C", str(beside), sys.executable, + f"-m{served.repo.name}.tools.broker"] + if where == "module-in-an-option-cluster": + # r4180041213: `-BmX` + return ["env", "-C", str(beside), sys.executable, + f"-Bm{served.repo.name}.tools.broker"] if where == "symlink-in-the-repository-to-outside": # r4179241566: the repository owns the link, and a pull can point it # at another program without changing the binding. @@ -3151,6 +3185,29 @@ def _in_repository_argv(served, where, monkeypatch): raise AssertionError(where) +def _chain(served, tool: Path, *, relative: bool, + program: bool = False) -> Path: + """`outside/entry -> /tools/selected -> outside/real`: a link + outside the repository whose target is a link inside it, whose own + target is a real file outside it (Copilot at openDox-code#86, + r4180041184). Each target is relative to its link's directory where + `relative`.""" + outside = served.tmp / "chain" + outside.mkdir(exist_ok=True) + real = outside / ("real-program" if program else "real.py") + real.write_text(f"#!{sys.executable}\n", encoding="utf-8") + os.chmod(real, 0o755) + selected = tool.parent / ("selected" if program else "selected.py") + entry = outside / ("entry" if program else "entry.py") + if relative: + selected.symlink_to(os.path.relpath(real, selected.parent)) + entry.symlink_to(os.path.relpath(selected, entry.parent)) + else: + selected.symlink_to(real) + entry.symlink_to(selected) + return entry + + IN_REPOSITORY = ("absolute", "relative-to-the-broker-directory", "relative-climbing-from-the-broker-directory", "relative-after-env-changes-directory", @@ -3160,6 +3217,12 @@ def _in_repository_argv(served, where, monkeypatch): "bare-word-after-env-changes-directory", "dotted-module-after-env-changes-directory", "dotted-module-through-a-package-link", + "a-link-inside-in-the-middle-of-a-chain", + "a-relative-chain-through-the-repository", + "a-chain-through-the-repository-as-the-program", + "sourceless-bytecode-module", "extension-module", + "module-attached-to-its-option", + "module-in-an-option-cluster", "symlink-in-the-repository-to-outside", "relative-search-path-entry", "climbing-out-of-a-link", @@ -3201,6 +3264,23 @@ def test_A2_a_broker_inside_the_repository_is_refused_by_name( served.nothing_was_touched() +def test_R3_a_value_python_reads_is_no_module(served): + """The other side of reading `-m` by Python's grammar (r4180041213): a + letter that takes a value ends the cluster, so `-Wm...` is a warning + filter, and an option of another letter is no module.""" + trust_mod = _trust_mod() + (served.repo / "tools").mkdir() + beside = served.repo.parent + for argv in (["env", "-C", str(beside), sys.executable, + f"-Wm{served.repo.name}.tools.broker", str(served.broker)], + ["env", "-C", str(beside), sys.executable, + f"-Xm{served.repo.name}.tools.broker", str(served.broker)], + ["env", "-C", str(beside), sys.executable, "-B", + str(served.broker)]): + assert trust_mod.broker_command_refused( + argv, root=served.repo) is None, argv + + def test_A2_a_broker_outside_the_repository_is_trusted_as_before( served, capsys): """The rule names files, so what names none is not refused: an option's @@ -3211,7 +3291,7 @@ def test_A2_a_broker_outside_the_repository_is_trusted_as_before( trust_mod = _trust_mod() argv = [sys.executable, str(served.broker), "--config=/etc/opref.conf", "--issuer=https://auth.example/v1", "-v", "--quiet", "plain-word", - "--profile=/etc/opref\x00.conf"] + "--profile=/etc/opref\x00.conf", "/etc/opref\x00.conf"] served.hand_write(served.record("broker", broker_argv=argv)) binding = served.declared() assert trust_mod.in_repository_program(binding, root=served.repo) is None @@ -4175,6 +4255,74 @@ def half_then_full(handle, text): [declarations.name, document.name]) +def _umask_mode() -> int: + mask = os.umask(0) + os.umask(mask) + return 0o666 & ~mask + + +@pytest.mark.parametrize("document", ["bindings", "declarations"]) +def test_R3_a_new_document_is_never_seen_in_part(served, capsys, monkeypatch, + document): + """Copilot at openDox-code#86, r4180041167; the holder's ruling, + openxFactory#656 comment 5986391296: a document that does not exist yet + is written whole beside its place and published with a hard link, so + while it is written its place holds nothing, and after it holds all of + it, with the mode a new file takes here and nothing left beside it. The + first `add`, and the first intake write.""" + path = (binding_mod.bindings_path(served.repo) if document == "bindings" + else intake_mod.declarations_path(served.repo)) + seen = [] + real = binding_mod._write_all + + def watching(handle, text): + seen.append(path.exists()) + real(handle, text) + + monkeypatch.setattr(binding_mod, "_write_all", watching) + if document == "bindings": + assert _cli(*served.add_argv("env")) == 0 + capsys.readouterr() + assert served.declared().id == BINDING_ID + else: + _propose(served.repo, "first-model") + assert intake_mod.pending_binding_ids(served.repo) == {"first-model"} + assert seen == [False] + assert stat.S_IMODE(path.stat().st_mode) == _umask_mode() + assert path.stat().st_nlink == 1 + assert sorted(p.name for p in path.parent.iterdir()) == [path.name] + + +@pytest.mark.parametrize("document", ["bindings", "declarations"]) +def test_R3_a_document_made_meanwhile_is_never_overwritten( + served, capsys, monkeypatch, document): + """A document another process makes while this one writes its first is + never overwritten: the hard link refuses (`File exists`), this write is + refused by name, the other document stands as it was written, and + nothing is left beside it. An `add` refused so withdraws the trust it + recorded (C2).""" + path = (binding_mod.bindings_path(served.repo) if document == "bindings" + else intake_mod.declarations_path(served.repo)) + theirs = b"# another process's document\n" + real = binding_mod._write_all + + def meanwhile(handle, text): + real(handle, text) + path.write_bytes(theirs) + + monkeypatch.setattr(binding_mod, "_write_all", meanwhile) + if document == "bindings": + assert _cli(*served.add_argv("env")) == 1 + err = capsys.readouterr().err + assert "could not be written (File exists)" in err, err + assert not _held_entries(served) + else: + with pytest.raises(FileExistsError): + _propose(served.repo, "first-model") + assert path.read_bytes() == theirs + assert sorted(p.name for p in path.parent.iterdir()) == [path.name] + + def test_N1_a_relative_path_that_is_the_default_is_judged_whole( served, monkeypatch): """r4179076919. Run from the repository's root, the default path is @@ -4732,6 +4880,66 @@ def test_A2_a_command_that_cannot_be_read_is_refused_by_name( served.nothing_was_touched() +@pytest.mark.parametrize("case", ["past-the-bound", "a-loop"]) +def test_R3_links_past_the_bound_are_unreadable(served, case): + """The holder's ruling, openxFactory#656 comment 5986391296: a path + whose symbolic links pass the kernel's own bound (40), or loop, is + refused BY NAME as unreadable, wherever it is judged; a chain within + the bound is followed to its end.""" + trust_mod = _trust_mod() + links = served.tmp / "links" + links.mkdir() + real = links / "real.py" + real.write_text("", encoding="utf-8") + if case == "past-the-bound": + target = real + for hop in range(41): + link = links / f"hop-{hop}" + link.symlink_to(target) + target = link + named = target + else: + (links / "a").symlink_to(links / "b") + (links / "b").symlink_to(links / "a") + named = links / "a" + argv = [sys.executable, str(named)] + served.hand_write(served.record("broker", broker_argv=argv)) + binding = served.declared() + assert trust_mod.broker_refusal(binding, root=served.repo) == ( + trust_mod.REASON_UNREADABLE_COMMAND) + assert trust_mod.in_repository_argv(argv, root=served.repo) == str( + named) + assert trust_mod.names_a_path_inside(str(named), root=served.repo) + trust_mod.unregister() + trust_mod.register(_TrustsEveryBinding()) + assert trust_mod.verdict_for(binding, root=served.repo).reason == ( + trust_mod.REASON_UNREADABLE_COMMAND) + + +def test_R3_a_path_that_leaves_the_root_by_dot_dot_is_outside(served): + """The walk ends where the path does: `/..` passes through the + served root and names its parent, which lies outside it.""" + trust_mod = _trust_mod() + leaving = os.path.join(str(served.repo), os.pardir) + for argv in (["env", "-C", leaving, sys.executable, str(served.broker)], + [sys.executable, str(served.broker), f"--cache={leaving}"]): + assert trust_mod.broker_command_refused( + argv, root=served.repo) is None, argv + + +def test_R3_a_chain_within_the_bound_is_followed_to_its_end(served): + trust_mod = _trust_mod() + links = served.tmp / "links" + links.mkdir() + target = served.broker + for hop in range(40): + link = links / f"hop-{hop}" + link.symlink_to(target) + target = link + assert trust_mod.broker_command_refused( + [sys.executable, str(target)], root=served.repo) is None + + def test_A2_launchers_within_the_depth_are_unwrapped_whole(served): """The control: launchers nested within the depth are unwrapped to the program they start, which is judged, and admitted where it is a file @@ -4981,10 +5189,137 @@ def refuses(self, binding, *, root, replacing): assert cli_model_binding.RECOVER_FAILED_UNDOING in err +def test_R3_an_add_racing_one_of_the_same_form_keeps_its_trust( + served, capsys, monkeypatch): + """Copilot at openDox-code#86, r4180041219; the holder's ruling, + openxFactory#656 comment 5986391296, (a): two `add`s of the same form + both record its trust, the other writes the document first, and this + one's write is refused for the repeated id. Its undoing is skipped, + because the document now declares exactly the form recorded, so the + declared binding stays trusted.""" + from opendox import cli_model_binding + + trust_mod = _trust_mod() + real = cli_model_binding._record_trust + document = binding_mod.bindings_path(served.repo) + + def and_the_other_add_lands(binding, args): + recording = real(binding, args) + binding_mod.BindingStore(document).add(binding) + return recording + + monkeypatch.setattr(cli_model_binding, "_record_trust", + and_the_other_add_lands) + assert _cli(*served.add_argv("env")) == 1 + capsys.readouterr() + assert trust_mod.verdict_for(served.declared(), root=served.repo).trusted + + +def test_R3_an_edit_racing_one_of_the_same_form_keeps_its_trust( + served, capsys, monkeypatch): + """The same for `edit`: the other edit writes the same form, and this + one's write fails; the document declares exactly the form recorded, so + its trust stands.""" + trust_mod = _trust_mod() + served.hand_write(served.record("env")) + document = binding_mod.bindings_path(served.repo) + real = binding_mod.BindingStore.edit + + def the_other_lands_then_this_fails(store, binding): + real(binding_mod.BindingStore(document), binding) + raise OSError(28, "No space left on device") + + monkeypatch.setattr(binding_mod.BindingStore, "edit", + the_other_lands_then_this_fails) + editing = served.add_argv("env") + editing[1] = "edit" + editing[editing.index("--label") + 1] = "Renamed" + assert _cli(*editing) == 1 + capsys.readouterr() + assert served.declared().label == "Renamed" + assert trust_mod.verdict_for(served.declared(), root=served.repo).trusted + + +class _RecordsNothing(_TrustsEveryBinding): + """A host policy whose `record()` writes nothing and answers nothing, + as openxFactory's governed policy does (T094): its `verdict` decides.""" + + def record(self, binding, *, root): + return None + + +class _RecordsNothingAndDeclines(_RecordsNothing): + def verdict(self, binding, *, root): + return _trust_mod().TrustVerdict.untrusted_for( + binding, root=root, basis=_trust_mod().BASIS_HOST, + reason="its declaration is pending") + + +@pytest.mark.parametrize("verb", ["add", "edit"]) +def test_R3_a_host_that_recorded_nothing_has_nothing_withdrawn( + served, capsys, monkeypatch, verb): + """The holder's ruling, openxFactory#656 comment 5986391296: openDox + withdraws only what a policy recorded. A host policy whose `record()` + answers something falsy recorded nothing; its `verdict` admits the + binding, so the act writes, and where the write fails the refusal says + truthfully that nothing changed, and nothing is withdrawn.""" + from opendox import cli_model_binding + + trust_mod = _trust_mod() + if verb == "edit": + served.hand_write(served.record("env")) + trust_mod.unregister() + trust_mod.register(_RecordsNothing()) + _failing(monkeypatch, verb) + argv = served.add_argv("env") + if verb == "edit": + argv[1] = "edit" + argv[argv.index("--label") + 1] = "Renamed" + assert _cli(*argv) == 1 + err = capsys.readouterr().err + assert "could not be written" in err and "nothing in it changed" in err + assert "could not be withdrawn" not in err + assert "could not be restored" not in err + assert trust_mod.REASON_NO_WITHDRAWAL not in err + assert cli_model_binding.RECOVER_FAILED_UNDOING not in err + assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() + + +def test_R3_a_host_that_records_nothing_is_asked_its_verdict(served, capsys): + """Recording under such a host asks its `verdict`: one that admits the + binding lets `add` write it, with nothing written to this machine's + store, and one that declines refuses `add` by name before it writes.""" + trust_mod = _trust_mod() + trust_mod.unregister() + trust_mod.register(_RecordsNothing()) + recording = trust_mod.recording_for( + binding_mod.ModelProviderBinding.from_record(served.record("env")), + root=served.repo) + assert recording.verdict.trusted and recording.recorded is False + assert _cli(*served.add_argv("env")) == 0 + capsys.readouterr() + assert served.declared().id == BINDING_ID + assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() + binding_mod.bindings_path(served.repo).unlink() + trust_mod.unregister() + trust_mod.register(_RecordsNothingAndDeclines()) + assert _cli(*served.add_argv("env")) == 1 + assert "its declaration is pending" in capsys.readouterr().err + assert not binding_mod.bindings_path(served.repo).exists() + trust_mod.unregister() + trust_mod.register(_TrustsEveryBinding()) + recording = trust_mod.recording_for( + binding_mod.ModelProviderBinding.from_record(served.record("env")), + root=served.repo) + assert recording.verdict.trusted and recording.recorded is True + + def test_C2_a_host_policy_cannot_be_asked_to_withdraw_and_says_so( served, capsys, monkeypatch): """A host's policy records and judges trust and withdraws none, so a - failed add under one says the trust could not be withdrawn, and why.""" + failed add under one whose `record()` answered a record (truthy) says + the trust could not be withdrawn, and why (the holder's ruling, + openxFactory#656 comment 5986391296).""" from opendox import cli_model_binding trust_mod = _trust_mod() @@ -5425,6 +5760,43 @@ def test_F16_1_batch_p_a_relative_word_is_judged_from_the_root_where_it_names_a_ argv, root=served.repo) == trust_mod.REASON_IN_REPOSITORY, argv +def test_R3_a_variable_off_the_path_list_is_kept_whole_or_dropped_whole( + served, monkeypatch): + """The holder's ruling, openxFactory#656 comment 5986391296, on Copilot + at openDox-code#86, r4180041233: only the CLOSED list of path-list + variables loses its entries inside the served repository; any other + variable is never edited, and is dropped WHOLE where any part of it + names a path inside, so a `HOME` or a `TMPDIR` is never emptied or + cut.""" + from opendox import doxbench_bridge + + monkeypatch.setattr(doxbench_bridge, "INHERITED_ENVIRONMENT", + (*doxbench_bridge.INHERITED_ENVIRONMENT, + *sorted(provider_mod.PATH_LIST_VARIABLES), + "OPENDOX_TEST_LIST")) + inside = served.repo / "tools" + inside.mkdir() + outside = served.tmp / "outside" + outside.mkdir() + colon = served.tmp / "a:scratch" + colon.mkdir() + sep = os.pathsep + base = {"HOME": str(colon), "TMPDIR": f"{outside}{sep}{inside}", + "LANG": "C.UTF-8", "OPENDOX_TEST_LIST": f"{outside}{sep}{inside}"} + for name in provider_mod.PATH_LIST_VARIABLES: + base[name] = f"{inside}{sep}{outside}{sep}tools" + environment = provider_mod.broker_environment(base, root=served.repo) + assert environment["HOME"] == str(colon) # never cut at its `:` + assert "TMPDIR" not in environment # dropped whole + assert "OPENDOX_TEST_LIST" not in environment # off the list: whole + assert environment["LANG"] == "C.UTF-8" + for name in provider_mod.PATH_LIST_VARIABLES: + assert environment[name] == str(outside), name + assert provider_mod.PATH_LIST_VARIABLES == frozenset({ + "PATH", "PYTHONPATH", "NODE_PATH", "LD_LIBRARY_PATH", "PERL5LIB", + "PERLLIB", "RUBYLIB", "CLASSPATH", "GEM_PATH", "MANPATH"}) + + def test_F16_1_batch_p_an_environment_value_is_judged_entry_by_entry(served): """`names_a_path_inside` reads a value as a path list, so an entry inside the served root is found wherever it stands in the list.""" From e03d1a56148d6aff458732cd6d4ed4363a67e894 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:28:05 +0000 Subject: [PATCH 08/16] T100 follow-on: julia, Rscript and R inline scripts, and deno's leading options (plan 034) Lane openXfactory-3 D7 early findings N1 and N2, from its read-only review of #86 at d3587910; the holder ruled both fix now, in round 3's push. N1. julia (-e/--eval, -E/--print), Rscript (-e) and R (-e) join the interpreter table (_INTERPRETERS), each read by its own option grammar: julia's value options skipped (an attached one too: -Ccore-avx2), R's -f/--file taking its script with R's own options still read after it (fail-closed), and --args ending them. N2. deno's leading global options are read before its subcommand (_deno_subcommand): flags (-q/--quiet, --unstable..., -h, -V) and value options (-L/--log-level) are skipped, so deno --quiet eval and deno -q eval are inline scripts. A leading option deno's global grammar does not hold is refused as unreadable, fail-closed (REASON_UNREADABLE_COMMAND), and inline_script names it. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 72 +++++++++++++++++++++++++++++-- tests/test_model_binding_trust.py | 40 +++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index b8059de5..39735cff 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -1362,6 +1362,21 @@ class _Interpreter(NamedTuple): "luajit": _Interpreter(inline="e", takes="lj", attached="O", ends="b"), "node": _NODE, "nodejs": _NODE, "bun": _NODE, "osascript": _Interpreter(inline="e", takes="ls"), + # Lane openXfactory-3 D7 early findings N1, holder ruled fix now: julia + # (-e/--eval, -E/--print), Rscript (-e) and R (-e). + "julia": _Interpreter( + inline="eE", takes="JHLtpC", attached="Og", + longs=frozenset({"--eval", "--print"}), + long_values=frozenset({"--sysimage", "--home", "--load", + "--threads", "--procs", "--cpu-target", + "--machine-file"})), + "Rscript": _Interpreter(inline="e"), + # R's -f/--file names its script, and R's own options are still read + # after it (fail-closed); --args passes the rest to the script. + "R": _Interpreter(inline="e", takes="dgf", + long_values=frozenset({"--debugger", "--gui", + "--file"}), + long_ends=frozenset({"--args"})), "flock": _Interpreter(inline="c", takes="wE", longs=frozenset({"--command"}), long_values=frozenset({"--timeout", "--wait", @@ -1709,6 +1724,40 @@ def _program_names(member: str, *, first: bool, return {_unversioned(name) for name in names if name} +#: deno's global options, which may stand before its subcommand (lane +#: openXfactory-3 D7 early findings N2, holder ruled fix now): flags, and +#: those that take a value (attached after `=`, or the next member). Any +#: `--unstable...` is a flag. A leading option that is none of these is +#: refused as unreadable, fail-closed, as a launcher's is. +_DENO_FLAGS = frozenset({"-q", "--quiet", "-h", "--help", "-V", "--version", + "--unstable"}) +_DENO_VALUES = frozenset({"-L", "--log-level"}) + + +def _deno_subcommand(rest: tuple[str, ...]) -> str | None: + """deno's subcommand: the first member after its leading global options + (`deno --quiet eval ...`), or None where there is none. Raises + `_Unreadable` for a leading option deno's global grammar does not + hold.""" + index = 0 + while index < len(rest): + member = rest[index] + if not member.startswith("-") or member == "-": + return member + index += 1 + option = member.partition("=")[0] + if option in _DENO_FLAGS or option.startswith("--unstable"): + continue + if option in _DENO_VALUES: + if "=" not in member: + index += 1 + continue + if member[:2] in _DENO_VALUES: # `-Linfo` + continue + raise _Unreadable(member) + return None + + def _gives_an_inline_script(name: str, rest: tuple[str, ...]) -> bool: """Whether a program named `name`, followed by `rest`, is given an inline script, read by its own grammar (`_INTERPRETERS`): a short cluster @@ -1718,7 +1767,7 @@ def _gives_an_inline_script(name: str, rest: tuple[str, ...]) -> bool: (`python /opt/broker.py -c profile`) is the script's (Copilot at openDox-code#86, r4179241583, r4179241614).""" if name == "deno": - return bool(rest) and rest[0] == "eval" + return _deno_subcommand(rest) == "eval" spec = _INTERPRETERS.get(name) if spec is None: return False @@ -1785,7 +1834,19 @@ def inline_script(members, *, root: Path | str | None = None) -> str | None: THE ACCEPTED LIMIT (the same ruling, item 4): a general program that runs code from its own arguments, such as `awk 'PROGRAM'`, `sed` or - `find -exec`, is not judged as an inline script.""" + `find -exec`, is not judged as an inline script. A member whose own + options cannot be read (an unknown leading option of deno's) is + returned as well, FAIL-CLOSED; `broker_command_refused` refuses it as + unreadable.""" + try: + return _inline_member(members) + except _Unreadable as unreadable: + return unreadable.member + + +def _inline_member(members) -> str | None: + """`inline_script`'s work. Raises `_Unreadable` for a member whose own + options cannot be read.""" unwrapped = _unwrapped(members) for command, context in ((tuple(members), _broker_context()), (unwrapped.command, unwrapped.context)): @@ -1812,8 +1873,11 @@ def broker_command_refused(members, *, unwrapped = _unwrapped(members) if unwrapped.unreadable is not None: return unwrapped.unreadable_because - if inline_script(members, root=root) is not None: - return REASON_INLINE_SCRIPT + try: + if _inline_member(members) is not None: + return REASON_INLINE_SCRIPT + except _Unreadable: + return REASON_UNREADABLE_COMMAND if root is not None: try: named = _in_repository_member(members, root=root) diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 46b02e36..a22fe957 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -3264,6 +3264,26 @@ def test_A2_a_broker_inside_the_repository_is_refused_by_name( served.nothing_was_touched() +def test_N2_deno_whose_leading_option_cannot_be_read_is_named(served): + """Lane openXfactory-3 D7 early findings N2, holder ruled fix now: a + leading option deno's global grammar does not hold is refused as + unreadable, fail-closed, and `inline_script` names it rather than pass + it; a known one is read past, to the subcommand.""" + trust_mod = _trust_mod() + unknown = ["deno", "--frobnicate", "eval", "x"] + assert trust_mod.inline_script(unknown) == "--frobnicate" + assert trust_mod.broker_command_refused(unknown, root=served.repo) == ( + trust_mod.REASON_UNREADABLE_COMMAND) + for argv in (["deno", "--quiet", "eval", "x"], + ["deno", "-q", "eval", "x"], + ["deno", "--log-level=info", "eval", "x"], + ["deno", "-Linfo", "eval", "x"], + ["deno", "--unstable-kv", "eval", "x"]): + assert trust_mod.inline_script(argv) == "deno", argv + assert trust_mod.inline_script( + ["deno", "--quiet", "run", str(served.broker)]) is None + + def test_R3_a_value_python_reads_is_no_module(served): """The other side of reading `-m` by Python's grammar (r4180041213): a letter that takes a value ends the cluster, so `-Wm...` is a warning @@ -4467,6 +4487,16 @@ def test_A5_an_intake_broker_the_rules_refuse_is_not_offered(served): "pwsh-encoded": ["powershell", "-EncodedCommand", "eAA="], "fish-C": ["fish", "-C", "x"], "bash-plus-o-then-c": ["bash", "+o", "posix", "-c", "x"], + # Lane openXfactory-3 D7 early findings N1 and N2, holder ruled fix now + "julia-e": ["julia", "-e", "x"], + "julia-eval": ["julia", "--eval=x"], + "julia-E": ["julia", "--threads", "2", "-E", "x"], + "Rscript-e": ["Rscript", "-e", "x"], + "R-e": ["R", "--no-echo", "-e", "x"], + "R-file-then-e": ["R", "-f", "/opt/opendox-test/x.R", "-e", "x"], + "deno-quiet-eval": ["deno", "--quiet", "eval", "x"], + "deno-q-eval": ["deno", "-q", "eval", "x"], + "deno-log-level-eval": ["deno", "--log-level", "info", "eval", "x"], # The holder's ruling, openxFactory#656 comment 5985046107, C1: env's # long options by any unambiguous beginning, and its clusters. "env-split-string-abbreviated": ["env", "--split=sh -c x"], @@ -4598,6 +4628,14 @@ def test_A2_an_inline_script_trusted_before_the_rule_never_runs( "-c", "x"], "su-given-a-shell-file": lambda served: [ "su", "bob", "-s", str(served.broker)], + "julia-given-a-file": lambda served: [ + "julia", "-t", "2", str(served.broker), "-e", "x"], + "julia-attached-target-then-a-file": lambda served: [ + "julia", "-Ccore-avx2", str(served.broker)], + "R-given-a-file": lambda served: [ + "R", "-f", str(served.broker), "--args", "-e", "x"], + "deno-quiet-run": lambda served: [ + "deno", "--quiet", "run", str(served.broker)], "a-module-attached-then-its-own-option": lambda served: [ sys.executable, "-mjson.tool", "-c", "x"], "an-option-like-file-after-double-dash": lambda served: [ @@ -4839,6 +4877,8 @@ def _unreadable_command(served, monkeypatch, case): "-c", "x"], "sudo-chroot": ["sudo", "--chroot=/srv", "/bin/true"], "sudo-login": ["sudo", "-i", "/bin/true"], + # N2: a leading option deno's global grammar does not hold + "deno-unknown-leading-option": ["deno", "--frobnicate", "eval", "x"], } From d4877eb8fcda84c55e5e4d02e20ba9b368053291 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:35:12 +0000 Subject: [PATCH 09/16] T100 follow-on: Copilot's fourth review, under the holder's ruling 5988088910 (plan 034) Copilot's fourth review of openDox-code#86 (review 5410001269, at e03d1a56), all 3 threads, as the holder ruled on openxFactory#656 comment 5988088910. 1. A dotted -m judges each package's initializer on the way and the final package's __main__, with every import suffix, links followed (_module_paths; r4180717725). 2. A launcher given a second working directory is refused as unreadable, fail-closed, rather than modelled: a repeated env -C/--chdir, counting one an env -S string gives, and a repeated sudo -D/--chdir (r4180717752). One per launcher keeps its judgment, nested launchers each with their own included. 3. A -m module is judged under every entry of the broker's effective PYTHONPATH as well as its start directory (_python_roots; r4180717772): the inherited entries after the broker environment's filter, or what a launcher assigns (env PYTHONPATH=..., a relative entry read from the start directory) or clears (env -i, env -, -u PYTHONPATH). THE ACCEPTED LIMIT: a script outside the repository that imports by name from a directory outside it holding links into it. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 124 +++++++++++++++++++++++------- tests/test_model_binding_trust.py | 96 +++++++++++++++++++++++ 2 files changed, 191 insertions(+), 29 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 39735cff..b7c334d1 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -327,18 +327,20 @@ #: openDox-code#86, r4179366319; the holder's ruling, openxFactory#656 #: comment 5985046107, C1): an option its launcher does not have, or has by #: more than one name; an option after which what runs cannot be judged -#: from its words (`env --argv0`, `sudo --chroot`, `sudo -i`); launchers -#: nested past what is unwrapped; or a path whose symbolic links pass the -#: kernel's own bound, or loop (`_LINK_HOPS`; ruling 5986391296). What it runs cannot be judged, so it is +#: from its words (`env --argv0`, `sudo --chroot`, `sudo -i`); a launcher +#: given a second working directory (`env -C`, counting an `env -S` +#: string's, or `sudo -D`; ruling 5988088910); launchers nested past what +#: is unwrapped; or a path whose symbolic links pass the kernel's own bound, +#: or loop (`_LINK_HOPS`; ruling 5986391296). What it runs cannot be judged, so it is #: refused FAIL-CLOSED, with the inline-script remedy. (An `env -S` string #: env would not split as a shell does is an inline script: #: `REASON_INLINE_SCRIPT`.) REASON_UNREADABLE_COMMAND = ( "its broker command cannot be read to the program it runs (a launcher " "option it does not have, or has by more than one name, an option after " - "which what runs cannot be judged, launchers nested too deeply, or a " - "path whose links go deeper than the system follows), so what it runs " - "cannot be judged") + "which what runs cannot be judged, a launcher given two working " + "directories, launchers nested too deeply, or a path whose links go " + "deeper than the system follows), so what it runs cannot be judged") #: What an operator is told to do about such a binding. REMEDY_INLINE_SCRIPT = ( @@ -943,14 +945,17 @@ def unservable_because(binding) -> str | None: class _Context(NamedTuple): - """Where a broker command runs: its working directory, and the search - path its program is found on (None: the platform's default, - `os.defpath`), as the child process sees them (Copilot at - openDox-code#86, r4179241532, r4179366288). A launcher can change both - for the command it starts (`env -C`, `env PATH=...`, `env -i`).""" + """Where a broker command runs: its working directory, the search path + its program is found on (None: the platform's default, `os.defpath`), + and its Python import path, `PYTHONPATH`'s entries (None: the ones the + broker inherits, `_inherited_pythonpath`), as the child process sees + them (Copilot at openDox-code#86, r4179241532, r4179366288, + r4180717772). A launcher can change each for the command it starts + (`env -C`, `env PATH=...`, `env PYTHONPATH=...`, `env -i`).""" cwd: str path: str | None + pythonpath: tuple[str, ...] | None = None def _broker_context() -> _Context: @@ -959,6 +964,34 @@ def _broker_context() -> _Context: return _Context(BROKER_WORKING_DIRECTORY, os.environ.get("PATH")) +def _inherited_pythonpath(*, root: Path | str) -> list[str]: + """The `PYTHONPATH` entries a broker inherits: those of this process's + environment that the broker's environment carries, after its filter + drops every entry inside the served repository + (`doxbench_provider.broker_environment`, `PATH_LIST_VARIABLES`).""" + from opendox import doxbench_bridge + + inherited = doxbench_bridge.child_environment(os.environ).get( + "PYTHONPATH") + if not inherited: + return [] + return [entry for entry in inherited.split(os.pathsep) + if not (entry and names_a_path_inside(entry, root=root))] + + +def _python_roots(context: _Context, *, root: Path | str) -> list[str]: + """Every directory `python -m` imports a module from, as the broker runs + it: its start directory, and each entry of its effective `PYTHONPATH`, + the inherited entries after the filter or whatever a launcher assigned + or cleared, a relative entry (or an empty one) read from the start + directory (Copilot at openDox-code#86, r4180717772; the holder's ruling, + openxFactory#656 comment 5988088910).""" + entries = (context.pythonpath if context.pythonpath is not None + else _inherited_pythonpath(root=root)) + return [context.cwd, *(os.path.join(context.cwd, entry) + for entry in entries)] + + def _which(name: str, context: _Context) -> str | None: """`name` as the child finds it on its search path: the first executable file among the path's directories, a relative directory (or an empty @@ -1087,18 +1120,32 @@ def _candidates(member: str, *, option: bool) -> list[str]: _MODULE_SUFFIXES: tuple[str, ...] = tuple(importlib.machinery.all_suffixes()) -def _module_paths(name: str, *, context: _Context) -> list[str]: - """The files a module named after `-m` could be imported from: its - whole dotted name as a path under the context's working directory, - which `python -m` imports from first, as a package directory (`a/b/c`) - and as a module with every import suffix (`a/b/c.py`, `a/b/c.pyc`, - `a/b/c..so`; `_MODULE_SUFFIXES`), whether or not a file is there - yet (Copilot at openDox-code#86, r4179241555, r4180041203). A namespace - package needs no `__init__`, so a dotted name can reach any directory - under the working directory; every package on the way is a name - `_traversed` judges.""" - base = os.path.join(context.cwd, *name.split(".")) - return [base, *(base + suffix for suffix in _MODULE_SUFFIXES)] +def _module_paths(name: str, *, roots: list[str]) -> list[str]: + """The files a module named after `-m` could be imported from, under + each of `roots` (`_python_roots`: the start directory, which `python -m` + imports from first, and the effective `PYTHONPATH`; r4180717772), + whether or not a file is there yet: its whole dotted name as a package + directory (`a/b/c`) and as a module with every import suffix + (`a/b/c.py`, `a/b/c.pyc`, `a/b/c..so`; `_MODULE_SUFFIXES`; + r4179241555, r4180041203); every package's initializer on the way + (`a/__init__.py`, `a/b/__init__.py`, ...), which runs first; and the + final package's `__main__`, which `-m` runs for a package + (r4180717725; the holder's ruling, openxFactory#656 comment + 5988088910), each with every suffix. A namespace package needs no + `__init__`, so a dotted name can reach any directory under a root; + every name on the way is one `_traversed` judges, links followed.""" + parts = name.split(".") + found: list[str] = [] + for root in roots: + base = os.path.join(root, *parts) + found += [base, *(base + suffix for suffix in _MODULE_SUFFIXES)] + for count in range(1, len(parts) + 1): + package = os.path.join(root, *parts[:count]) + found += [os.path.join(package, "__init__" + suffix) + for suffix in _MODULE_SUFFIXES] + found += [os.path.join(base, "__main__" + suffix) + for suffix in _MODULE_SUFFIXES] + return found #: Python's own short options that take a value (`-W arg`, `-X opt`), and @@ -1270,7 +1317,8 @@ def judged(member: str, found: list[str]) -> bool: found = named(member, option=bool(index) and member.startswith("-") and not positional, first=index == 0, context=context) if index in modules and not positional: - found += _module_paths(modules[index], context=context) + found += _module_paths(modules[index], roots=_python_roots( + context, root=served)) if judged(member, found): return member return None @@ -1570,11 +1618,14 @@ def _launcher_name(member: str, *, context: _Context) -> str | None: def _launcher_options(name: str, rest: tuple[str, ...], context: _Context, - values: list[tuple[str, _Context]]): + values: list[tuple[str, _Context]], + chdirs: list[int]): """Read launcher `name`'s options from `rest`, as its getopt does: returns how many members they took, the context they leave, and, for `env -S`, the words its string splits into (else None). Each value is - added to `values` in the context it is read in. Raises `_Unreadable`.""" + added to `values` in the context it is read in. `chdirs` counts the + working directories this launcher was given, across an `env -S` + string's re-reading. Raises `_Unreadable`.""" grammar = _LAUNCHERS[name] def given(key: str, value: str | None) -> list[str] | None: @@ -1582,7 +1633,7 @@ def given(key: str, value: str | None) -> list[str] | None: if (name, key) in _UNREADABLE_OPTIONS: raise _Unreadable(value if value is not None else key) if name == "env" and key == "i": - context = context._replace(path=None) + context = context._replace(path=None, pythonpath=()) if value is None: return None if name == "env" and key == "S": @@ -1594,11 +1645,21 @@ def given(key: str, value: str | None) -> list[str] | None: raise _Unreadable(value, REASON_INLINE_SCRIPT) from None values.append((value, context)) if (name, key) in (("env", "C"), ("sudo", "D")): + # ONE working directory per launcher, counting one an `env -S` + # string gives: GNU env applies only the last, from the + # directory it started in, which is not modelled; a second is + # refused as unreadable (Copilot at openDox-code#86, + # r4180717752; the holder's ruling, #656 5988088910) + chdirs[0] += 1 + if chdirs[0] > 1: + raise _Unreadable(value) # the directory as the system enters it, links followed context = context._replace(cwd=_resolved_path( os.path.join(context.cwd, value))) elif name == "env" and key == "u" and value == "PATH": context = context._replace(path=None) + elif name == "env" and key == "u" and value == "PYTHONPATH": + context = context._replace(pythonpath=()) return None index = 0 @@ -1607,7 +1668,7 @@ def given(key: str, value: str | None) -> list[str] | None: if member == "--": return index + 1, context, None if name == "env" and member == "-": # `env -`: `-i` - context = context._replace(path=None) + context = context._replace(path=None, pythonpath=()) return index + 1, context, None if not member.startswith("-") or member == "-": break @@ -1664,6 +1725,7 @@ def _unwrapped(members) -> _Unwrapped: context = _broker_context() launchers: list[tuple[str, _Context]] = [] values: list[tuple[str, _Context]] = [] + chdirs = [0] for _depth in range(_LAUNCHER_DEPTH): if not command: break @@ -1674,7 +1736,7 @@ def _unwrapped(members) -> _Unwrapped: rest = command[1:] try: index, context, split = _launcher_options(name, rest, context, - values) + values, chdirs) except _Unreadable as unreadable: return _Unwrapped(tuple(launchers), tuple(values), (), context, unreadable=unreadable.member, @@ -1685,6 +1747,7 @@ def _unwrapped(members) -> _Unwrapped: launchers.pop() command = (command[0],) + tuple(split) + rest[index:] continue + chdirs = [0] # the next launcher is another program if name == "env": while index < len(rest) and _ASSIGNMENT.match(rest[index]): variable, assigned = rest[index].split("=", 1) @@ -1693,6 +1756,9 @@ def _unwrapped(members) -> _Unwrapped: for part in assigned.split(os.pathsep)) if variable == "PATH": context = context._replace(path=assigned) + elif variable == "PYTHONPATH": + context = context._replace( + pythonpath=tuple(assigned.split(os.pathsep))) index += 1 operands = rest[index:index + _LAUNCHERS[name].operands] values.extend((operand, context) for operand in operands) diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index a22fe957..ae13b792 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -3095,6 +3095,44 @@ def _in_repository_argv(served, where, monkeypatch): elsewhere.mkdir() (elsewhere / f"native{suffix}").symlink_to(extension) return ["env", "-C", str(elsewhere), sys.executable, "-m", "native"] + if where == "package-main-linked-into-the-repository": + # r4180717725: `-m pkg` runs `pkg/__main__.py` + elsewhere = served.tmp / "elsewhere" + (elsewhere / "pkg").mkdir(parents=True) + (elsewhere / "pkg" / "__main__.py").symlink_to(tool) + return ["env", "-C", str(elsewhere), sys.executable, "-m", "pkg"] + if where == "package-init-linked-into-the-repository": + # r4180717725: `-m pkg.mod` runs `pkg/__init__.py` first + elsewhere = served.tmp / "elsewhere" + (elsewhere / "pkg").mkdir(parents=True) + (elsewhere / "pkg" / "__init__.py").symlink_to(tool) + (elsewhere / "pkg" / "mod.py").write_text("", encoding="utf-8") + return ["env", "-C", str(elsewhere), sys.executable, "-m", + "pkg.mod"] + if where == "module-on-an-assigned-pythonpath": + # r4180717772: the module is found through `PYTHONPATH` + library = served.tmp / "library" + library.mkdir() + (library / "broker.py").symlink_to(tool) + return ["env", f"PYTHONPATH={library}", sys.executable, "-m", + "broker"] + if where == "module-on-a-relative-pythonpath-entry": + library = beside / "library" + library.mkdir() + (library / "broker.py").symlink_to(tool) + return ["env", "-C", str(beside), "PYTHONPATH=library", + sys.executable, "-m", "broker"] + if where == "module-on-the-inherited-pythonpath": + library = served.tmp / "library" + library.mkdir() + (library / "broker.py").symlink_to(tool) + from opendox import doxbench_bridge + + monkeypatch.setattr(doxbench_bridge, "INHERITED_ENVIRONMENT", + (*doxbench_bridge.INHERITED_ENVIRONMENT, + "PYTHONPATH")) + monkeypatch.setenv("PYTHONPATH", str(library)) + return [sys.executable, "-m", "broker"] if where == "module-attached-to-its-option": # r4180041213: `-mX` return ["env", "-C", str(beside), sys.executable, @@ -3223,6 +3261,11 @@ def _chain(served, tool: Path, *, relative: bool, "sourceless-bytecode-module", "extension-module", "module-attached-to-its-option", "module-in-an-option-cluster", + "package-main-linked-into-the-repository", + "package-init-linked-into-the-repository", + "module-on-an-assigned-pythonpath", + "module-on-a-relative-pythonpath-entry", + "module-on-the-inherited-pythonpath", "symlink-in-the-repository-to-outside", "relative-search-path-entry", "climbing-out-of-a-link", @@ -3284,6 +3327,51 @@ def test_N2_deno_whose_leading_option_cannot_be_read_is_named(served): ["deno", "--quiet", "run", str(served.broker)]) is None +def test_R4_one_working_directory_per_launcher_is_judged(served): + """The other side of r4180717752: one `-C` per launcher keeps its + judgment, nested launchers each with their own included, and one given + only inside an `env -S` string.""" + trust_mod = _trust_mod() + program = [sys.executable, str(served.broker)] + for argv in (["env", "-C", "/tmp", *program], + ["env", "-C", "/tmp", "env", "-C", "usr", *program], + ["env", "-S", f"-C /tmp {sys.executable} {served.broker}"], + ["sudo", "-D", "/tmp", *program]): + assert trust_mod.broker_command_refused( + argv, root=served.repo) is None, argv + + +def test_R4_the_effective_pythonpath_is_what_the_broker_has( + served, monkeypatch): + """r4180717772; the holder's ruling, #656 5988088910: an inherited + `PYTHONPATH` entry inside the repository, which the broker's environment + drops, is no root a module is judged under; `env -i` and `env -u + PYTHONPATH` clear the inherited entries, as they clear the broker's.""" + from opendox import doxbench_bridge + + trust_mod = _trust_mod() + tools = served.repo / "tools" + tools.mkdir() + library = served.tmp / "library" + library.mkdir() + (library / "broker.py").symlink_to(tools / "broker.py") + monkeypatch.setattr(doxbench_bridge, "INHERITED_ENVIRONMENT", + (*doxbench_bridge.INHERITED_ENVIRONMENT, + "PYTHONPATH")) + monkeypatch.setenv("PYTHONPATH", str(tools)) # dropped: inside + assert trust_mod.broker_command_refused( + [sys.executable, "-m", "json.tool"], root=served.repo) is None + monkeypatch.setenv("PYTHONPATH", str(library)) # reaches inside + assert trust_mod.broker_command_refused( + [sys.executable, "-m", "broker"], root=served.repo) == ( + trust_mod.REASON_IN_REPOSITORY) + for cleared in (["env", "-i"], ["env", "-"], ["env", "-u", "PYTHONPATH"], + ["env", "--ignore-environment"]): + argv = [*cleared, sys.executable, "-m", "broker"] + assert trust_mod.broker_command_refused( + argv, root=served.repo) is None, argv + + def test_R3_a_value_python_reads_is_no_module(served): """The other side of reading `-m` by Python's grammar (r4180041213): a letter that takes a value ends the cluster, so `-Wm...` is a warning @@ -4879,6 +4967,14 @@ def _unreadable_command(served, monkeypatch, case): "sudo-login": ["sudo", "-i", "/bin/true"], # N2: a leading option deno's global grammar does not hold "deno-unknown-leading-option": ["deno", "--frobnicate", "eval", "x"], + # r4180717752; the holder's ruling, #656 5988088910: a second working + # directory for one launcher, counting one an `env -S` string gives + "env-chdir-twice": ["env", "-C", "/tmp", "-C", "usr", "/bin/true"], + "env-chdir-twice-long": ["env", "--chdir=/tmp", "--chd", "usr", + "/bin/true"], + "env-chdir-again-in-a-split-string": ["env", "-C", "/tmp", "-S", + "-C usr /bin/true"], + "sudo-chdir-twice": ["sudo", "-D", "/tmp", "--chdir=/usr", "/bin/true"], } From c00a8fd7b03c547b6e6de2a6993a25efe64caec2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:02:43 +0000 Subject: [PATCH 10/16] T100 follow-on: lane openXfactory-3 D7 F1, under the holder's ruling 5988369111 (plan 034) Lane openXfactory-3's D7 review of openDox-code#86 at e03d1a56, finding F1, as the holder ruled on openxFactory#656 comment 5988369111. 1. raku, perl6's current name, is read as perl is (_INTERPRETERS), so raku -e is refused as an inline script as perl6 -e already was; a raku given a file is not. 2. THE ACCEPTED LIMIT, named beside awk, sed and find in REASON_INLINE_SCRIPT's and inline_script's docstrings: an interpreter the table does not hold, such as guile -c or -e, elixir -e, erl -eval, escript, groovy -e, scala -e, clojure -e, gjs -c and swipl -g. Also a control the round-4 mutants asked for: a dotted -m whose packages lie outside and whose last name is a link into the repository is refused (the whole dotted name is judged, not its top level alone). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 16 ++++++++++++++-- tests/test_model_binding_trust.py | 12 ++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index b7c334d1..d1720b7a 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -313,7 +313,11 @@ #: ruling, openxFactory#656 comment 5984069416). THE ACCEPTED LIMIT (same #: ruling, item 4): a general program that runs code from its own arguments #: (`awk`, `sed`, `find -exec`, and the like) is not judged as an inline -#: script. An `env -S` string env would not split as a shell does (one +#: script; nor is an interpreter `_INTERPRETERS` does not hold, such as +#: `guile -c` or `-e`, `elixir -e`, `erl -eval`, `escript`, `groovy -e`, +#: `scala -e`, `clojure -e`, `gjs -c` and `swipl -g` (lane openXfactory-3 +#: D7 F1; the holder's ruling, openxFactory#656 comment 5988369111). An +#: `env -S` string env would not split as a shell does (one #: holding a backslash, a `$` or a `#`, or quotes that do not close) is #: refused as one too: what it runs cannot be read (F16.1 as T007 batch P #: amends it; the holder's ruling, openxFactory#656 comment 5985046107, C1). @@ -1404,6 +1408,10 @@ class _Interpreter(NamedTuple): "pypy": _Interpreter(inline="c", takes="WX", ends="m"), "jython": _Interpreter(inline="c", takes="WX", ends="m"), "perl": _Interpreter(inline="eE", takes="IMm", attached="ixdDC"), + # Lane openXfactory-3 D7 F1, holder ruled fix now (openxFactory#656 + # comment 5988369111): raku, perl6's current name, is read as perl is + # (perl6 is perl by its version suffix, `_unversioned`). + "raku": _Interpreter(inline="eE", takes="IMm", attached="ixdDC"), "ruby": _Interpreter(inline="e", takes="IrCE", attached="FKTxW"), "php": _Interpreter(inline="rRBE", takes="cdzt", ends="f"), "lua": _Interpreter(inline="e", takes="l"), @@ -1900,7 +1908,11 @@ def inline_script(members, *, root: Path | str | None = None) -> str | None: THE ACCEPTED LIMIT (the same ruling, item 4): a general program that runs code from its own arguments, such as `awk 'PROGRAM'`, `sed` or - `find -exec`, is not judged as an inline script. A member whose own + `find -exec`, is not judged as an inline script, nor is an interpreter + `_INTERPRETERS` does not hold, such as `guile -c` or `-e`, `elixir -e`, + `erl -eval`, `escript`, `groovy -e`, `scala -e`, `clojure -e`, `gjs -c` + and `swipl -g` (lane openXfactory-3 D7 F1; the holder's ruling, + openxFactory#656 comment 5988369111). A member whose own options cannot be read (an unknown leading option of deno's) is returned as well, FAIL-CLOSED; `broker_command_refused` refuses it as unreadable.""" diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index ae13b792..7af2b213 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -3069,6 +3069,14 @@ def _in_repository_argv(served, where, monkeypatch): (package / "inner").symlink_to(tool.parent) return ["env", "-C", str(served.tmp), sys.executable, "-m", "pkg.inner.broker"] + if where == "dotted-module-linked-at-its-last-name": + # r4179241555: every package on the way lies outside, and the file + # the whole dotted name reaches is a link into the repository. + elsewhere = served.tmp / "elsewhere" + (elsewhere / "pkg").mkdir(parents=True) + (elsewhere / "pkg" / "broker.py").symlink_to(tool) + return ["env", "-C", str(elsewhere), sys.executable, "-m", + "pkg.broker"] if where == "a-link-inside-in-the-middle-of-a-chain": # r4180041184: outside -> repository -> outside, as the script return [sys.executable, str(_chain(served, tool, relative=False))] @@ -3255,6 +3263,7 @@ def _chain(served, tool: Path, *, relative: bool, "bare-word-after-env-changes-directory", "dotted-module-after-env-changes-directory", "dotted-module-through-a-package-link", + "dotted-module-linked-at-its-last-name", "a-link-inside-in-the-middle-of-a-chain", "a-relative-chain-through-the-repository", "a-chain-through-the-repository-as-the-program", @@ -4582,6 +4591,8 @@ def test_A5_an_intake_broker_the_rules_refuse_is_not_offered(served): "Rscript-e": ["Rscript", "-e", "x"], "R-e": ["R", "--no-echo", "-e", "x"], "R-file-then-e": ["R", "-f", "/opt/opendox-test/x.R", "-e", "x"], + # Lane openXfactory-3 D7 F1, holder ruled fix now (#656 5988369111) + "raku-e": ["raku", "-e", "x"], "deno-quiet-eval": ["deno", "--quiet", "eval", "x"], "deno-q-eval": ["deno", "-q", "eval", "x"], "deno-log-level-eval": ["deno", "--log-level", "info", "eval", "x"], @@ -4720,6 +4731,7 @@ def test_A2_an_inline_script_trusted_before_the_rule_never_runs( "julia", "-t", "2", str(served.broker), "-e", "x"], "julia-attached-target-then-a-file": lambda served: [ "julia", "-Ccore-avx2", str(served.broker)], + "raku-given-a-file": lambda served: ["raku", "/opt/x.raku"], "R-given-a-file": lambda served: [ "R", "-f", str(served.broker), "--args", "-e", "x"], "deno-quiet-run": lambda served: [ From fbfd50150a96c5af88e1fe63a18e93d1c7528504 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:58:25 +0000 Subject: [PATCH 11/16] T100 follow-on: Copilot's fifth review, under the holder's ruling 5988818366 (plan 034) Copilot's fifth review of openDox-code#86 (review 5410336597, at c00a8fd7), all 5 threads, and the writer's sibling finding, as the holder ruled on openxFactory#656 comment 5988818366. 1. A dotted -m judges every prefix of its name as a module file with every import suffix, under every root, since a parent that is a plain module runs before the import finds it is no package (_module_paths; r4181006328). 2. A long option the interpreter table does not know, given without =, is read both ways, fail-closed: as a flag, and as taking the next member as its value, the scan going on (r4181006345, node --v8-pool-size 1 -e). The strictness is accepted: node --no-warnings /opt/x.js -e is refused. A member read as a value is still judged as a path. 3. Every xargs is refused as unreadable, after the in-repository and inline-script judgments, so each keeps its name: xargs -a /args is in the repository, xargs -n 1 python3 -c an inline script (_builds_its_command; r4181006365). 4. env's assignments are read as GNU env reads them: any member holding a = after its options and before the command, whatever its name, its value judged as a path (_is_assignment; r4181006390). 5. The sibling: any VAR=value given to sudo is refused as unreadable, PATH= included, fail-closed. 6. THE ACCEPTED LIMIT (r4181006277), recorded in in_repository_argv's docstring with 5988088910's: a module -m finds on the interpreter's own default import path (site-packages, a .pth file, an editable install) linking into the repository. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 130 +++++++++++++++++++++++++----- tests/test_model_binding_trust.py | 100 ++++++++++++++++++++++- 2 files changed, 209 insertions(+), 21 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index d1720b7a..de4ae45a 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -333,9 +333,11 @@ #: more than one name; an option after which what runs cannot be judged #: from its words (`env --argv0`, `sudo --chroot`, `sudo -i`); a launcher #: given a second working directory (`env -C`, counting an `env -S` -#: string's, or `sudo -D`; ruling 5988088910); launchers nested past what -#: is unwrapped; or a path whose symbolic links pass the kernel's own bound, -#: or loop (`_LINK_HOPS`; ruling 5986391296). What it runs cannot be judged, so it is +#: string's, or `sudo -D`; ruling 5988088910); an assignment given to sudo +#: (`sudo A=b`, `PATH=` included), which sudo's policy honours or not; an +#: xargs, which builds what it runs from its input (ruling 5988818366); +#: launchers nested past what is unwrapped; or a path whose symbolic links +#: pass the kernel's own bound, or loop (`_LINK_HOPS`; ruling 5986391296). What it runs cannot be judged, so it is #: refused FAIL-CLOSED, with the inline-script remedy. (An `env -S` string #: env would not split as a shell does is an inline script: #: `REASON_INLINE_SCRIPT`.) @@ -343,8 +345,10 @@ "its broker command cannot be read to the program it runs (a launcher " "option it does not have, or has by more than one name, an option after " "which what runs cannot be judged, a launcher given two working " - "directories, launchers nested too deeply, or a path whose links go " - "deeper than the system follows), so what it runs cannot be judged") + "directories, an assignment given to sudo, xargs, which builds what it " + "runs from its input, launchers nested too deeply, or a path whose " + "links go deeper than the system follows), so what it runs cannot be " + "judged") #: What an operator is told to do about such a binding. REMEDY_INLINE_SCRIPT = ( @@ -989,7 +993,10 @@ def _python_roots(context: _Context, *, root: Path | str) -> list[str]: the inherited entries after the filter or whatever a launcher assigned or cleared, a relative entry (or an empty one) read from the start directory (Copilot at openDox-code#86, r4180717772; the holder's ruling, - openxFactory#656 comment 5988088910).""" + openxFactory#656 comment 5988088910). Not the interpreter's own default + import path (its site-packages, a `.pth` file, an editable install): + the accepted limit `in_repository_argv` records (r4181006277; ruling + 5988818366).""" entries = (context.pythonpath if context.pythonpath is not None else _inherited_pythonpath(root=root)) return [context.cwd, *(os.path.join(context.cwd, entry) @@ -1131,7 +1138,11 @@ def _module_paths(name: str, *, roots: list[str]) -> list[str]: whether or not a file is there yet: its whole dotted name as a package directory (`a/b/c`) and as a module with every import suffix (`a/b/c.py`, `a/b/c.pyc`, `a/b/c..so`; `_MODULE_SUFFIXES`; - r4179241555, r4180041203); every package's initializer on the way + r4179241555, r4180041203); every prefix of the dotted name as a module + file with every suffix (`a.py`, `a/b.py`, ...), since a parent that is + a plain module runs before the import finds it is no package (r4181006328; + the holder's ruling, openxFactory#656 comment 5988818366); every + package's initializer on the way (`a/__init__.py`, `a/b/__init__.py`, ...), which runs first; and the final package's `__main__`, which `-m` runs for a package (r4180717725; the holder's ruling, openxFactory#656 comment @@ -1145,6 +1156,7 @@ def _module_paths(name: str, *, roots: list[str]) -> list[str]: found += [base, *(base + suffix for suffix in _MODULE_SUFFIXES)] for count in range(1, len(parts) + 1): package = os.path.join(root, *parts[:count]) + found += [package + suffix for suffix in _MODULE_SUFFIXES] found += [os.path.join(package, "__init__" + suffix) for suffix in _MODULE_SUFFIXES] found += [os.path.join(base, "__main__" + suffix) @@ -1256,7 +1268,18 @@ def in_repository_argv(members, *, root: Path | str) -> str | None: context they are read in, and the command it starts is judged as a command of its own, in the context the launchers left it. A file a launcher writes inside the repository is refused too, an accepted - strictness.""" + strictness. + + THE ACCEPTED LIMIT (the holder's rulings, openxFactory#656 comments + 5988088910 and 5988818366): code imported BY NAME from a directory + outside the repository that holds links into it is not judged, which + only an operator can set up: a script outside the repository importing + from such a directory (Copilot at openDox-code#86, r4180717772), and a + module `-m` finds on the interpreter's own default import path, its + site-packages, a `.pth` file or an editable install (r4181006277). A + `-m` module is judged under its start directory and its effective + `PYTHONPATH` (`_python_roots`), and so is every package and every + parent module on the way (`_module_paths`).""" try: return _in_repository_member(members, root=root) except _TooManyLinks as deep: @@ -1498,7 +1521,9 @@ def _launcher(short: str, longs: str = "", *, operands: int = 0, #: `taskset`, and wrappers of the same class: `time`, `xargs`, `busybox` #: (whose first operand is the applet it runs), `flock`, `sudo`, `doas`. #: Each is read by its own getopt grammar, the GNU coreutils, findutils -#: and util-linux ones where there are several (C1). +#: and util-linux ones where there are several (C1). An xargs is unwrapped +#: so what it names is judged, and is then refused as unreadable whatever +#: it starts (`_builds_its_command`; ruling 5988818366). _LAUNCHERS: dict[str, _Launcher] = { "env": _launcher( "a:C:iS:u:v0", @@ -1559,8 +1584,14 @@ def _launcher(short: str, longs: str = "", *, operands: int = 0, #: openDox-code#86, r4179366319). _LAUNCHER_DEPTH = 32 -#: `env NAME=value`: an assignment, whose value is judged as a path. -_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=") +def _is_assignment(member: str) -> bool: + """Whether `member`, after env's options and before the command, is an + assignment, as GNU env reads one: any member holding a `=`, whatever + its name (`A-B=x`, `1A=x`; Copilot at openDox-code#86, r4181006390; the + holder's ruling, openxFactory#656 comment 5988818366). Its value is + judged as a path.""" + return "=" in member + #: `nice -5`, `nice --5`, `nice -+5`: an adjustment, nice's own option. _NUMERIC_OPTION = re.compile(r"-[-+]?[0-9]+") @@ -1579,8 +1610,8 @@ class _Unwrapped(NamedTuple): `unreadable` is a member that cannot be read to the program it runs: an option its launcher does not have, or has by more than one name, an option after which a name cannot be judged, an `env -S` string env would - not split as a shell does, or a launcher still left at - `_LAUNCHER_DEPTH`.""" + not split as a shell does, an assignment given to sudo, or a launcher + still left at `_LAUNCHER_DEPTH`.""" launchers: tuple[tuple[str, _Context], ...] values: tuple[tuple[str, _Context], ...] @@ -1728,7 +1759,8 @@ def _unwrapped(members) -> _Unwrapped: from the context every broker starts in, as each changes it: `env -C` and `sudo -D`/`--chdir` move the working directory, and `env PATH=...`, `env -i` and `env -u PATH` change the search path. Each launcher's - operands are judged as its values are.""" + operands are judged as its values are, and so is every value env + assigns (`_is_assignment`).""" command = tuple(members) context = _broker_context() launchers: list[tuple[str, _Context]] = [] @@ -1756,8 +1788,18 @@ def _unwrapped(members) -> _Unwrapped: command = (command[0],) + tuple(split) + rest[index:] continue chdirs = [0] # the next launcher is another program + if name == "sudo" and index < len(rest) and _is_assignment( + rest[index]): + # sudo's `VAR=value`: whether sudo honours it is its policy's + # (`env_reset`, `secure_path`), not the command's words, so it + # is refused as unreadable, `PATH=` included (FAIL-CLOSED; the + # writer's sibling of r4181006390; the holder's ruling, + # openxFactory#656 comment 5988818366) + return _Unwrapped(tuple(launchers), tuple(values), (), context, + unreadable=rest[index], + unreadable_because=REASON_UNREADABLE_COMMAND) if name == "env": - while index < len(rest) and _ASSIGNMENT.match(rest[index]): + while index < len(rest) and _is_assignment(rest[index]): variable, assigned = rest[index].split("=", 1) # A search path's every directory is judged (`PATH=a:b`). values.extend((part, context) @@ -1839,14 +1881,40 @@ def _gives_an_inline_script(name: str, rest: tuple[str, ...]) -> bool: (`-Sc`, `-nle`) are read, and an option's value is skipped; and only until its script's operand, so an option given to the script itself (`python /opt/broker.py -c profile`) is the script's (Copilot at - openDox-code#86, r4179241583, r4179241614).""" + openDox-code#86, r4179241583, r4179241614). + + A long option the table does not know, given without `=`, is read both + ways, FAIL-CLOSED: as a flag, and as taking the next member as its + value, the scan going on past it (`node --v8-pool-size 1 -e ...`; + Copilot at openDox-code#86, r4181006345; the holder's ruling, + openxFactory#656 comment 5988818366). Either reading that reaches an + inline script gives one, an accepted strictness (`node --no-warnings + /opt/x.js -e ...` is refused). A member read as a value is still judged + as a path (`in_repository_argv` judges every member).""" if name == "deno": return _deno_subcommand(rest) == "eval" spec = _INTERPRETERS.get(name) if spec is None: return False - operands = 0 - index = 0 + pending = [(0, 0)] + read: set[tuple[int, int]] = set() + while pending: + start = pending.pop() + if start in read: + continue + read.add(start) + if _reads_an_inline_script(spec, rest, *start, pending=pending): + return True + return False + + +def _reads_an_inline_script(spec: _Interpreter, rest: tuple[str, ...], + index: int, operands: int, *, + pending: list[tuple[int, int]]) -> bool: + """One reading for `_gives_an_inline_script`, from member `index` of + `rest` with `operands` of the program's own operands read: whether it + reaches an inline script. Each other reading it finds, an unknown long + option's value, is added to `pending`, as where to read from next.""" while index < len(rest): member = rest[index] index += 1 @@ -1862,6 +1930,10 @@ def _gives_an_inline_script(name: str, rest: tuple[str, ...]) -> bool: return False if option in spec.long_values and not equals: index += 1 + elif not equals: + # an option the table does not know may take the next + # member as its value: read that way as well (r4181006345) + pending.append((index + 1, operands)) continue if len(member) > 1 and (member[0] == "-" or ( spec.plus and member[0] == "+")): @@ -1938,6 +2010,16 @@ def _inline_member(members) -> str | None: return None +def _builds_its_command(unwrapped: _Unwrapped) -> bool: + """Whether a launcher `unwrapped` holds is xargs, which builds the + command it runs from its input: openDox's request on its standard + input, or an argument file's contents, neither of which can be judged + (Copilot at openDox-code#86, r4181006365, `xargs -a FILE -I S python3 + S`; the holder's ruling, openxFactory#656 comment 5988818366).""" + return any(_launcher_name(launcher, context=context) == "xargs" + for launcher, context in unwrapped.launchers) + + def broker_command_refused(members, *, root: Path | str | None) -> str | None: """Why the broker command `members` may never be trusted, or None: it @@ -1945,8 +2027,10 @@ def broker_command_refused(members, *, `env -S` string env would not split as a shell does is `REASON_INLINE_SCRIPT`), it gives a shell or an interpreter an inline script (`REASON_INLINE_SCRIPT`), or, at a known `root`, it names a file inside - the served repository (`REASON_IN_REPOSITORY`). Asked where trust is - recorded and wherever it is judged, and of the console intake's + the served repository (`REASON_IN_REPOSITORY`); or, after those, it runs + an xargs, whose command is built from input that cannot be judged + (`REASON_UNREADABLE_COMMAND`; `_builds_its_command`). Asked where trust + is recorded and wherever it is judged, and of the console intake's broker.""" unwrapped = _unwrapped(members) if unwrapped.unreadable is not None: @@ -1965,6 +2049,12 @@ def broker_command_refused(members, *, return REASON_UNREADABLE_COMMAND if named is not None: return REASON_IN_REPOSITORY + if _builds_its_command(unwrapped): + # FAIL-CLOSED, and AFTER the inline-script and in-repository + # judgments, so each keeps its name: `xargs -a /args ...` is + # in the repository and `xargs -n 1 python3 -c ...` an inline + # script (F16.1 as T007 batch P amends it; ruling 5988818366) + return REASON_UNREADABLE_COMMAND return None diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 7af2b213..e5663309 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -3183,6 +3183,41 @@ def _in_repository_argv(served, where, monkeypatch): monkeypatch.setenv("PATH", os.path.relpath(program.parent, broker_cwd) + os.pathsep + os.environ.get("PATH", "")) return [program.name] + if where == "parent-module-on-an-assigned-pythonpath": + # r4181006328: `-m parent.child` runs `parent.py` before it finds + # `parent` is no package + library = served.tmp / "library" + library.mkdir() + (library / "parent.py").symlink_to(tool) + return ["env", f"PYTHONPATH={library}", sys.executable, "-m", + "parent.child"] + if where == "parent-module-in-the-start-directory": + elsewhere = served.tmp / "elsewhere" + elsewhere.mkdir() + (elsewhere / "parent.py").symlink_to(tool) + return ["env", "-C", str(elsewhere), sys.executable, "-m", + "parent.child"] + if where == "dotted-package-main-linked-into-the-repository": + # `-m pkg.sub` runs `pkg/sub/__main__.py`, under packages outside + elsewhere = served.tmp / "elsewhere" + (elsewhere / "pkg" / "sub").mkdir(parents=True) + (elsewhere / "pkg" / "sub" / "__main__.py").symlink_to(tool) + return ["env", "-C", str(elsewhere), sys.executable, "-m", + "pkg.sub"] + if where == "program-past-an-assignment-whose-name-is-no-identifier": + # r4181006390: GNU env reads `A-B=x` as an assignment, so the + # program is the next member, found on the assigned `PATH` + os.chmod(tool, 0o755) + links = served.tmp / "links" + links.mkdir() + (links / "broker").symlink_to(tool) + return ["env", f"PATH={links}", "A-B=x", "broker"] + if where == "value-of-an-assignment-whose-name-is-no-identifier": + return ["env", f"A-B={tool}", sys.executable, str(served.broker)] + if where == "member-an-unknown-option-may-take": + # 5988818366: a member read as an unknown option's value is still + # judged as a path + return ["node", "--frobnicate", str(tool)] if where == "search-path-assigned-through-a-link": # r4179366288: `env PATH=...` is the path the program is found on. program = served.repo / "bin" / "opref-linked" @@ -3275,6 +3310,12 @@ def _chain(served, tool: Path, *, relative: bool, "module-on-an-assigned-pythonpath", "module-on-a-relative-pythonpath-entry", "module-on-the-inherited-pythonpath", + "parent-module-on-an-assigned-pythonpath", + "parent-module-in-the-start-directory", + "dotted-package-main-linked-into-the-repository", + "program-past-an-assignment-whose-name-is-no-identifier", + "value-of-an-assignment-whose-name-is-no-identifier", + "member-an-unknown-option-may-take", "symlink-in-the-repository-to-outside", "relative-search-path-entry", "climbing-out-of-a-link", @@ -3350,6 +3391,34 @@ def test_R4_one_working_directory_per_launcher_is_judged(served): argv, root=served.repo) is None, argv +def test_R5_xargs_is_refused_after_the_other_two_judgments(served): + """r4181006365; the holder's ruling, #656 5988818366: every xargs is + refused as unreadable, but only after the in-repository and + inline-script judgments, so each keeps its name (F16.1 as T007 batch P + amends it); an xargs a launcher starts, or an `env -S` string names, is + one too, and so is one judged with no root.""" + trust_mod = _trust_mod() + arguments = served.repo / "args" + arguments.write_text("x\n", encoding="utf-8") + outside = "/opt/opendox-test/broker" + for argv, reason in ( + (["xargs", "-a", str(arguments), outside], + trust_mod.REASON_IN_REPOSITORY), + (["xargs", "-n", "1", "python3", "-c", "x"], + trust_mod.REASON_INLINE_SCRIPT), + (["xargs", outside], trust_mod.REASON_UNREADABLE_COMMAND), + (["nice", "xargs", outside], + trust_mod.REASON_UNREADABLE_COMMAND), + (["env", "-S", f"xargs {outside}"], + trust_mod.REASON_UNREADABLE_COMMAND)): + assert trust_mod.broker_command_refused( + argv, root=served.repo) == reason, argv + assert trust_mod.broker_command_refused(["xargs", outside], root=None) == ( + trust_mod.REASON_UNREADABLE_COMMAND) + assert trust_mod.broker_command_refused(["nice", outside], + root=served.repo) is None + + def test_R4_the_effective_pythonpath_is_what_the_broker_has( served, monkeypatch): """r4180717772; the holder's ruling, #656 5988088910: an inherited @@ -4581,6 +4650,14 @@ def test_A5_an_intake_broker_the_rules_refuse_is_not_offered(served): "runuser-command": ["runuser", "-u", "bob", "--command=x"], "pwsh-policy-then-command": ["pwsh", "-ExecutionPolicy", "Bypass", "-Command", "x"], + # r4181006345; the holder's ruling, #656 5988818366: a long option the + # table does not know is read as a flag and as taking the next member + "node-unknown-value-option-then-e": ["node", "--v8-pool-size", "1", + "-e", "x", "--"], + "node-flag-then-e": ["node", "--no-warnings", "-e", "x"], + "pwsh-flag-then-command": ["pwsh", "-NoProfile", "-Command", "x"], + "node-flag-a-file-then-e": ["node", "--no-warnings", "/opt/x.js", "-e", + "x"], "pwsh-encoded": ["powershell", "-EncodedCommand", "eAA="], "fish-C": ["fish", "-C", "x"], "bash-plus-o-then-c": ["bash", "+o", "posix", "-c", "x"], @@ -4732,6 +4809,13 @@ def test_A2_an_inline_script_trusted_before_the_rule_never_runs( "julia-attached-target-then-a-file": lambda served: [ "julia", "-Ccore-avx2", str(served.broker)], "raku-given-a-file": lambda served: ["raku", "/opt/x.raku"], + "node-flag-then-a-file": lambda served: [ + "node", "--no-warnings", str(served.broker)], + "node-flag-a-file-and-its-own-options": lambda served: [ + "node", "--no-warnings", str(served.broker), "--flag", "x"], + "flock-unknown-option-then-a-program": lambda served: [ + "flock", "/tmp/opendox-lock", "--verbose", sys.executable, + str(served.broker), "-c", "x"], "R-given-a-file": lambda served: [ "R", "-f", str(served.broker), "--args", "-e", "x"], "deno-quiet-run": lambda served: [ @@ -4987,6 +5071,19 @@ def _unreadable_command(served, monkeypatch, case): "env-chdir-again-in-a-split-string": ["env", "-C", "/tmp", "-S", "-C usr /bin/true"], "sudo-chdir-twice": ["sudo", "-D", "/tmp", "--chdir=/usr", "/bin/true"], + # r4181006365; the holder's ruling, #656 5988818366: what xargs runs is + # built from its input, so every xargs is unreadable, after the + # in-repository and inline-script judgments + "xargs-building-its-command": ["xargs", "-a", "/opt/opendox-test/args", + "-I", "SCRIPT", "python3", "SCRIPT"], + "xargs-and-a-program-outside": ["xargs", "/opt/opendox-test/broker"], + "xargs-abbreviated-option": ["xargs", "--max-a", "1", + "/opt/opendox-test/broker"], + # the writer's sibling, 5988818366: any assignment given to sudo + "sudo-assignment": ["sudo", "A=b", "/bin/true"], + "sudo-path-assignment": ["sudo", "PATH=/opt/opendox-test/bin", "true"], + "sudo-option-then-assignment": ["sudo", "-u", "bob", "A=b", + "/bin/true"], } @@ -5128,7 +5225,8 @@ def test_C1_a_launchers_own_options_are_read_as_its_getopt_reads_them( ["nice", "--adj=5", *program], ["timeout", "--sig=KILL", "5", *program], ["stdbuf", "--out", "L", *program], - ["xargs", "--max-a", "1", *program], + ["env", "A-B=x", *program], + ["env", "1A=x", "A.B=y", *program], ["sudo", "--user=bob", *program], ["env", "--default-signal", *program], ["env", "--help"]): From e74eb9fb5f2270a818902a80d28a777a298b993e Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:14:06 +0000 Subject: [PATCH 12/16] T100 follow-on: one walk judges every module file of a dotted -m (plan 034) The whole dotted name's own module file is the last prefix's, so the walk over every prefix (r4181006328; the holder's ruling, openxFactory#656 comment 5988818366) judges it, and it is no longer listed twice. The round-5 mutants found the duplicate: "a sourceless module is not judged" had become equivalent. It is now anchored on the walk, and two more mutants pin it: a parent's module file not judged, and the whole name's own not judged. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index de4ae45a..4da39ad0 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -1153,8 +1153,10 @@ def _module_paths(name: str, *, roots: list[str]) -> list[str]: found: list[str] = [] for root in roots: base = os.path.join(root, *parts) - found += [base, *(base + suffix for suffix in _MODULE_SUFFIXES)] + found.append(base) for count in range(1, len(parts) + 1): + # each prefix, the whole name last, as a module file and as a + # package's initializer, with every suffix package = os.path.join(root, *parts[:count]) found += [package + suffix for suffix in _MODULE_SUFFIXES] found += [os.path.join(package, "__init__" + suffix) From fe56c0c4017a47d50c8e394c343600fd7ed64bbd Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:25:15 +0000 Subject: [PATCH 13/16] T100 follow-on: pin pwsh reading every member before its command (plan 034) The round-5 rule for an option the interpreter table does not know (r4181006345; the holder's ruling, openxFactory#656 comment 5988818366) reads pwsh -ExecutionPolicy Bypass -Command both ways, so that case no longer pinned pwsh's operands=None, and the round-5 mutants found it. A word before -Command, which pwsh reads past, now pins it: refused, fail-closed, as before. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_binding_trust.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index e5663309..0263cafe 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -4656,6 +4656,9 @@ def test_A5_an_intake_broker_the_rules_refuse_is_not_offered(served): "-e", "x", "--"], "node-flag-then-e": ["node", "--no-warnings", "-e", "x"], "pwsh-flag-then-command": ["pwsh", "-NoProfile", "-Command", "x"], + # pwsh reads every member before its command (`operands=None`), + # fail-closed: a word before `-Command` hides none + "pwsh-a-word-then-command": ["pwsh", "x", "-Command", "y"], "node-flag-a-file-then-e": ["node", "--no-warnings", "/opt/x.js", "-e", "x"], "pwsh-encoded": ["powershell", "-EncodedCommand", "eAA="], From 5324ca4cee4bf23693e49087ba8182a746d3369a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:31:13 +0000 Subject: [PATCH 14/16] T100 follow-on: Copilot's sixth review, under the holder's ruling 5989835334 (plan 034) Copilot's sixth review of openDox-code#86 (review 5411026353, at fe56c0c4): its thread and its summary's cost item, as the holder ruled on openxFactory#656 comment 5989835334. 1. A source's bytecode cache is judged as resolved, for every source candidate a -m name has (each module file, each __init__, the __main__): this interpreter's cache name at optimization levels none, 1 and 2 (importlib.util.cache_from_source), in the __pycache__ beside the source whatever cache prefix this process has, whether or not a file is there yet; and every __pycache__/.*.pyc already there, for other interpreters' tags (_bytecode_caches; r4181465499). 2. The cost bounds, fail-closed (_unbounded_member): a member longer than 4096 characters (PATH_MAX), or holding more than 32 absolute paths, is refused as unreadable before anything is judged. in_repository_argv returns it unjudged, and names_a_path_inside holds an environment value to the same bounds, so no such value reaches a broker. The bindings document lives in the served repository, so a pull must not be able to hang the judgment: one member of 400 absolute paths took 29 s at fe56c0c4. 3. The both-ways option scan reads each state (a member and the operands read before it) at most once, across every reading, so it is linear: 4000 options node does not know took seconds, growing with their square, and now take milliseconds. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_trust.py | 149 +++++++++++++++++++++++++----- tests/test_model_binding_trust.py | 133 +++++++++++++++++++++++++- 2 files changed, 259 insertions(+), 23 deletions(-) diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 4da39ad0..22149651 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -109,6 +109,7 @@ import errno import hashlib import importlib.machinery +import importlib.util import json import os import re @@ -336,8 +337,10 @@ #: string's, or `sudo -D`; ruling 5988088910); an assignment given to sudo #: (`sudo A=b`, `PATH=` included), which sudo's policy honours or not; an #: xargs, which builds what it runs from its input (ruling 5988818366); -#: launchers nested past what is unwrapped; or a path whose symbolic links -#: pass the kernel's own bound, or loop (`_LINK_HOPS`; ruling 5986391296). What it runs cannot be judged, so it is +#: launchers nested past what is unwrapped; a path whose symbolic links +#: pass the kernel's own bound, or loop (`_LINK_HOPS`; ruling 5986391296); +#: or a member past the bounds of what is judged (`_unbounded_member`; +#: ruling 5989835334). What it runs cannot be judged, so it is #: refused FAIL-CLOSED, with the inline-script remedy. (An `env -S` string #: env would not split as a shell does is an inline script: #: `REASON_INLINE_SCRIPT`.) @@ -346,9 +349,10 @@ "option it does not have, or has by more than one name, an option after " "which what runs cannot be judged, a launcher given two working " "directories, an assignment given to sudo, xargs, which builds what it " - "runs from its input, launchers nested too deeply, or a path whose " - "links go deeper than the system follows), so what it runs cannot be " - "judged") + "runs from its input, launchers nested too deeply, a path whose links " + "go deeper than the system follows, or a member longer than " + "the system's path limit or holding more than 32 paths), so what it " + "runs cannot be judged") #: What an operator is told to do about such a binding. REMEDY_INLINE_SCRIPT = ( @@ -1086,6 +1090,34 @@ def _traversed(path: str) -> list[Path]: return names +#: The bounds on one member of a broker command, past which it is refused +#: as unreadable, FAIL-CLOSED, before anything is judged: a member longer +#: than the system's own path limit (PATH_MAX), and one holding more +#: absolute paths than `_MAX_ABSOLUTE_PATHS` (every separator begins one, +#: `_candidates`). The bindings document lives in the served repository, +#: so a pull must not be able to hang the judgment (Copilot at +#: openDox-code#86, review 5411026353; the holder's ruling, +#: openxFactory#656 comment 5989835334). +_MAX_MEMBER_LENGTH = 4096 +_MAX_ABSOLUTE_PATHS = 32 + +#: The separators a path is written with on this system. +_SEPARATORS = tuple(sep for sep in (os.sep, os.altsep) if sep) + + +def _unbounded_member(members) -> str | None: + """The first member of a broker command `members` past the bounds of + what is judged (`_MAX_MEMBER_LENGTH`, `_MAX_ABSOLUTE_PATHS`), or + None.""" + for member in members: + if len(member) > _MAX_MEMBER_LENGTH: + return member + if sum(member.count(sep) for sep in _SEPARATORS) > ( + _MAX_ABSOLUTE_PATHS): + return member + return None + + def _has_a_separator(candidate: str) -> bool: """Whether `candidate` is a path rather than a bare word.""" return candidate in (".", "..") or os.sep in candidate or bool( @@ -1130,6 +1162,47 @@ def _candidates(member: str, *, option: bool) -> list[str]: #: the holder's ruling, openxFactory#656 comment 5986391296). _MODULE_SUFFIXES: tuple[str, ...] = tuple(importlib.machinery.all_suffixes()) +#: The suffixes a module's source is read from (`.py`), whose bytecode +#: cache Python imports in its place (`_bytecode_caches`). +_SOURCE_SUFFIXES: tuple[str, ...] = tuple( + importlib.machinery.SOURCE_SUFFIXES) + +#: The optimization levels a bytecode cache is written at: none, `-O` and +#: `-OO` (`importlib.util.cache_from_source`). +_OPTIMIZATIONS = ("", 1, 2) + + +def _bytecode_caches(source: str) -> list[str]: + """The bytecode caches Python could import the module whose source is + `source` from, in its place (Copilot at openDox-code#86, r4181465499; + the holder's ruling, openxFactory#656 comment 5989835334): this + interpreter's own cache name at every optimization level + (`importlib.util.cache_from_source`), whether or not a file is there + yet, in the `__pycache__` beside the source, where a broker's + interpreter writes and reads it whatever cache prefix this process + has; and every cache already there under another interpreter's tag + (`__pycache__/.*.pyc`). Each is judged as resolved, links + followed.""" + directory, name = os.path.split(source) + pycache = os.path.join(directory, "__pycache__") + caches: list[str] = [] + for optimization in _OPTIMIZATIONS: + try: + cached = importlib.util.cache_from_source( + source, optimization=optimization) + except NotImplementedError: # no cache tag: none written + break + caches.append(os.path.join(pycache, os.path.basename(cached))) + stem = name.rpartition(".")[0] + try: + present = sorted(os.listdir(pycache)) + except (OSError, ValueError): + present = [] + caches += [os.path.join(pycache, entry) for entry in present + if entry.startswith(stem + ".") and entry.endswith(".pyc")] + return caches + + def _module_paths(name: str, *, roots: list[str]) -> list[str]: """The files a module named after `-m` could be imported from, under @@ -1146,7 +1219,9 @@ def _module_paths(name: str, *, roots: list[str]) -> list[str]: (`a/__init__.py`, `a/b/__init__.py`, ...), which runs first; and the final package's `__main__`, which `-m` runs for a package (r4180717725; the holder's ruling, openxFactory#656 comment - 5988088910), each with every suffix. A namespace package needs no + 5988088910), each with every suffix; and each source's bytecode cache + (`_bytecode_caches`; r4181465499; the holder's ruling, + openxFactory#656 comment 5989835334). A namespace package needs no `__init__`, so a dotted name can reach any directory under a root; every name on the way is one `_traversed` judges, links followed.""" parts = name.split(".") @@ -1161,8 +1236,16 @@ def _module_paths(name: str, *, roots: list[str]) -> list[str]: found += [package + suffix for suffix in _MODULE_SUFFIXES] found += [os.path.join(package, "__init__" + suffix) for suffix in _MODULE_SUFFIXES] + for source in _SOURCE_SUFFIXES: + # each source's bytecode cache, read in its place + found += _bytecode_caches(package + source) + found += _bytecode_caches( + os.path.join(package, "__init__" + source)) found += [os.path.join(base, "__main__" + suffix) for suffix in _MODULE_SUFFIXES] + found += [cache for source in _SOURCE_SUFFIXES + for cache in _bytecode_caches( + os.path.join(base, "__main__" + source))] return found @@ -1270,7 +1353,9 @@ def in_repository_argv(members, *, root: Path | str) -> str | None: context they are read in, and the command it starts is judged as a command of its own, in the context the launchers left it. A file a launcher writes inside the repository is refused too, an accepted - strictness. + strictness. A member past the bounds of what is judged + (`_unbounded_member`) is returned unjudged, FAIL-CLOSED (the holder's + ruling, openxFactory#656 comment 5989835334). THE ACCEPTED LIMIT (the holder's rulings, openxFactory#656 comments 5988088910 and 5988818366): code imported BY NAME from a directory @@ -1282,6 +1367,16 @@ def in_repository_argv(members, *, root: Path | str) -> str | None: `-m` module is judged under its start directory and its effective `PYTHONPATH` (`_python_roots`), and so is every package and every parent module on the way (`_module_paths`).""" + unbounded = _unbounded_member(members) + if unbounded is not None: + return unbounded + return _named_inside(members, root=root) + + +def _named_inside(members, *, root: Path | str) -> str | None: + """`in_repository_argv` for members within the bounds: the member that + names a file inside the served repository, or whose links pass + `_LINK_HOPS`, or None.""" try: return _in_repository_member(members, root=root) except _TooManyLinks as deep: @@ -1359,9 +1454,11 @@ def names_a_path_inside(value: str, *, root: Path | str) -> bool: rule for an option's value: from `BROKER_WORKING_DIRECTORY` and from the served root, every name on the way, links followed. A broker's environment carries no such value (F16.1 as T007 batch P amends it; - `doxbench_provider.broker_environment`).""" - return any(part and in_repository_argv( - ["opendox-environment", f"--value={part}"], root=root) is not None + `doxbench_provider.broker_environment`), and no part past the bounds of + what is judged either (`_unbounded_member`, FAIL-CLOSED; the holder's + ruling, openxFactory#656 comment 5989835334).""" + return any(part and (_unbounded_member([part]) is not None or _named_inside( + ["opendox-environment", f"--value={part}"], root=root) is not None) for part in value.split(os.pathsep)) @@ -1892,7 +1989,10 @@ def _gives_an_inline_script(name: str, rest: tuple[str, ...]) -> bool: openxFactory#656 comment 5988818366). Either reading that reaches an inline script gives one, an accepted strictness (`node --no-warnings /opt/x.js -e ...` is refused). A member read as a value is still judged - as a path (`in_repository_argv` judges every member).""" + as a path (`in_repository_argv` judges every member). Each state, a + member and the operands read before it, is read at most once across + every reading, so the scan is linear (the holder's ruling, + openxFactory#656 comment 5989835334).""" if name == "deno": return _deno_subcommand(rest) == "eval" spec = _INTERPRETERS.get(name) @@ -1901,23 +2001,26 @@ def _gives_an_inline_script(name: str, rest: tuple[str, ...]) -> bool: pending = [(0, 0)] read: set[tuple[int, int]] = set() while pending: - start = pending.pop() - if start in read: - continue - read.add(start) - if _reads_an_inline_script(spec, rest, *start, pending=pending): + if _reads_an_inline_script(spec, rest, *pending.pop(), + pending=pending, read=read): return True return False def _reads_an_inline_script(spec: _Interpreter, rest: tuple[str, ...], index: int, operands: int, *, - pending: list[tuple[int, int]]) -> bool: + pending: list[tuple[int, int]], + read: set[tuple[int, int]]) -> bool: """One reading for `_gives_an_inline_script`, from member `index` of `rest` with `operands` of the program's own operands read: whether it reaches an inline script. Each other reading it finds, an unknown long - option's value, is added to `pending`, as where to read from next.""" + option's value, is added to `pending`, as where to read from next. A + state in `read` was read already, by this reading or another, which + read on from it as this one would, so this one stops there.""" while index < len(rest): + if (index, operands) in read: + return False + read.add((index, operands)) member = rest[index] index += 1 if member == "--": @@ -2031,9 +2134,13 @@ def broker_command_refused(members, *, script (`REASON_INLINE_SCRIPT`), or, at a known `root`, it names a file inside the served repository (`REASON_IN_REPOSITORY`); or, after those, it runs an xargs, whose command is built from input that cannot be judged - (`REASON_UNREADABLE_COMMAND`; `_builds_its_command`). Asked where trust - is recorded and wherever it is judged, and of the console intake's - broker.""" + (`REASON_UNREADABLE_COMMAND`; `_builds_its_command`). A member past the + bounds of what is judged is refused as unreadable before anything else + is asked (`_unbounded_member`; the holder's ruling, openxFactory#656 + comment 5989835334). Asked where trust is recorded and wherever it is + judged, and of the console intake's broker.""" + if _unbounded_member(members) is not None: + return REASON_UNREADABLE_COMMAND unwrapped = _unwrapped(members) if unwrapped.unreadable is not None: return unwrapped.unreadable_because diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 0263cafe..425d8eb5 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -44,6 +44,8 @@ import contextlib import dataclasses import http.server +import importlib.machinery +import importlib.util import io import json import os @@ -3094,8 +3096,6 @@ def _in_repository_argv(served, where, monkeypatch): return ["env", "-C", str(elsewhere), sys.executable, "-m", "bytecode"] if where == "extension-module": - import importlib.machinery - suffix = importlib.machinery.EXTENSION_SUFFIXES[0] extension = tool.parent / f"native{suffix}" extension.write_bytes(b"") @@ -3183,6 +3183,50 @@ def _in_repository_argv(served, where, monkeypatch): monkeypatch.setenv("PATH", os.path.relpath(program.parent, broker_cwd) + os.pathsep + os.environ.get("PATH", "")) return [program.name] + if where.endswith("bytecode-cache-linked-into-the-repository"): + # r4181465499; the holder's ruling, #656 5989835334: Python imports + # a source's bytecode cache from `__pycache__` in its place + compiled = tool.parent / "broker.pyc" + compiled.write_bytes(b"") + elsewhere = served.tmp / "elsewhere" + package = elsewhere / "pkg" + package.mkdir(parents=True) + source, module = { + "module-bytecode-cache-linked-into-the-repository": ( + elsewhere / "broker.py", "broker"), + "initializer-bytecode-cache-linked-into-the-repository": ( + package / "__init__.py", "pkg.mod"), + "main-bytecode-cache-linked-into-the-repository": ( + package / "__main__.py", "pkg"), + "another-interpreters-bytecode-cache-linked-into-the-repository": ( + elsewhere / "broker.py", "broker"), + "prefixed-process-bytecode-cache-linked-into-the-repository": ( + elsewhere / "broker.py", "broker"), + }[where] + source.write_text("", encoding="utf-8") + (package / "mod.py").write_text("", encoding="utf-8") + name = Path(importlib.util.cache_from_source(str(source))).name + if where.startswith("another-interpreters"): + name = f"{source.stem}.cpython-399.pyc" + if where.startswith("prefixed-process"): + # a cache prefix this process has is not where a broker's own + # interpreter reads the cache from + monkeypatch.setattr(sys, "pycache_prefix", + str(served.tmp / "prefix")) + cache = source.parent / "__pycache__" / name + cache.parent.mkdir() + cache.symlink_to(compiled) + return ["env", "-C", str(elsewhere), sys.executable, "-m", module] + if where == "bytecode-cache-directory-linked-into-the-repository": + # no cache is there yet, and the directory it would be read from is + # a link into the repository + caches = tool.parent / "caches" + caches.mkdir() + elsewhere = served.tmp / "elsewhere" + elsewhere.mkdir() + (elsewhere / "broker.py").write_text("", encoding="utf-8") + (elsewhere / "__pycache__").symlink_to(caches) + return ["env", "-C", str(elsewhere), sys.executable, "-m", "broker"] if where == "parent-module-on-an-assigned-pythonpath": # r4181006328: `-m parent.child` runs `parent.py` before it finds # `parent` is no package @@ -3316,6 +3360,12 @@ def _chain(served, tool: Path, *, relative: bool, "program-past-an-assignment-whose-name-is-no-identifier", "value-of-an-assignment-whose-name-is-no-identifier", "member-an-unknown-option-may-take", + "module-bytecode-cache-linked-into-the-repository", + "initializer-bytecode-cache-linked-into-the-repository", + "main-bytecode-cache-linked-into-the-repository", + "another-interpreters-bytecode-cache-linked-into-the-repository", + "prefixed-process-bytecode-cache-linked-into-the-repository", + "bytecode-cache-directory-linked-into-the-repository", "symlink-in-the-repository-to-outside", "relative-search-path-entry", "climbing-out-of-a-link", @@ -3391,6 +3441,80 @@ def test_R4_one_working_directory_per_launcher_is_judged(served): argv, root=served.repo) is None, argv +@pytest.mark.parametrize("optimization", ["", 1, 2]) +def test_R6_a_cache_is_judged_by_name_where_its_directory_cannot_be_listed( + served, optimization): + """r4181465499; the holder's ruling, #656 5989835334: this + interpreter's cache name, at every optimization level, is judged + whether or not the directory it is in can be listed.""" + trust_mod = _trust_mod() + compiled = served.repo / "broker.pyc" + compiled.write_bytes(b"") + elsewhere = served.tmp / "elsewhere" + elsewhere.mkdir() + source = elsewhere / "broker.py" + source.write_text("", encoding="utf-8") + name = Path(importlib.util.cache_from_source( + str(source), optimization=optimization)).name + pycache = elsewhere / "__pycache__" + pycache.mkdir() + (pycache / name).symlink_to(compiled) + os.chmod(pycache, 0o300) # searchable, not listable + try: + with pytest.raises(OSError): + os.listdir(pycache) + assert trust_mod.broker_command_refused( + ["env", "-C", str(elsewhere), sys.executable, "-m", "broker"], + root=served.repo) == trust_mod.REASON_IN_REPOSITORY + finally: + os.chmod(pycache, 0o700) + + +def test_R6_a_member_past_the_bounds_is_refused_as_unreadable(served): + """The summary of review 5411026353; the holder's ruling, #656 + 5989835334: a member longer than PATH_MAX (4096), or holding more than + 32 absolute paths, is refused as unreadable before it is judged, and + `in_repository_argv` returns it unjudged, FAIL-CLOSED, so an + environment value past the bounds is no value a broker gets; a member + at each bound is judged.""" + trust_mod = _trust_mod() + outside = "/opt/opendox-test/broker" + for member in ("a" * 4097, "/a" * 33): + for argv in ([outside, member], [member]): + assert trust_mod.broker_command_refused( + argv, root=served.repo) == ( + trust_mod.REASON_UNREADABLE_COMMAND), len(member) + assert trust_mod.in_repository_argv( + [outside, member], root=served.repo) == member + assert trust_mod.names_a_path_inside(member, root=served.repo) + for member in ("a" * 4096, "/a" * 32): + assert trust_mod.broker_command_refused( + [outside, member], root=served.repo) is None, len(member) + assert not trust_mod.names_a_path_inside(member, root=served.repo) + + +def test_R6_the_old_worst_cases_are_judged_in_under_a_second(served): + """The summary of review 5411026353 ("quadratic memory allocation + before trust approval"); the holder's ruling, #656 5989835334: a pull + that delivers a bindings document must not hang the judgment. One + member of 400 absolute paths took 29 s at fe56c0c4, and 4000 options + node does not know took seconds, growing with their square; each is + judged now in under a second.""" + trust_mod = _trust_mod() + for argv, reason in ( + (["/opt/opendox-test/broker", "/x" * 400], + trust_mod.REASON_UNREADABLE_COMMAND), + (["/opt/opendox-test/broker", "/x" * 2048], + trust_mod.REASON_UNREADABLE_COMMAND), + (["node", *(["--a"] * 4000)], None), + (["node", *(["--a"] * 4000), "-e", "x"], + trust_mod.REASON_INLINE_SCRIPT)): + started = time.monotonic() + assert trust_mod.broker_command_refused( + argv, root=served.repo) == reason, argv[:2] + assert time.monotonic() - started < 1, argv[:2] + + def test_R5_xargs_is_refused_after_the_other_two_judgments(served): """r4181006365; the holder's ruling, #656 5988818366: every xargs is refused as unreadable, but only after the in-repository and @@ -5087,6 +5211,11 @@ def _unreadable_command(served, monkeypatch, case): "sudo-path-assignment": ["sudo", "PATH=/opt/opendox-test/bin", "true"], "sudo-option-then-assignment": ["sudo", "-u", "bob", "A=b", "/bin/true"], + # the summary of review 5411026353; the holder's ruling, #656 + # 5989835334: a member past the bounds of what is judged + "member-past-the-length-bound": ["/opt/opendox-test/broker", + "a" * 4097], + "member-past-the-path-bound": ["/opt/opendox-test/broker", "/a" * 33], } From 401b95878113662997a192c02b8b65e504d24e74 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:53:44 +0000 Subject: [PATCH 15/16] T100 follow-on: pin the cache judged beside its source under a process cache prefix (plan 034) A source's bytecode cache is judged in the __pycache__ beside it, where a broker's own interpreter reads it, whatever cache prefix this process has (r4181465499; the holder's ruling, openxFactory#656 comment 5989835334). The round-6 mutants found the prefixed-process case caught by the glob of caches already there alone. A __pycache__ that is a link into the repository, with no cache in it yet, under such a prefix, now pins it. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_binding_trust.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index 425d8eb5..ffe6e51f 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -3217,9 +3217,13 @@ def _in_repository_argv(served, where, monkeypatch): cache.parent.mkdir() cache.symlink_to(compiled) return ["env", "-C", str(elsewhere), sys.executable, "-m", module] - if where == "bytecode-cache-directory-linked-into-the-repository": + if where.startswith("bytecode-cache-directory-linked"): # no cache is there yet, and the directory it would be read from is - # a link into the repository + # a link into the repository; under a cache prefix this process has + # as well, which is not where a broker's interpreter reads it from + if where.endswith("under-a-process-cache-prefix"): + monkeypatch.setattr(sys, "pycache_prefix", + str(served.tmp / "prefix")) caches = tool.parent / "caches" caches.mkdir() elsewhere = served.tmp / "elsewhere" @@ -3366,6 +3370,7 @@ def _chain(served, tool: Path, *, relative: bool, "another-interpreters-bytecode-cache-linked-into-the-repository", "prefixed-process-bytecode-cache-linked-into-the-repository", "bytecode-cache-directory-linked-into-the-repository", + "bytecode-cache-directory-linked-under-a-process-cache-prefix", "symlink-in-the-repository-to-outside", "relative-search-path-entry", "climbing-out-of-a-link", From aecac8055496758c77365c8361b38e91a4fdd645 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:03:04 +0000 Subject: [PATCH 16/16] T100 follow-on: Copilot's seventh review and the judgment's work budget, under the holder's ruling 5990845570 (plan 034) Copilot's seventh review of openDox-code#86 (review 5411788138, at 401b9587), all 4 threads, and the writer's residual-cost measurements, as the holder ruled on openxFactory#656 comment 5990845570. 1. Re-reading an env -S string keeps the launcher's original member and the context it was judged in, so a launcher inside the repository is refused as in-repository however the string moves the working directory (r4182002510). 2. A bare program name after sudo or doas is refused as unreadable, fail-closed: the search path their policy picks (secure_path) is not judged, so an absolute path is required (r4182002567). 3. The inline scan also looks for each bare word after the program on the broker's filtered search path, keeping the start-directory check, so an alias a wrapper not in the launcher table runs is judged by what it resolves to (_search_context; r4182002637). 4. The work budget (r4182002696 and the writer's measurements): import roots are judged once each; one budget per command counts the names traversed, the module candidates before they are built, the directories searched, a resolved name's components, and every member and option letter a scan reads, and past it the command is refused as unreadable. A command's budget is 2,000 steps per member, at most 20,000, and a bindings document may declare at most 256 broker command members across its bindings (MAX_JUDGED_MEMBERS), refused by name past that, so a whole document's work is bounded too. What the operator's own environment holds (the inherited search path and module path, filtered) is no command's work. 5. So the steps bound the time: the walk keeps its names in a deque, and "inside the root" is a prefix of a name's text rather than a search of its parents, which was quadratic in the name's depth. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 17 +++ src/opendox/doxbench_trust.py | 245 +++++++++++++++++++++++++----- tests/test_model_binding_trust.py | 178 +++++++++++++++++++++- 3 files changed, 394 insertions(+), 46 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index bdb3683d..8006b759 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -643,6 +643,17 @@ def document_present(path: Path) -> bool: return False +#: The most broker command members one bindings document may declare +#: across its bindings, every one of which is judged where trust is asked +#: (`doxbench_trust.broker_command_refused`). Past it the document is +#: refused by name, FAIL-CLOSED: it lives in the served repository, so a +#: pull must not be able to hang verdict computation, and each command's +#: judgment is bounded (`doxbench_trust._WORK_BUDGET`) while their number +#: is bounded here (Copilot at openDox-code#86, r4182002696; the holder's +#: ruling, openxFactory#656 comment 5990845570). +MAX_JUDGED_MEMBERS = 256 + + def read_settings_document(path: Path, *, what: str, yaml, refused): """The YAML document at `path`, parsed, or a refusal BY NAME (`refused`, the caller's own refusal class) for one that cannot be read (T100 @@ -1225,6 +1236,12 @@ def _load(self) -> list[ModelProviderBinding]: f"the bindings document at {self.path} declares the id " f"{binding.id!r} twice") seen.add(binding.id) + judged = sum(len(binding.broker_argv) for binding in bindings) + if judged > MAX_JUDGED_MEMBERS: + raise BindingRefused( + f"the bindings document at {self.path} declares {judged} " + "broker command members across its bindings, more than the " + f"{MAX_JUDGED_MEMBERS} judged in one document") return bindings def _save(self, bindings: Iterable[ModelProviderBinding]) -> None: diff --git a/src/opendox/doxbench_trust.py b/src/opendox/doxbench_trust.py index 22149651..1a5b42c2 100644 --- a/src/opendox/doxbench_trust.py +++ b/src/opendox/doxbench_trust.py @@ -104,7 +104,9 @@ from __future__ import annotations +import collections import contextlib +import contextvars import dataclasses import errno import hashlib @@ -339,8 +341,10 @@ #: xargs, which builds what it runs from its input (ruling 5988818366); #: launchers nested past what is unwrapped; a path whose symbolic links #: pass the kernel's own bound, or loop (`_LINK_HOPS`; ruling 5986391296); -#: or a member past the bounds of what is judged (`_unbounded_member`; -#: ruling 5989835334). What it runs cannot be judged, so it is +#: a member past the bounds of what is judged (`_unbounded_member`; +#: ruling 5989835334); a bare program name after sudo or doas, found on a +#: search path their policy picks; or a command whose judgment would pass +#: its work budget (`_WORK_BUDGET`; ruling 5990845570). What it runs cannot be judged, so it is #: refused FAIL-CLOSED, with the inline-script remedy. (An `env -S` string #: env would not split as a shell does is an inline script: #: `REASON_INLINE_SCRIPT`.) @@ -350,9 +354,10 @@ "which what runs cannot be judged, a launcher given two working " "directories, an assignment given to sudo, xargs, which builds what it " "runs from its input, launchers nested too deeply, a path whose links " - "go deeper than the system follows, or a member longer than " - "the system's path limit or holding more than 32 paths), so what it " - "runs cannot be judged") + "go deeper than the system follows, a member longer than the " + "system's path limit or holding more than 32 paths, a bare program " + "name after sudo or doas, or more than its judgment may take), so what " + "it runs cannot be judged") #: What an operator is told to do about such a binding. REMEDY_INLINE_SCRIPT = ( @@ -987,8 +992,9 @@ def _inherited_pythonpath(*, root: Path | str) -> list[str]: "PYTHONPATH") if not inherited: return [] - return [entry for entry in inherited.split(os.pathsep) - if not (entry and names_a_path_inside(entry, root=root))] + return _outside_the_budget(lambda: [ + entry for entry in inherited.split(os.pathsep) + if not (entry and names_a_path_inside(entry, root=root))]) def _python_roots(context: _Context, *, root: Path | str) -> list[str]: @@ -1003,8 +1009,9 @@ def _python_roots(context: _Context, *, root: Path | str) -> list[str]: 5988818366).""" entries = (context.pythonpath if context.pythonpath is not None else _inherited_pythonpath(root=root)) - return [context.cwd, *(os.path.join(context.cwd, entry) - for entry in entries)] + # each once: a repeated entry is no other directory (r4182002696) + return list(dict.fromkeys([context.cwd, *( + os.path.join(context.cwd, entry) for entry in entries)])) def _which(name: str, context: _Context) -> str | None: @@ -1014,6 +1021,7 @@ def _which(name: str, context: _Context) -> str | None: it, never from this process's (r4179366288).""" search = context.path if context.path is not None else os.defpath for entry in search.split(os.pathsep): + _spend() candidate = os.path.join(context.cwd, entry, name) if os.path.isfile(candidate) and os.access(candidate, os.X_OK): return candidate @@ -1062,10 +1070,11 @@ def _traversed(path: str) -> list[Path]: links pass `_LINK_HOPS` raises `_TooManyLinks`.""" names: list[Path] = [] real = Path(os.sep) - pending = list(Path(path).parts[1:]) + pending = collections.deque(Path(path).parts[1:]) hops = 0 while pending: - part = pending.pop(0) + _spend() + part = pending.popleft() if part == os.curdir: continue if part == os.pardir: @@ -1085,7 +1094,7 @@ def _traversed(path: str) -> list[Path]: if os.path.isabs(target): real = Path(os.sep) parts = parts[1:] - pending[:0] = parts + pending.extendleft(reversed(parts)) names.append(real) return names @@ -1105,6 +1114,73 @@ def _traversed(path: str) -> list[Path]: _SEPARATORS = tuple(sep for sep in (os.sep, os.altsep) if sep) +#: The work judging one broker command may take, in steps: each name +#: looked up on the way to a path (`_traversed`), each candidate path a +#: `-m` name could be imported from, before it is built (`_module_paths`), +#: each directory a program is looked for in (`_which`), each entry of a +#: bytecode cache directory, and each member and option letter a scan reads. +#: Past it the command is refused as unreadable, FAIL-CLOSED: the bindings +#: document lives in the served repository, so a pull must not be able to +#: hang verdict computation (Copilot at openDox-code#86, r4182002696; the +#: holder's ruling, openxFactory#656 comment 5990845570). A command's +#: budget is `_WORK_PER_MEMBER` steps for each of its members, at most +#: `_WORK_BUDGET`, so the ceiling on the members one bindings document +#: declares (`doxbench_binding.MAX_JUDGED_MEMBERS`) bounds the work of the +#: whole document too. An ordinary command takes a few hundred steps; at +#: their slowest, deep paths, steps take some 15 microseconds each. +_WORK_PER_MEMBER = 2_000 +_WORK_BUDGET = 20_000 + +#: The steps left in the judgment being made, or None outside one. +_STEPS_LEFT: contextvars.ContextVar[list[int] | None] = ( + contextvars.ContextVar("opendox_trust_steps_left", default=None)) + + +class _OverBudget(Exception): + """A judgment that would pass `_WORK_BUDGET`.""" + + +def _spend(steps: int = 1) -> None: + """Spend `steps` of the judgment's budget. Raises `_OverBudget` past + it.""" + left = _STEPS_LEFT.get() + if left is None: + return + left[0] -= steps + if left[0] < 0: + raise _OverBudget() + + +def _within_budget(work, *, over, members=None): + """`work()` within one command's work budget: the one already being + spent, whose owner answers for it, or a new one for the command + `members` (`_WORK_PER_MEMBER` for each, at most `_WORK_BUDGET`; the + cap for none), where a judgment past it answers `over()` instead.""" + if _STEPS_LEFT.get() is not None: + return work() + steps = _WORK_BUDGET if members is None else min( + _WORK_BUDGET, _WORK_PER_MEMBER * max(1, len(members))) + token = _STEPS_LEFT.set([steps]) + try: + return work() + except _OverBudget: + return over() + finally: + _STEPS_LEFT.reset(token) + + +def _outside_the_budget(work): + """`work()` as no command's work: what the operator's own environment + holds (the search path and the module path a broker inherits, filtered + of the served root), which no pull chooses. Each judgment in it has a + budget of its own.""" + token = _STEPS_LEFT.set(None) + try: + return work() + finally: + _STEPS_LEFT.reset(token) + + def _unbounded_member(members) -> str | None: """The first member of a broker command `members` past the bounds of what is judged (`_MAX_MEMBER_LENGTH`, `_MAX_ABSOLUTE_PATHS`), or @@ -1198,6 +1274,7 @@ def _bytecode_caches(source: str) -> list[str]: present = sorted(os.listdir(pycache)) except (OSError, ValueError): present = [] + _spend(1 + len(present)) caches += [os.path.join(pycache, entry) for entry in present if entry.startswith(stem + ".") and entry.endswith(".pyc")] return caches @@ -1231,7 +1308,9 @@ def _module_paths(name: str, *, roots: list[str]) -> list[str]: found.append(base) for count in range(1, len(parts) + 1): # each prefix, the whole name last, as a module file and as a - # package's initializer, with every suffix + # package's initializer, with every suffix, paid for before it + # is built + _spend(count) package = os.path.join(root, *parts[:count]) found += [package + suffix for suffix in _MODULE_SUFFIXES] found += [os.path.join(package, "__init__" + suffix) @@ -1272,6 +1351,7 @@ def _module_operands(members) -> dict[int, str]: if not member.startswith("-") or member.startswith("--"): continue for at in range(1, len(member)): + _spend() letter = member[at] if letter == "m": if member[at + 1:]: @@ -1370,7 +1450,9 @@ def in_repository_argv(members, *, root: Path | str) -> str | None: unbounded = _unbounded_member(members) if unbounded is not None: return unbounded - return _named_inside(members, root=root) + return _within_budget(lambda: _named_inside(members, root=root), + over=lambda: _first_member(members), + members=members) def _named_inside(members, *, root: Path | str) -> str | None: @@ -1388,15 +1470,22 @@ def _in_repository_member(members, *, root: Path | str) -> str | None: member, for a member whose links pass `_LINK_HOPS`.""" served = Path(resolved_root(root)) + under = str(served).rstrip(os.sep) + os.sep + + def lies_under(name: Path) -> bool: + # strictly under the served root: a prefix of the name's text, which + # `_traversed` builds with no `.` or `..` in it, linear in its depth + return str(name).startswith(under) + def inside(found: list[str]) -> bool: # A name on the way counts where it lies INSIDE the root (a link the # repository holds); the root itself is passed through by every path # joined to it, so only the end may be the root itself. for path in found: *on_the_way, end = _traversed(path) - if end == served or served in end.parents: + if end == served or lies_under(end): return True - if any(served in name.parents for name in on_the_way): + if any(lies_under(name) for name in on_the_way): return True return False @@ -1417,6 +1506,7 @@ def named(member: str, *, option: bool, first: bool, return found def judged(member: str, found: list[str]) -> bool: + _spend(len(found)) try: return inside(found) except _TooManyLinks: @@ -1457,9 +1547,10 @@ def names_a_path_inside(value: str, *, root: Path | str) -> bool: `doxbench_provider.broker_environment`), and no part past the bounds of what is judged either (`_unbounded_member`, FAIL-CLOSED; the holder's ruling, openxFactory#656 comment 5989835334).""" - return any(part and (_unbounded_member([part]) is not None or _named_inside( - ["opendox-environment", f"--value={part}"], root=root) is not None) - for part in value.split(os.pathsep)) + return _within_budget(lambda: any(part and ( + _unbounded_member([part]) is not None or _named_inside( + ["opendox-environment", f"--value={part}"], root=root) is not None) + for part in value.split(os.pathsep)), over=lambda: True) #: Shells: an option cluster holding `c` gives one an inline script. @@ -1802,6 +1893,7 @@ def given(key: str, value: str | None) -> list[str] | None: index = 0 while index < len(rest): + _spend() member = rest[index] if member == "--": return index + 1, context, None @@ -1865,13 +1957,22 @@ def _unwrapped(members) -> _Unwrapped: launchers: list[tuple[str, _Context]] = [] values: list[tuple[str, _Context]] = [] chdirs = [0] + reread: str | None = None for _depth in range(_LAUNCHER_DEPTH): if not command: break - name = _launcher_name(command[0], context=context) - if name is None: - break - launchers.append((command[0], context)) + if reread is not None: + # `env -S`: the same launcher reads its split string, judged + # once, as the member it is and in the context it was judged + # in, however the string moves the working directory (Copilot + # at openDox-code#86, r4182002510; the holder's ruling, + # openxFactory#656 comment 5990845570) + name, reread = reread, None + else: + name = _launcher_name(command[0], context=context) + if name is None: + break + launchers.append((command[0], context)) rest = command[1:] try: index, context, split = _launcher_options(name, rest, context, @@ -1883,7 +1984,7 @@ def _unwrapped(members) -> _Unwrapped: if split is not None: # `env -S STRING`: STRING's words are env's own arguments, read # again by env's grammar, before what followed them. - launchers.pop() + reread = name command = (command[0],) + tuple(split) + rest[index:] continue chdirs = [0] # the next launcher is another program @@ -1897,6 +1998,16 @@ def _unwrapped(members) -> _Unwrapped: return _Unwrapped(tuple(launchers), tuple(values), (), context, unreadable=rest[index], unreadable_because=REASON_UNREADABLE_COMMAND) + if name in ("sudo", "doas") and index < len(rest) and not ( + _has_a_separator(rest[index])): + # the search path sudo's or doas's policy picks (`secure_path`) + # is not judged, so a bare program name is refused as + # unreadable and an absolute path is required (FAIL-CLOSED; + # Copilot at openDox-code#86, r4182002567; the holder's ruling, + # openxFactory#656 comment 5990845570) + return _Unwrapped(tuple(launchers), tuple(values), (), context, + unreadable=rest[index], + unreadable_because=REASON_UNREADABLE_COMMAND) if name == "env": while index < len(rest) and _is_assignment(rest[index]): variable, assigned = rest[index].split("=", 1) @@ -1927,18 +2038,39 @@ def _unversioned(name: str) -> str: return re.sub(r"[-.\d]+$", "", name) or name -def _program_names(member: str, *, first: bool, - context: _Context) -> set[str]: +def _program_names(member: str, *, first: bool, context: _Context, + search: _Context | None = None) -> set[str]: """The names `member` could run as: its own file name and, where it names a file (`_located`), that file's, links followed (`/bin/sh` may be `dash`, and a link named `broker` may be `python3`), each without a - version suffix.""" + version suffix. Given `search`, a bare word is also looked for on its + search path (`_which`), as a wrapper that runs it would find it.""" names = {Path(member).name} - names.update(Path(_resolved_path(path)).name for path in _located( - member, first=first, context=context)) + found = _located(member, first=first, context=context) + if search is not None and not _has_a_separator(member): + located = _which(member, search) + if located is not None: + found.append(located) + _spend(len(found) + sum(path.count(os.sep) for path in found)) + names.update(Path(_resolved_path(path)).name for path in found) return {_unversioned(name) for name in names if name} +def _search_context(context: _Context, *, root: Path | str | None) -> _Context: + """`context` with the search path a broker is given: the one it + inherits, filtered as `doxbench_provider.broker_environment` filters + it, with no entry inside the served root; or the one a launcher + assigned (Copilot at openDox-code#86, r4182002637; the holder's ruling, + openxFactory#656 comment 5990845570).""" + if root is None or context.path != os.environ.get("PATH"): + return context + from opendox import doxbench_provider + + return context._replace(path=_outside_the_budget( + lambda: doxbench_provider.broker_environment( + os.environ, root=root).get("PATH"))) + + #: deno's global options, which may stand before its subcommand (lane #: openXfactory-3 D7 early findings N2, holder ruled fix now): flags, and #: those that take a value (attached after `=`, or the next member). Any @@ -2018,6 +2150,7 @@ def _reads_an_inline_script(spec: _Interpreter, rest: tuple[str, ...], state in `read` was read already, by this reading or another, which read on from it as this one would, so this one stops there.""" while index < len(rest): + _spend() if (index, operands) in read: return False read.add((index, operands)) @@ -2045,6 +2178,7 @@ def _reads_an_inline_script(spec: _Interpreter, rest: tuple[str, ...], letters = member[1:] at = 0 while at < len(letters): + _spend() letter = letters[at] if letter in spec.inline: return True @@ -2080,8 +2214,9 @@ def inline_script(members, *, root: Path | str | None = None) -> str | None: the same class that is not in `_LAUNCHERS` (`busybox sh -c`, `xargs sh -c`, `sudo bash -c`) hides none. A member's name is its own file name and, where it resolves to a file, that file's, each without a version - suffix (`_program_names`). `root` is not needed: the rule is the same - for every repository. + suffix (`_program_names`); a bare word after the first is looked for on + the broker's search path as well, filtered of every entry inside `root` + where it is given (`_search_context`; r4182002637). THE ACCEPTED LIMIT (the same ruling, item 4): a general program that runs code from its own arguments, such as `awk 'PROGRAM'`, `sed` or @@ -2093,23 +2228,41 @@ def inline_script(members, *, root: Path | str | None = None) -> str | None: options cannot be read (an unknown leading option of deno's) is returned as well, FAIL-CLOSED; `broker_command_refused` refuses it as unreadable.""" - try: - return _inline_member(members) - except _Unreadable as unreadable: - return unreadable.member + def work() -> str | None: + try: + return _inline_member(members, root=root) + except _Unreadable as unreadable: + return unreadable.member + + return _within_budget(work, over=lambda: _first_member(members), + members=members) -def _inline_member(members) -> str | None: +def _first_member(members) -> str | None: + """The command's first member, which a judgment past its work budget + names, FAIL-CLOSED, or None for an empty command.""" + return next(iter(members), None) + + +def _inline_member(members, *, root: Path | str | None = None) -> str | None: """`inline_script`'s work. Raises `_Unreadable` for a member whose own - options cannot be read.""" + options cannot be read. A member after the first, a bare word, is + looked for on the broker's filtered search path as well as in its start + directory (`_search_context`; r4182002637), so an alias a wrapper not + in `_LAUNCHERS` runs is judged by what it resolves to.""" unwrapped = _unwrapped(members) for command, context in ((tuple(members), _broker_context()), (unwrapped.command, unwrapped.context)): + search: _Context | None = None for index, member in enumerate(command): + _spend() if not member or (index and member.startswith("-")): continue + if index and search is None: + search = _search_context(context, root=root) for name in _program_names(member, first=index == 0, - context=context): + context=context, + search=search if index else None): if _gives_an_inline_script(name, command[index + 1:]): return member return None @@ -2137,15 +2290,25 @@ def broker_command_refused(members, *, (`REASON_UNREADABLE_COMMAND`; `_builds_its_command`). A member past the bounds of what is judged is refused as unreadable before anything else is asked (`_unbounded_member`; the holder's ruling, openxFactory#656 - comment 5989835334). Asked where trust is recorded and wherever it is - judged, and of the console intake's broker.""" + comment 5989835334), and so is one whose judgment would pass its work + budget (`_WORK_BUDGET`; the holder's ruling, openxFactory#656 comment + 5990845570). Asked where trust is recorded and wherever it is judged, + and of the console intake's broker.""" if _unbounded_member(members) is not None: return REASON_UNREADABLE_COMMAND + return _within_budget(lambda: _command_refused(members, root=root), + over=lambda: REASON_UNREADABLE_COMMAND, + members=members) + + +def _command_refused(members, *, root: Path | str | None) -> str | None: + """`broker_command_refused`'s work, within the command's work budget + (`_WORK_BUDGET`). Raises `_OverBudget` past it.""" unwrapped = _unwrapped(members) if unwrapped.unreadable is not None: return unwrapped.unreadable_because try: - if _inline_member(members) is not None: + if _inline_member(members, root=root) is not None: return REASON_INLINE_SCRIPT except _Unreadable: return REASON_UNREADABLE_COMMAND diff --git a/tests/test_model_binding_trust.py b/tests/test_model_binding_trust.py index ffe6e51f..4c88e28c 100644 --- a/tests/test_model_binding_trust.py +++ b/tests/test_model_binding_trust.py @@ -3183,6 +3183,14 @@ def _in_repository_argv(served, where, monkeypatch): monkeypatch.setenv("PATH", os.path.relpath(program.parent, broker_cwd) + os.pathsep + os.environ.get("PATH", "")) return [program.name] + if where == "relative-launcher-in-the-repository-re-read-after-its-split": + # r4182002510; the holder's ruling, #656 5990845570: `-C` before `-S` + # moves the directory the split string is read in, not the one the + # launcher itself was found from + launcher = served.repo / "env" + launcher.symlink_to(shutil.which("env")) + return [os.path.relpath(launcher, broker_cwd), "-C", str(served.tmp), + "-S", f"{sys.executable} {served.broker}"] if where.endswith("bytecode-cache-linked-into-the-repository"): # r4181465499; the holder's ruling, #656 5989835334: Python imports # a source's bytecode cache from `__pycache__` in its place @@ -3364,6 +3372,7 @@ def _chain(served, tool: Path, *, relative: bool, "program-past-an-assignment-whose-name-is-no-identifier", "value-of-an-assignment-whose-name-is-no-identifier", "member-an-unknown-option-may-take", + "relative-launcher-in-the-repository-re-read-after-its-split", "module-bytecode-cache-linked-into-the-repository", "initializer-bytecode-cache-linked-into-the-repository", "main-bytecode-cache-linked-into-the-repository", @@ -3504,14 +3513,15 @@ def test_R6_the_old_worst_cases_are_judged_in_under_a_second(served): that delivers a bindings document must not hang the judgment. One member of 400 absolute paths took 29 s at fe56c0c4, and 4000 options node does not know took seconds, growing with their square; each is - judged now in under a second.""" + judged now in under a second (4000 such options now pass the work + budget, ruling 5990845570, so 1500 are judged here).""" trust_mod = _trust_mod() for argv, reason in ( (["/opt/opendox-test/broker", "/x" * 400], trust_mod.REASON_UNREADABLE_COMMAND), (["/opt/opendox-test/broker", "/x" * 2048], trust_mod.REASON_UNREADABLE_COMMAND), - (["node", *(["--a"] * 4000)], None), + (["node", *(["--a"] * 1500)], None), (["node", *(["--a"] * 4000), "-e", "x"], trust_mod.REASON_INLINE_SCRIPT)): started = time.monotonic() @@ -3520,6 +3530,155 @@ def test_R6_the_old_worst_cases_are_judged_in_under_a_second(served): assert time.monotonic() - started < 1, argv[:2] +def test_R7_an_alias_a_wrapper_runs_is_judged_on_the_brokers_search_path( + served, monkeypatch, capsys): + """r4182002637; the holder's ruling, #656 5990845570: a bare word after + the program, which a wrapper not in the launcher table may run, is + looked for on the broker's FILTERED search path as well, so an alias + that resolves to an interpreter given its inline flag is an inline + script; an alias of a program that is no interpreter is not, and one + only an entry inside the repository holds is no program the broker + finds.""" + trust_mod = _trust_mod() + aliases = served.tmp / "aliases" + aliases.mkdir() + (aliases / "opref-alias").symlink_to(sys.executable) + (aliases / "opref-tool").symlink_to(shutil.which("true")) + hidden = served.repo / "bin" + hidden.mkdir() + (hidden / "opref-hidden").symlink_to(sys.executable) + monkeypatch.setenv("PATH", os.pathsep.join( + [str(aliases), str(hidden), os.environ.get("PATH", "")])) + for wrapper in (["prlimit", "--"], ["setpriv", "--"]): + assert trust_mod.broker_command_refused( + [*wrapper, "opref-alias", "-c", "x"], root=served.repo) == ( + trust_mod.REASON_INLINE_SCRIPT), wrapper + for word in ("opref-tool", "opref-hidden"): + assert trust_mod.broker_command_refused( + [*wrapper, word, "-c", "x"], root=served.repo) is None, word + served.hand_write(served.record( + "broker", broker_argv=["prlimit", "--", "opref-alias", "-c", "x"])) + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + BINDING_ID) == 1 + assert trust_mod.REASON_INLINE_SCRIPT in capsys.readouterr().err + assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() + + +#: Each residual form of the judgment's cost the writer measured at +#: 5324ca4c, with what it answers now (the holder's ruling, #656 +#: 5990845570): every one past its work budget is refused as unreadable. +RESIDUAL_FORMS = { + "a-module-of-100-dotted-parts": ( + lambda py: [py, "-m", ".".join(["a"] * 100)], "unreadable"), + "a-module-of-2000-dotted-parts-under-two-roots": ( + lambda py: ["env", "PYTHONPATH=x", py, "-m", + ".".join(["a"] * 2000)], "unreadable"), + "2000-repeated-pythonpath-entries": ( + lambda py: ["env", "PYTHONPATH=" + ":".join(["a"] * 2000), py, "-m", + "a"], None), + "700-distinct-pythonpath-entries": ( + lambda py: ["env", "PYTHONPATH=" + ":".join( + f"e{i}" for i in range(700)), py, "-m", "a"], "unreadable"), + "6000-members-of-32-separators": ( + lambda py: ["/opt/opendox-test/broker", *(["/a" * 32] * 6000)], + "unreadable"), + "4000-short-members": ( + lambda py: ["/opt/opendox-test/broker", *(["w"] * 4000)], + "unreadable"), + "3000-su-members": ( + lambda py: ["/opt/opendox-test/broker", *(["su"] * 3000)], + "unreadable"), + "1000-words-on-a-search-path-of-700-entries": ( + lambda py: ["env", "PATH=" + ":".join(f"p{i}" for i in range(700)), + "prlimit", "--", *(["w"] * 1000)], "unreadable"), + "2000-interpreter-option-clusters-of-4095-letters": ( + lambda py: [py, *(["-" + "B" * 4095] * 2000)], "unreadable"), + "2000-option-clusters-of-4095-letters": ( + lambda py: ["/opt/opendox-test/broker", + *(["-" + "B" * 4095] * 2000)], "unreadable"), + "4000-options-node-does-not-know": ( + lambda py: ["node", *(["--a"] * 4000)], "unreadable"), +} + + +@pytest.mark.parametrize("form", sorted(RESIDUAL_FORMS)) +def test_R7_each_residual_form_is_judged_in_under_a_second(served, form): + """r4182002696 and the writer's residual measurements; the holder's + ruling, #656 5990845570: import roots are judged once each, and one + work budget per command counts the names traversed, the module + candidates before they are built, the directories searched, and every + member and option letter a scan reads; past it the command is refused + as unreadable, FAIL-CLOSED, so a pull cannot hang verdict computation. + Each form the writer measured (100 dotted parts took 7 s, and 2000 su + members, 1 s) is judged in under a second.""" + trust_mod = _trust_mod() + build, answer = RESIDUAL_FORMS[form] + argv = build(sys.executable) + started = time.monotonic() + refused = trust_mod.broker_command_refused(argv, root=served.repo) + assert time.monotonic() - started < 1 + assert refused == (trust_mod.REASON_UNREADABLE_COMMAND + if answer == "unreadable" else None) + + +def test_R7_a_commands_budget_grows_with_its_members(served, monkeypatch): + """The work budget is `_WORK_PER_MEMBER` steps for each member, at most + `_WORK_BUDGET`, so the bindings document's ceiling on members bounds a + whole document's work: the same module of 30 dotted parts is past the + budget of a command of 2 members and within that of 10. An ordinary + command spends little of its budget, and the operator's own search + path, filtered of the served root, is no command's work.""" + trust_mod = _trust_mod() + module = "-m" + ".".join(["a"] * 30) + assert trust_mod.broker_command_refused( + [sys.executable, module], root=served.repo) == ( + trust_mod.REASON_UNREADABLE_COMMAND) + assert trust_mod.broker_command_refused( + [sys.executable, module, *(["-q"] * 8)], root=served.repo) is None + entries = [] + for index in range(300): + entry = served.tmp / "path" / f"d{index}" + entry.mkdir(parents=True) + entries.append(str(entry)) + monkeypatch.setenv("PATH", os.pathsep.join( + [*entries, os.environ.get("PATH", "")])) + assert trust_mod.broker_command_refused( + ["/opt/opendox-test/broker", "show"], root=served.repo) is None + + +def test_R7_a_bindings_document_past_its_ceiling_is_refused_by_name( + served, capsys): + """The holder's ruling, #656 5990845570: a bindings document declaring + more broker command members across its bindings than + `MAX_JUDGED_MEMBERS` is refused by name, FAIL-CLOSED, before any of + them is judged; one at the ceiling is read.""" + trust_mod = _trust_mod() + ceiling = binding_mod.MAX_JUDGED_MEMBERS + assert ceiling == 256 + + def document(members: int) -> Path: + # bindings of 64 members each, the last taking the rest + sizes = [64] * (members // 64) + sizes[-1] += members % 64 + return served.hand_write(*(served.record( + "broker", id=f"{BINDING_ID}-{number}", + broker_argv=[sys.executable, str(served.broker), + *(["--flag"] * (size - 2))]) + for number, size in enumerate(sizes))) + + path = document(ceiling) + assert len(binding_mod.BindingStore(path).list()) == 4 + path = document(ceiling + 1) + with pytest.raises(binding_mod.BindingRefused) as refused: + binding_mod.BindingStore(path).list() + assert f"{ceiling + 1} broker command members" in str(refused.value) + assert f"more than the {ceiling} judged" in str(refused.value) + assert _cli("model-binding", "trust", "--repo-root", str(served.repo), + f"{BINDING_ID}-0") == 1 + assert f"more than the {ceiling} judged" in capsys.readouterr().err + assert not (served.state_dir / trust_mod.TRUST_FILENAME).exists() + + def test_R5_xargs_is_refused_after_the_other_two_judgments(served): """r4181006365; the holder's ruling, #656 5988818366: every xargs is refused as unreadable, but only after the in-repository and @@ -4761,7 +4920,7 @@ def test_A5_an_intake_broker_the_rules_refuse_is_not_offered(served): "busybox-wrapped": ["busybox", "sh", "-c", "x"], "unknown-wrapper": ["/opt/opendox-test/wrap", "sh", "-c", "x"], "xargs-wrapped": ["xargs", "-0", "sh", "-c", "x"], - "sudo-wrapped": ["sudo", "-u", "bob", "bash", "-c", "x"], + "sudo-wrapped": ["sudo", "-u", "bob", "/bin/bash", "-c", "x"], "env-python": ["/usr/bin/env", "python3", "-c", "x"], "env-split-python": ["env", "-S", "python3 -c x"], # Copilot at openDox-code#86, r4179241583: an attached script, and an @@ -5052,8 +5211,9 @@ def test_A2_every_broker_starts_outside_the_served_repository( "xargs": ["xargs", "-0", "PROG"], "busybox": ["busybox", "env", "PROG"], "flock": ["flock", "-w", "5", "/tmp/opendox-lock", "PROG"], - "sudo": ["sudo", "-u", "bob", "PROG"], - "doas": ["doas", "-u", "bob", "PROG"], + # r4182002567; #656 5990845570: an absolute path after sudo or doas + "sudo": ["sudo", "-u", "bob", "BIN/PROG"], + "doas": ["doas", "-u", "bob", "BIN/PROG"], "nested": ["env", "nice", "-n", "1", "timeout", "5", "PROG"], } @@ -5221,6 +5381,13 @@ def _unreadable_command(served, monkeypatch, case): "member-past-the-length-bound": ["/opt/opendox-test/broker", "a" * 4097], "member-past-the-path-bound": ["/opt/opendox-test/broker", "/a" * 33], + # r4182002567; the holder's ruling, #656 5990845570: a bare program + # name after sudo or doas is found on a search path their policy picks + "sudo-bare-program": ["sudo", "true"], + "sudo-option-then-bare-program": ["sudo", "-u", "bob", "true"], + "sudo-bare-launcher": ["sudo", "env", "/bin/true"], + "doas-bare-program": ["doas", "true"], + "doas-option-then-bare-program": ["doas", "-u", "bob", "true"], } @@ -5365,6 +5532,7 @@ def test_C1_a_launchers_own_options_are_read_as_its_getopt_reads_them( ["env", "A-B=x", *program], ["env", "1A=x", "A.B=y", *program], ["sudo", "--user=bob", *program], + ["doas", "-u", "bob", *program], ["env", "--default-signal", *program], ["env", "--help"]): assert trust_mod.broker_command_refused(