From e1c2d6368b113755e40be9d6a8b0d17df8a5027f Mon Sep 17 00:00:00 2001 From: arpan Date: Tue, 15 Sep 2026 03:35:30 +0530 Subject: [PATCH 1/2] The last layering cycle, and the properties five milestones of examples missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **`policy <-> authority` is gone, and `RECORDED_LAYERING_CYCLES` is empty.** I said this one could not be fixed, and gave a reason that was true but incomplete: `_from_section` constructs an `Authority` and `canonical_grants` consumes one, so neither moves below `policy.py`, and moving their two callers up to `control.py` would change what `policy_hash` is taken over, which is evidence in every receipt. All of that holds. What I missed is that the cycle has another side. `authority.py` imported eight names from `policy.py`, and not one of them is `Policy`: schemas, the strict YAML loader, the condition grammar, type-strict equality, key validation. That is the policy **document grammar**, 433 lines of 2031, and it is shared vocabulary rather than either axis's property. It moves to `grammar.py`, so `authority.py` no longer imports `policy.py` at all and the cycle is broken from the side that was actually loose. SPEC-v0.3 §4.5 requires the two axes to share ONE condition evaluator, because a second would be a second place for `True` to start comparing equal to `1`. That is better served now than before: the one evaluator is owned by neither axis. Nothing changed but an address. Every block moved verbatim, comments included, and `policy.py` re-exports all thirty-four names, so SPEC-v0.1 §8's frozen `__init__` block and SPEC-v0.3 §8's `from .policy import Condition, parse_conditions` both stay literally true. `_LOG` is pinned to `ctrlrun.policy` rather than taken from `__name__`, so no operator's handler is re-routed. **Property tests, and the first one corrected me before it passed.** I wrote "one tamper is one break". Hypothesis falsified it on the second example: altering row n also breaks the link at n+1, because n+1 carries prev_hash over what n used to hash to. Two is correct and my expectation was wrong, which is the same mistake in miniature that the file exists to catch, so the docstring keeps it rather than quietly fixing it. The real invariant is sharper and it is the one that catches v0.11 item 1: **every break names a row the store actually holds.** That defect reported `content_altered 99` and `missing 100` on an eight-row chain, because position came from the document rather than the `seq` column. Reinstating it against these tests fails with "reported a break at [3], and this store holds seq 1..2". Four properties: an untouched chain verifies, as the positive control; a tamper names only rows that exist and does not cascade; one unreadable row costs one row; a deleted row is reported, with the truncation case asserted as **undetected**, which is what SPEC-v0.11 §2.4 states and what the anchor exists for. `derandomize=True` and `deadline=None`: a suite that fails once in a while teaches people to re-run it, and #207 had just finished showing what that costs. Signed-off-by: arpan --- pyproject.toml | 6 + src/ctrlrun/authority.py | 8 +- src/ctrlrun/grammar.py | 557 ++++++++++++++++++++++++++++++++++++ src/ctrlrun/policy.py | 568 +++---------------------------------- tests/test_module_graph.py | 29 +- tests/test_properties.py | 239 ++++++++++++++++ 6 files changed, 863 insertions(+), 544 deletions(-) create mode 100644 src/ctrlrun/grammar.py create mode 100644 tests/test_properties.py diff --git a/pyproject.toml b/pyproject.toml index 373c1c23..1172b979 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,12 @@ dev = [ # `tests/data/junit-10.xsd`. JUnit XML has no normative schema, so the schema is a # de-facto one and the test asserts structurally as well; `xmlschema` is used by that # test and by nothing else, and `ctrlrun` never imports it. + # SPEC-v0.11 §13.2's corollary, one milestone on: the reader defect item 1 found -- one + # `UPDATE` reporting four breaks at three positions, two of them rows that do not exist -- + # is a property, and five milestones of example tests never hit it because every example + # tampered in a way its author had already thought of. `tests/test_properties.py` states + # those invariants directly. Pinned to a deterministic profile there; see that module. + "hypothesis>=6", "xmlschema>=3", # SPEC-v0.5 §6.1 - T136 builds the wheel and the sdist and asserts neither carries an # adapter. Without `build` here the test skipped, and it skipped in *both* CI jobs: the diff --git a/src/ctrlrun/authority.py b/src/ctrlrun/authority.py index 90e20edd..80fbb19d 100644 --- a/src/ctrlrun/authority.py +++ b/src/ctrlrun/authority.py @@ -38,7 +38,11 @@ from .action import Action, PlainValue, Principal from .errors import AuthorityEscalation, IdentityError, InvalidArgument, PolicyError -from .policy import ( + +# `grammar.py` and not `policy.py`: SPEC-v0.3 §4.5 requires the two axes to share one condition +# evaluator, and it now lives below both rather than inside one of them. Importing it from +# `policy.py` was the last cycle ARCHITECTURE §6 carried an exception for. +from .grammar import ( SUPPORTED_SCHEMAS, Condition, parse_conditions, @@ -47,7 +51,7 @@ require_v7, strict_load, ) -from .policy import _equal as _type_strict_equal +from .grammar import _equal as _type_strict_equal from .state import Charge, DelegationRecord, StateStore #: SPEC-v0.3 §4.4 — an action name is dotted (`v0.1 §2.1`) and a resource is `type:id`. diff --git a/src/ctrlrun/grammar.py b/src/ctrlrun/grammar.py new file mode 100644 index 00000000..34993b2d --- /dev/null +++ b/src/ctrlrun/grammar.py @@ -0,0 +1,557 @@ +# SPDX-FileCopyrightText: 2026 The CTRLRun contributors +# SPDX-License-Identifier: Apache-2.0 +"""The policy document grammar: schemas, strict loading, and the condition evaluator. + +**Below `policy.py` and `authority.py`, and owned by neither.** `docs/ARCHITECTURE.md` §6 records +that `authority.py` imports the condition parser and evaluator from `policy.py` deliberately, +because a grant's `constraints:` is a rule's `when:` syntax and `SPEC-v0.3.md` §4.5 says the two +axes MUST share one evaluator: a second one would be a second place for `True` to start comparing +equal to `1`. That requirement is unchanged. What changed is where the one evaluator lives. + +It lived in `policy.py`, so `authority.py` imported upward, while `policy.py` reached back into +`authority.py` from `_canonical_authority` and `hash_with_authority`. That was a cycle, and the +last one §6 had to carry an exception for. Neither direction could be removed on its own: +`_from_section` constructs an `Authority` and `canonical_grants` consumes one, so neither moves +below `policy.py`, and moving their two callers up to `control.py` would change what +`policy_hash` is taken over, which is evidence in every receipt rather than an implementation +detail. + +Moving the *shared* half down removes the cycle without touching either. The evaluator is now +owned by neither axis, which is what §4.5 asks for more literally than the old arrangement did. + +**Nothing here changed but its address.** Every block was moved verbatim, comments included, and +`policy.py` re-exports all of it, so `from ctrlrun.policy import Condition, parse_conditions` +still resolves and `SPEC-v0.3.md` §8's frozen line stays literally true. +""" + +from __future__ import annotations + +import logging +import operator +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, Final + +import yaml + +from .errors import PolicyError + +#: Pinned to `ctrlrun.policy` rather than taken from `__name__`. These functions logged there +#: before the move, and a refactor that silently re-routes a log line is a refactor that loses +#: whatever handler or filter an operator pointed at it. +_LOG = logging.getLogger("ctrlrun.policy") + + +#: The schema `ctrlrun init` writes, and the one every v0.1 file declares. +POLICY_SCHEMA: Final = "ctrlrun.policy/v1" +#: SPEC-v0.2 §3.1 — required by any document using `effect:`, `resource:` or `mcp:`. A v2 +#: file fails to load on v0.1, correctly: v0.1 would ignore the effect template and execute +#: with no duplicate protection at all. The schema string is the only thing standing between +#: those two outcomes, so it is not optional and not inferred. +POLICY_SCHEMA_V2: Final = "ctrlrun.policy/v2" +#: SPEC-v0.3 §12.1 — required by any document using `environment:`, and later by `authority:` +#: and `mode:`. A v3 key in an older document is a load error naming the key and the schema, +#: for the reason v0.2 gives: a reader that ignored it would run with a guarantee switched off. +POLICY_SCHEMA_V3: Final = "ctrlrun.policy/v3" +#: SPEC-v0.6 §7.1, §9.5 — required by any document using `version:`, `controls:` or `data:`. +#: `v1`, `v2` and `v3` documents load unchanged and get a `policy_hash` like any other; only +#: those three keys need `v4`. +POLICY_SCHEMA_V4: Final = "ctrlrun.policy/v4" +#: SPEC-v0.7 §5.3 — required by any document using `max_attempts:`. An 0.6.1 reader refuses a +#: `v5` document outright, which is the fail-closed direction: a reader that ignored the key +#: would renew without a ceiling, which is the behaviour the key exists to bound. +POLICY_SCHEMA_V5: Final = "ctrlrun.policy/v5" +#: SPEC-v0.8 §11.3: `v6` adds `approver_role` on a control entry, and items 4 and 5 add +#: their keys under it. The version moves once, here, for the reason §11.4 gives: an older +#: reader must refuse a document whose keys it would otherwise ignore, and a reader that +#: ignored `approver_role` would run a deployment believing nobody was gated. +POLICY_SCHEMA_V6: Final = "ctrlrun.policy/v6" +#: SPEC-v0.9 §10.1: `v7` adds `tasks` on a grant (item 1) and `budgets` on one (item 3). The +#: version moves once, here, with item 1, and item 3 fills it under the version already in +#: place: two branches racing a schema bump is how a catalogue ends up with a stub row. +POLICY_SCHEMA_V7: Final = "ctrlrun.policy/v7" +#: SPEC-v0.10 §4.6 — `v8` adds one action-entry key, `upstream:`. Bumped once, by item 3. +POLICY_SCHEMA_V8: Final = "ctrlrun.policy/v8" +#: All of them, newest last, for the message an unknown schema produces. **In version order**, +#: which `_at_least` reads: a version added out of order would make every gate below lie. +SUPPORTED_SCHEMAS: Final = ( + POLICY_SCHEMA, + POLICY_SCHEMA_V2, + POLICY_SCHEMA_V3, + POLICY_SCHEMA_V4, + POLICY_SCHEMA_V5, + POLICY_SCHEMA_V6, + POLICY_SCHEMA_V7, + POLICY_SCHEMA_V8, +) + + +def _at_least(schema: str, minimum: str) -> bool: + """Whether `schema` is `minimum` or a later version (SPEC-v0.7 §5.3). + + Each version is a **superset** of the one before: a `v5` document may use every key any + earlier version allows. Three gates in this module compared for equality instead, which was + right while `v4` was the newest and became wrong the moment it was not; `require_v3`'s own + comment predicted it. An unknown schema is refused before any of them runs, so a name that + is not in `SUPPORTED_SCHEMAS` cannot reach here from `Policy._from_document`; where one does, + from `authority.py`'s standalone path, it is treated as too old, which is fail-closed. + """ + if schema not in SUPPORTED_SCHEMAS or minimum not in SUPPORTED_SCHEMAS: + return False + return SUPPORTED_SCHEMAS.index(schema) >= SUPPORTED_SCHEMAS.index(minimum) + + +#: SPEC-v0.3 §6.1 — the two values of the top-level `mode:` key, and nothing else. Absent +#: means `enforce`: the fail-closed default, so a document that predates the key enforces. +MODE_KEY: Final = "mode" +_NUMERIC_COMPARE: Final[Mapping[str, Callable[[int, int], bool]]] = { + "lt": operator.lt, + "lte": operator.le, + "gt": operator.gt, + "gte": operator.ge, +} +_OPERATORS: Final = ("eq", "neq", "in", *_NUMERIC_COMPARE) +#: Longest first, so `amount_neq` reads as (amount, neq) and never as (amount_n, eq). +_OPERATORS_BY_LENGTH: Final = tuple(sorted(_OPERATORS, key=len, reverse=True)) +#: SPEC-v0.3 §12.1 — the top-level keys that need `ctrlrun.policy/v3`, and what an older +#: reader would do with each if it ignored one. +_V3_TOP_LEVEL_KEYS: Final[Mapping[str, str]] = { + "environment": ("an older reader would ignore it and put every action in the wrong deployment"), + "authority": ( + "an older reader would ignore it and run every action with no authority check at all" + ), + "mode": ("an older reader would enforce a configuration that was deployed to observe"), +} +RESERVED_ARGUMENTS: Final = frozenset( + { + "action_id", + "agent", + "claims", + "data_scope", + "environment", + "expires_at", + "issuer", + "principal", + "resource", + "user", + } +) +#: SPEC-v0.6 §7.4 — names refused as **arguments** and permitted as **condition subjects**, +#: resolved at evaluation from something other than `action.canonical_arguments`. +#: +#: The distinction is what makes `data_scope` implementable at all. Today one check does both +#: jobs: the splitter refuses a condition whose subject is in `RESERVED_ARGUMENTS`, which is how +#: `claims_eq:` becomes a load error. Adding `data_scope` to that set unchanged would have made +#: `data_scope_in:` a load error too -- the very condition §7.4 asks operators to write. +#: +#: **A name here is still refused as an argument**, so one name never means two things in one +#: document. And this list is the *policy evaluator's*: authority `constraints:` do not consult +#: it, so a grant naming `data_scope` is refused exactly as it always was (§11 puts matching a +#: grant on a data label out of scope). +DERIVED_SUBJECTS: Final = frozenset({"data_scope"}) + + +def _is_int(value: object) -> bool: + """True for a real int. `bool` subclasses int in Python; SPEC-v0.1 §3.2 excludes it.""" + return isinstance(value, int) and not isinstance(value, bool) + + +def _type_name(value: object) -> str: + return type(value).__name__ + + +def _equal(value: object, operand: object) -> bool: + """Type-strict equality: `True` never equals `1`, and a list never equals a scalar. + + SPEC: §3.2 — equality is type-strict and applies recursively inside containers. + Canonical arguments distinguish bool from int (§2.3), so conditions must too, or a + policy written for `1` would match `True`. + """ + if isinstance(value, bool) or isinstance(operand, bool): + return value is operand + if isinstance(value, Mapping) and isinstance(operand, Mapping): + return value.keys() == operand.keys() and all( + _equal(value[key], operand[key]) for key in value + ) + if isinstance(value, list | tuple) and isinstance(operand, list | tuple): + return len(value) == len(operand) and all( + _equal(item, other) for item, other in zip(value, operand, strict=True) + ) + if _is_container(value) or _is_container(operand): + return False + return bool(value == operand) + + +def _is_container(value: object) -> bool: + return isinstance(value, Mapping | list | tuple) + + +@dataclass(frozen=True) +class Condition: + """One `_: operand` test against an action's arguments (SPEC-v0.1 §3.2). + + Public since SPEC-v0.3 §11, because a `Grant`'s constraints are made of them and the two + axes share one evaluator: a second implementation would be a second place for `True` to + start comparing equal to `1`. `key` is the raw condition key the author wrote. + """ + + key: str + argument: str + op: str + operand: Any + + def matches(self, action_name: str, arguments: Mapping[str, Any]) -> bool: + if self.argument not in arguments: + # SPEC §3.2 — still false, never an error, but never silent either. Defaults are + # applied when a call is bound (§8), so an argument is either always present or + # never: an absent one is a typo, and silence let a mistyped rule disappear into + # a catch-all below it. + _LOG.warning( + "%s: condition %s ignored: the action has no argument %r (it has: %s)", + action_name, + self.key, + self.argument, + ", ".join(sorted(arguments)) or "none", + ) + return False + value = arguments[self.argument] + if self.op in {"eq", "neq"} and self.argument in DERIVED_SUBJECTS: + # SPEC-v0.6 §7.4: *"`_eq` and `_neq` compare the whole set."* **The whole set, and + # a set has no order.** The derived value is a `sorted(...)` list, and `_equal` on + # lists is order-sensitive -- so an independent review found `data_scope_eq: [phi, + # internal]` never matching, silently, while `[internal, phi]` did. An operator + # writing the labels in the order their own `data:` map declares them gets a rule + # that never fires, with no warning: the key splits, the subject is present, and + # `matches` simply returns `False` and falls through to whatever is below. Where + # the rule was the `deny` or `approve`, that is fail-open. + # + # Narrowed to `DERIVED_SUBJECTS` for exactly the reason `_in` below is: an ordinary + # list-valued argument means *this list*, and `value_eq: [1, 2]` against `[2, 1]` + # must stay false. This branch is one line away from the one that regressed when it + # was written too wide, and it is written narrow for the same reason. + if not isinstance(value, list | tuple) or not isinstance(self.operand, list | tuple): + return ( + _equal(value, self.operand) + if self.op == "eq" + else not _equal(value, self.operand) + ) + same = frozenset(value) == frozenset(self.operand) + return same if self.op == "eq" else not same + if self.op == "eq": + return _equal(value, self.operand) + if self.op == "neq": + return not _equal(value, self.operand) + if self.op == "in": + if self.argument in DERIVED_SUBJECTS and isinstance(value, list | tuple): + # SPEC-v0.6 §7.4 — a **derived, set-valued** subject intersects the list, which + # is the membership `_in` already expresses one element at a time. §7.4 adds no + # operator for it: `contains` and `not_in` would land in `_OPERATORS`, which + # authority `constraints:` share (`v0.3 §4.5` -- one implementation, not two), + # and §11 puts matching a grant on a data label out of scope. + # + # **Narrowed to derived subjects, and the first version was not.** Testing every + # list-valued subject changed `_in` for ordinary arguments: `value_in: [[1, 2]]` + # against `value = [1, 2]` means *this exact list is one of the operands* and has + # since v0.1, and intersecting broke it. A rule about a new subject may not + # quietly re-mean an operator for the old ones. + return any(_equal(item, candidate) for item in value for candidate in self.operand) + return any(_equal(value, item) for item in self.operand) + if not _is_int(value): + _LOG.warning( + "%s: condition %s ignored: argument %r is %s, not int", + action_name, + self.key, + self.argument, + _type_name(value), + ) + return False + return _NUMERIC_COMPARE[self.op](value, self.operand) + + +class _StrictLoader(yaml.SafeLoader): # type: ignore[misc] # PyYAML ships no stubs + """`yaml.SafeLoader` that refuses a repeated mapping key instead of resolving it. + + YAML says a duplicated key is an error and PyYAML resolves it to the last one anyway, + silently. That is a fail-**open** in the authority document: a grant written as + + actions: ["payments.refund"] + actions: ["**"] + + -- the shape of a half-finished narrowing edit -- loads as `("**",)` with no warning, and + `ctrlrun verify` reads this same loader, so nothing downstream catches it either. Every + other mistake in these documents is refused, the key sets being closed at every level, so + a clean load reads as "the document is what I meant". + + The node carries the line, which is exactly what the message needs. + """ + + def construct_mapping( + self, + node: Any, # noqa: ANN401 - PyYAML's node type, and PyYAML ships no stubs + deep: bool = False, + ) -> dict[Any, Any]: + seen: set[Any] = set() + for key_node, _ in node.value: + key = self.construct_object(key_node, deep=True) + try: + duplicate = key in seen + except TypeError: # an unhashable key; the base class refuses it below + continue + if duplicate: + mark = key_node.start_mark + raise yaml.constructor.ConstructorError( + None, + None, + f"duplicate key {key!r} on line {mark.line + 1}, column {mark.column + 1}", + mark, + ) + seen.add(key) + return super().construct_mapping(node, deep) # type: ignore[no-any-return] + + +def strict_load(text: str, source: str) -> Any: # noqa: ANN401 - any YAML scalar or node + """`yaml.safe_load`, refusing a repeated key. The one loader for every CTRLRun document. + + **`yaml.YAMLError` is not the whole contract.** PyYAML converts a scalar before it has + decided the document is well formed, and three conversions raise the interpreter's own + exception rather than a `YAMLError`: + + - `"\\U0001f600"` with too many digits overflows converting the codepoint to a C int, + which is `OverflowError`. The fuzzer found this one after 174,380 executions; + - `"\\U00110000"` is a legal-looking escape above the Unicode maximum, and `chr()` says + `ValueError`; + - `2026-99-99` is a `ValueError` from `datetime`, and is the one that matters: a mistyped + date is a thing a person writes in a real policy, not a thing a fuzzer invents. + + Each was a crash where the caller was promised a refusal, and `Policy.from_yaml` says + "anything malformed raises `PolicyError`" without qualification. The document is refused + either way, so nothing unsafe was ever admitted; what leaked was the exception type, and a + caller that catches `PolicyError` around a policy load would not have caught these. + + `ValueError` and `OverflowError` are therefore refusals too. The catch is deliberately not + `Exception`: this function calls one thing, so a `MemoryError` or a `KeyboardInterrupt` + here is not the document's fault and must not be reported as one. `RecursionError` is left + uncaught for the same reason, and `fuzz/properties.py` says so where it excludes it. + """ + try: + # `_StrictLoader` derives from `SafeLoader`, so this constructs no arbitrary object. + return yaml.load(text, Loader=_StrictLoader) + except yaml.YAMLError as exc: + raise PolicyError(f"{source}: not valid YAML: {exc}") from exc + except (ValueError, OverflowError) as exc: + raise PolicyError(f"{source}: not valid YAML: {type(exc).__name__}: {exc}") from exc + + +def require_v3(document: Mapping[Any, Any], schema: str, source: str) -> None: + """Refuse a `ctrlrun.policy/v3` key in an older document (SPEC-v0.3 §12.1). + + Shared with `authority.py`, which reads the same key from a document the policy loader + may never see: SPEC-v0.3 §8.3's `--authority` file carries `schema` and `authority` and + nothing else, so the check has to exist on both paths rather than on whichever runs first. + """ + # v4 is a superset: a `v4` document may use every `v3` key. Comparing for equality here was + # right while v3 was the newest and becomes a bug the moment it is not. SPEC-v0.7 §5.3: the + # membership test that replaced it had the same shape, so `v5` reads it through `_at_least`. + if _at_least(schema, POLICY_SCHEMA_V3): + return + for key, consequence in _V3_TOP_LEVEL_KEYS.items(): + if key in document: + raise PolicyError( + f"{source}: {key!r} needs 'schema: {POLICY_SCHEMA_V3}'; this document " + f"declares {schema!r}, and {consequence}" + ) + + +#: SPEC-v0.9 §10.1 — the grant-entry keys that need `ctrlrun.policy/v7`, and what an older +#: reader would do with each. Both are authorization dimensions, so both fail the same way: an +#: older reader ignores the key and grants more than the document says. +_V7_GRANT_KEYS: Final[Mapping[str, str]] = { + "tasks": ( + "an older reader would ignore the binding and authorise the grant on every task, which " + "is the whole of what the key restricts" + ), + "budgets": ( + "an older reader would ignore the limit and let the grant spend without bound, which is " + "the whole of what the key restricts" + ), +} + + +def require_v7(document: Mapping[Any, Any], schema: str, source: str) -> None: + """Refuse a `ctrlrun.policy/v7` grant key in an older document (SPEC-v0.9 §10.1). + + `require_v3`'s shape, two versions up, and shared with `authority.py` for the same reason: + `SPEC-v0.3 §8.3`'s `--authority` file carries `schema` and `authority` and nothing else, so + a gate that lived only in the policy loader would not run on that path at all. The keys are + one level deeper than v3's and v4's, hence the walk rather than a membership test. + """ + if _at_least(schema, POLICY_SCHEMA_V7): + return + section = document.get("authority") + if not isinstance(section, Mapping): + return + entries: list[Any] = [] + grants = section.get("grants") + if isinstance(grants, list): + entries.extend(grants) + elif isinstance(grants, Mapping): + entries.extend(grants.values()) + envelopes = section.get("break_glass") + if isinstance(envelopes, Mapping): + entries.extend(envelopes.values()) + for entry in entries: + if not isinstance(entry, Mapping): + continue + for key, consequence in _V7_GRANT_KEYS.items(): + if key in entry: + raise PolicyError( + f"{source}: {key!r} on a grant needs 'schema: {POLICY_SCHEMA_V7}'; this " + f"document declares {schema!r}, and {consequence}" + ) + + +def reject_nested_mode(mapping: Mapping[Any, Any], where: str) -> None: + """Refuse a `mode:` anywhere but the top level of the policy document (SPEC-v0.3 §6.1). + + The closed key sets of `v0.1 §3.1` would already refuse it as unknown, wherever they + reach. This runs first and for its *message*: "unknown key 'mode'" reads as "CTRLRun has + no such setting", and the author who wrote it here believes they have observed one action + while enforcing the rest. A partially-enforced configuration is the failure mode the + top-level-only rule exists to prevent, so the error says which rule was broken. + + Shared with `authority.py`, which owns the two nestings inside an `authority:` section and + parses documents the policy loader never reads (§4.8). + """ + if MODE_KEY in mapping: + raise PolicyError( + f"{where}: 'mode' is top level and nothing else (SPEC-v0.3 §6.1). A configuration " + "where some actions are observed and some are enforced is one where nobody can say " + "whether an action was permitted or merely watched; move it beside 'schema:'" + ) + + +def parse_conditions( + mapping: Mapping[Any, Any], *, where: str, allow_derived: bool = False +) -> Mapping[str, Condition]: + """Parse a `when:`-shaped mapping into conditions, keyed by the raw condition key. + + Public since SPEC-v0.3 §11: a grant's `constraints:` is in exactly this syntax and MUST be + parsed by this code (§4.5). The key is injective given §3.2's longest-suffix split, which + is what lets `Grant`'s containment check look a dimension up by name. + + `allow_derived` admits §7.4's derived subjects and **defaults to off**, so `authority.py` -- + which calls this without it — sees exactly the surface it saw in v0.3. A grant naming + `data_scope` is refused as it always was, which is what keeps §11's *"matching a grant on a + data label"* out of v0.6 rather than letting it in through a shared parser. + """ + conditions: dict[str, Condition] = {} + for key, operand in mapping.items(): + condition = _parse_condition(key, operand, where, allow_derived) + conditions[condition.key] = condition + return conditions + + +def _parse_condition( + key: object, operand: object, where: str, allow_derived: bool = False +) -> Condition: + if not isinstance(key, str): + raise PolicyError(f"{where}: condition keys must be strings, got {key!r}") + argument, op = _split_condition_key(key, where, allow_derived) + return Condition( + key=key, + argument=argument, + op=op, + operand=_parse_operand(op, operand, where, key), + ) + + +def _split_condition_key(key: str, where: str, allow_derived: bool = False) -> tuple[str, str]: + for op in _OPERATORS_BY_LENGTH: + suffix = f"_{op}" + if key.endswith(suffix) and len(key) > len(suffix): + argument = key[: -len(suffix)] + if argument in DERIVED_SUBJECTS and allow_derived: + return argument, op + if argument in DERIVED_SUBJECTS: + # Reached only where derived subjects are not admitted -- an authority + # `constraints:` mapping. §11 puts *"matching a grant on a data label"* out of + # v0.6, and the message says which surface refused it rather than claiming the + # name is an `Action` field, which `data_scope` is not. + raise PolicyError( + f"{where}: condition {key!r} addresses {argument!r}, which a policy rule may " + f"see and a grant may not. Matching a grant on {argument!r} is not in v0.6; " + "write the rule in the policy instead." + ) + if argument in RESERVED_ARGUMENTS: + raise PolicyError( + f"{where}: condition {key!r} names the Action field {argument!r}, not an " + "argument; a v0.1 condition can only address the action's arguments, so " + "this rule would never match. If the protected function really does take " + f"an argument called {argument!r}, rename it." + ) + return argument, op + raise PolicyError( + f"{where}: condition {key!r} must be '_' where op is one of " + f"{', '.join(sorted(_OPERATORS))}" + ) + + +def _parse_operand(op: str, operand: object, where: str, key: str) -> object: + if op in _NUMERIC_COMPARE: + if not _is_int(operand): + # SPEC-v0.3 §4.5 — the message names the representation rule, because the operator + # who wrote `amount_lte: "2000.00"` has hit a real limit and not a typo: only + # integer arguments can be bounded, so a deployment representing money as decimal + # strings cannot express an amount ceiling in a grant at all. + raise PolicyError( + f"{where}: condition {key!r}: a numeric operator needs an int operand, " + f"got {_type_name(operand)}. Only integers can be bounded, so an amount that " + "a rule or a grant compares is written in integer minor units " + "(amount_lte: 200000), never as a decimal string" + ) + return operand + if op == "in": + if not isinstance(operand, list): + raise PolicyError( + f"{where}: condition {key!r}: '_in' needs a list operand, got {_type_name(operand)}" + ) + return tuple(_checked_operand(item, where, key) for item in operand) + checked = _checked_operand(operand, where, key) + if op in {"eq", "neq"} and isinstance(checked, list | tuple): + # A derived, set-valued subject is compared with `frozenset(...)`, so every element + # has to be hashable. `data_scope_eq: [[phi]]` used to load clean and then raise + # `TypeError: unhashable type: 'list'` on every evaluation of the action -- out of + # `Control.execute`, and not as a `CTRLRunError`, so an application catching the + # kernel's own errors did not catch it. Refuse here, where the message can name the + # condition and the operator can find the line. + for item in checked: + if isinstance(item, list | tuple | Mapping): + raise PolicyError( + f"{where}: condition {key!r}: a set-valued operand holds strings, " + f"got {_type_name(item)}. Write the labels as a flat list " + "(data_scope_eq: [phi, pci]), not nested" + ) + return checked + + +def _checked_operand(operand: object, where: str, key: str) -> object: + """Validate an operand against the argument types allowed by SPEC-v0.1 §2.3.""" + if isinstance(operand, float): + raise PolicyError( + f"{where}: condition {key!r}: float operands are not allowed; use integer minor " + "units (amount_lte: 50000) or a decimal string" + ) + if operand is None or isinstance(operand, str | int): # bool is a subclass of int + return operand + if isinstance(operand, Mapping): + for name in operand: + if not isinstance(name, str): + raise PolicyError( + f"{where}: condition {key!r}: operand keys must be strings, got {name!r}" + ) + return {name: _checked_operand(value, where, key) for name, value in operand.items()} + if isinstance(operand, list): + return [_checked_operand(item, where, key) for item in operand] + raise PolicyError( + f"{where}: condition {key!r}: {_type_name(operand)} is not an allowed operand type" + ) diff --git a/src/ctrlrun/policy.py b/src/ctrlrun/policy.py index 5d495cc9..4e7848fe 100644 --- a/src/ctrlrun/policy.py +++ b/src/ctrlrun/policy.py @@ -14,7 +14,6 @@ import hashlib import logging -import operator import os import re import unicodedata @@ -29,85 +28,53 @@ import yaml from .action import Action, PlainValue, canonical_bytes - -# Re-exported, not merely used. `SPEC-v0.1.md` §8 freezes `from .policy import Decision, Policy` -# in `__init__.py`, and `adapter.py` imports `Decision` from here too. Both names moved down to -# break the module cycle §6 records, and both still resolve from this module because that block -# is a frozen public surface and a cycle is not a reason to move a published import path. from .decision import POLICY_UNAPPROVED as POLICY_UNAPPROVED from .decision import Decision as Decision from .effect import template_placeholders from .errors import InvalidArgument, PolicyError +from .grammar import _NUMERIC_COMPARE as _NUMERIC_COMPARE +from .grammar import _OPERATORS as _OPERATORS +from .grammar import _OPERATORS_BY_LENGTH as _OPERATORS_BY_LENGTH +from .grammar import _V3_TOP_LEVEL_KEYS as _V3_TOP_LEVEL_KEYS +from .grammar import _V7_GRANT_KEYS as _V7_GRANT_KEYS -#: The schema `ctrlrun init` writes, and the one every v0.1 file declares. -POLICY_SCHEMA: Final = "ctrlrun.policy/v1" - -#: SPEC-v0.2 §3.1 — required by any document using `effect:`, `resource:` or `mcp:`. A v2 -#: file fails to load on v0.1, correctly: v0.1 would ignore the effect template and execute -#: with no duplicate protection at all. The schema string is the only thing standing between -#: those two outcomes, so it is not optional and not inferred. -POLICY_SCHEMA_V2: Final = "ctrlrun.policy/v2" - -#: SPEC-v0.3 §12.1 — required by any document using `environment:`, and later by `authority:` -#: and `mode:`. A v3 key in an older document is a load error naming the key and the schema, -#: for the reason v0.2 gives: a reader that ignored it would run with a guarantee switched off. -POLICY_SCHEMA_V3: Final = "ctrlrun.policy/v3" - -#: SPEC-v0.6 §7.1, §9.5 — required by any document using `version:`, `controls:` or `data:`. -#: `v1`, `v2` and `v3` documents load unchanged and get a `policy_hash` like any other; only -#: those three keys need `v4`. -POLICY_SCHEMA_V4: Final = "ctrlrun.policy/v4" - -#: SPEC-v0.7 §5.3 — required by any document using `max_attempts:`. An 0.6.1 reader refuses a -#: `v5` document outright, which is the fail-closed direction: a reader that ignored the key -#: would renew without a ceiling, which is the behaviour the key exists to bound. -POLICY_SCHEMA_V5: Final = "ctrlrun.policy/v5" - -#: SPEC-v0.8 §11.3: `v6` adds `approver_role` on a control entry, and items 4 and 5 add -#: their keys under it. The version moves once, here, for the reason §11.4 gives: an older -#: reader must refuse a document whose keys it would otherwise ignore, and a reader that -#: ignored `approver_role` would run a deployment believing nobody was gated. -POLICY_SCHEMA_V6: Final = "ctrlrun.policy/v6" - -#: SPEC-v0.9 §10.1: `v7` adds `tasks` on a grant (item 1) and `budgets` on one (item 3). The -#: version moves once, here, with item 1, and item 3 fills it under the version already in -#: place: two branches racing a schema bump is how a catalogue ends up with a stub row. -POLICY_SCHEMA_V7: Final = "ctrlrun.policy/v7" -#: SPEC-v0.10 §4.6 — `v8` adds one action-entry key, `upstream:`. Bumped once, by item 3. -POLICY_SCHEMA_V8: Final = "ctrlrun.policy/v8" - -#: All of them, newest last, for the message an unknown schema produces. **In version order**, -#: which `_at_least` reads: a version added out of order would make every gate below lie. -SUPPORTED_SCHEMAS: Final = ( - POLICY_SCHEMA, - POLICY_SCHEMA_V2, - POLICY_SCHEMA_V3, - POLICY_SCHEMA_V4, - POLICY_SCHEMA_V5, - POLICY_SCHEMA_V6, - POLICY_SCHEMA_V7, - POLICY_SCHEMA_V8, -) - - -def _at_least(schema: str, minimum: str) -> bool: - """Whether `schema` is `minimum` or a later version (SPEC-v0.7 §5.3). - - Each version is a **superset** of the one before: a `v5` document may use every key any - earlier version allows. Three gates in this module compared for equality instead, which was - right while `v4` was the newest and became wrong the moment it was not; `require_v3`'s own - comment predicted it. An unknown schema is refused before any of them runs, so a name that - is not in `SUPPORTED_SCHEMAS` cannot reach here from `Policy._from_document`; where one does, - from `authority.py`'s standalone path, it is treated as too old, which is fail-closed. - """ - if schema not in SUPPORTED_SCHEMAS or minimum not in SUPPORTED_SCHEMAS: - return False - return SUPPORTED_SCHEMAS.index(schema) >= SUPPORTED_SCHEMAS.index(minimum) - +# Re-exported, not merely used. `SPEC-v0.1.md` §8 freezes `from .policy import Decision, Policy` +# in `__init__.py`, and `adapter.py` imports `Decision` from here too. Both names moved down to +# break the module cycle §6 records, and both still resolve from this module because that block +# is a frozen public surface and a cycle is not a reason to move a published import path. +# Re-exported, not merely used. `SPEC-v0.3.md` §8 freezes +# `from .policy import Condition, parse_conditions`, and `SPEC-v0.1.md` §8 the `__init__` block, +# so every one of these keeps resolving from here. They moved to `grammar.py` to break the last +# cycle §6 carried an exception for; see that module. +from .grammar import DERIVED_SUBJECTS as DERIVED_SUBJECTS +from .grammar import MODE_KEY as MODE_KEY +from .grammar import POLICY_SCHEMA as POLICY_SCHEMA +from .grammar import POLICY_SCHEMA_V2 as POLICY_SCHEMA_V2 +from .grammar import POLICY_SCHEMA_V3 as POLICY_SCHEMA_V3 +from .grammar import POLICY_SCHEMA_V4 as POLICY_SCHEMA_V4 +from .grammar import POLICY_SCHEMA_V5 as POLICY_SCHEMA_V5 +from .grammar import POLICY_SCHEMA_V6 as POLICY_SCHEMA_V6 +from .grammar import POLICY_SCHEMA_V7 as POLICY_SCHEMA_V7 +from .grammar import POLICY_SCHEMA_V8 as POLICY_SCHEMA_V8 +from .grammar import RESERVED_ARGUMENTS as RESERVED_ARGUMENTS +from .grammar import SUPPORTED_SCHEMAS as SUPPORTED_SCHEMAS +from .grammar import Condition as Condition +from .grammar import _at_least as _at_least +from .grammar import _checked_operand as _checked_operand +from .grammar import _equal as _equal +from .grammar import _is_container as _is_container +from .grammar import _is_int as _is_int +from .grammar import _parse_condition as _parse_condition +from .grammar import _parse_operand as _parse_operand +from .grammar import _split_condition_key as _split_condition_key +from .grammar import _StrictLoader as _StrictLoader +from .grammar import _type_name as _type_name +from .grammar import parse_conditions as parse_conditions +from .grammar import reject_nested_mode as reject_nested_mode +from .grammar import require_v3 as require_v3 +from .grammar import require_v7 as require_v7 +from .grammar import strict_load as strict_load -#: SPEC-v0.3 §6.1 — the two values of the top-level `mode:` key, and nothing else. Absent -#: means `enforce`: the fail-closed default, so a document that predates the key enforces. -MODE_KEY: Final = "mode" OBSERVE: Final = "observe" ENFORCE: Final = "enforce" POLICY_MODES: Final = (OBSERVE, ENFORCE) @@ -122,15 +89,6 @@ def _at_least(schema: str, minimum: str) -> bool: _LOG = logging.getLogger(__name__) -_NUMERIC_COMPARE: Final[Mapping[str, Callable[[int, int], bool]]] = { - "lt": operator.lt, - "lte": operator.le, - "gt": operator.gt, - "gte": operator.ge, -} -_OPERATORS: Final = ("eq", "neq", "in", *_NUMERIC_COMPARE) -#: Longest first, so `amount_neq` reads as (amount, neq) and never as (amount_n, eq). -_OPERATORS_BY_LENGTH: Final = tuple(sorted(_OPERATORS, key=len, reverse=True)) _TOP_LEVEL_KEYS: Final = frozenset( {"schema", "actions", "environment", "authority", "mode", "version", "controls"} @@ -154,15 +112,6 @@ def _at_least(schema: str, minimum: str) -> bool: ), } -#: SPEC-v0.3 §12.1 — the top-level keys that need `ctrlrun.policy/v3`, and what an older -#: reader would do with each if it ignored one. -_V3_TOP_LEVEL_KEYS: Final[Mapping[str, str]] = { - "environment": ("an older reader would ignore it and put every action in the wrong deployment"), - "authority": ( - "an older reader would ignore it and run every action with no authority check at all" - ), - "mode": ("an older reader would enforce a configuration that was deployed to observe"), -} #: §7.4 — the action-entry keys that need `ctrlrun.policy/v4`, and what an older reader would do. _V4_ENTRY_KEYS: Final[Mapping[str, str]] = { "data": ( @@ -250,35 +199,6 @@ def _at_least(schema: str, minimum: str) -> bool: #: needs it and dependencies point downward: `policy.py` does not import `control.py`. DEFAULT_ENVIRONMENT: Final = "production" -RESERVED_ARGUMENTS: Final = frozenset( - { - "action_id", - "agent", - "claims", - "data_scope", - "environment", - "expires_at", - "issuer", - "principal", - "resource", - "user", - } -) - -#: SPEC-v0.6 §7.4 — names refused as **arguments** and permitted as **condition subjects**, -#: resolved at evaluation from something other than `action.canonical_arguments`. -#: -#: The distinction is what makes `data_scope` implementable at all. Today one check does both -#: jobs: the splitter refuses a condition whose subject is in `RESERVED_ARGUMENTS`, which is how -#: `claims_eq:` becomes a load error. Adding `data_scope` to that set unchanged would have made -#: `data_scope_in:` a load error too -- the very condition §7.4 asks operators to write. -#: -#: **A name here is still refused as an argument**, so one name never means two things in one -#: document. And this list is the *policy evaluator's*: authority `constraints:` do not consult -#: it, so a grant naming `data_scope` is refused exactly as it always was (§11 puts matching a -#: grant on a data label out of scope). -DERIVED_SUBJECTS: Final = frozenset({"data_scope"}) - def _refuse_reserved(names: Iterable[str], where: str, what: str) -> None: """Refuse a **derived** subject used as an argument name, wherever an argument is named. @@ -351,123 +271,6 @@ class Evaluation: controls: tuple[str, ...] = () -def _is_int(value: object) -> bool: - """True for a real int. `bool` subclasses int in Python; SPEC-v0.1 §3.2 excludes it.""" - return isinstance(value, int) and not isinstance(value, bool) - - -def _type_name(value: object) -> str: - return type(value).__name__ - - -def _equal(value: object, operand: object) -> bool: - """Type-strict equality: `True` never equals `1`, and a list never equals a scalar. - - SPEC: §3.2 — equality is type-strict and applies recursively inside containers. - Canonical arguments distinguish bool from int (§2.3), so conditions must too, or a - policy written for `1` would match `True`. - """ - if isinstance(value, bool) or isinstance(operand, bool): - return value is operand - if isinstance(value, Mapping) and isinstance(operand, Mapping): - return value.keys() == operand.keys() and all( - _equal(value[key], operand[key]) for key in value - ) - if isinstance(value, list | tuple) and isinstance(operand, list | tuple): - return len(value) == len(operand) and all( - _equal(item, other) for item, other in zip(value, operand, strict=True) - ) - if _is_container(value) or _is_container(operand): - return False - return bool(value == operand) - - -def _is_container(value: object) -> bool: - return isinstance(value, Mapping | list | tuple) - - -@dataclass(frozen=True) -class Condition: - """One `_: operand` test against an action's arguments (SPEC-v0.1 §3.2). - - Public since SPEC-v0.3 §11, because a `Grant`'s constraints are made of them and the two - axes share one evaluator: a second implementation would be a second place for `True` to - start comparing equal to `1`. `key` is the raw condition key the author wrote. - """ - - key: str - argument: str - op: str - operand: Any - - def matches(self, action_name: str, arguments: Mapping[str, Any]) -> bool: - if self.argument not in arguments: - # SPEC §3.2 — still false, never an error, but never silent either. Defaults are - # applied when a call is bound (§8), so an argument is either always present or - # never: an absent one is a typo, and silence let a mistyped rule disappear into - # a catch-all below it. - _LOG.warning( - "%s: condition %s ignored: the action has no argument %r (it has: %s)", - action_name, - self.key, - self.argument, - ", ".join(sorted(arguments)) or "none", - ) - return False - value = arguments[self.argument] - if self.op in {"eq", "neq"} and self.argument in DERIVED_SUBJECTS: - # SPEC-v0.6 §7.4: *"`_eq` and `_neq` compare the whole set."* **The whole set, and - # a set has no order.** The derived value is a `sorted(...)` list, and `_equal` on - # lists is order-sensitive -- so an independent review found `data_scope_eq: [phi, - # internal]` never matching, silently, while `[internal, phi]` did. An operator - # writing the labels in the order their own `data:` map declares them gets a rule - # that never fires, with no warning: the key splits, the subject is present, and - # `matches` simply returns `False` and falls through to whatever is below. Where - # the rule was the `deny` or `approve`, that is fail-open. - # - # Narrowed to `DERIVED_SUBJECTS` for exactly the reason `_in` below is: an ordinary - # list-valued argument means *this list*, and `value_eq: [1, 2]` against `[2, 1]` - # must stay false. This branch is one line away from the one that regressed when it - # was written too wide, and it is written narrow for the same reason. - if not isinstance(value, list | tuple) or not isinstance(self.operand, list | tuple): - return ( - _equal(value, self.operand) - if self.op == "eq" - else not _equal(value, self.operand) - ) - same = frozenset(value) == frozenset(self.operand) - return same if self.op == "eq" else not same - if self.op == "eq": - return _equal(value, self.operand) - if self.op == "neq": - return not _equal(value, self.operand) - if self.op == "in": - if self.argument in DERIVED_SUBJECTS and isinstance(value, list | tuple): - # SPEC-v0.6 §7.4 — a **derived, set-valued** subject intersects the list, which - # is the membership `_in` already expresses one element at a time. §7.4 adds no - # operator for it: `contains` and `not_in` would land in `_OPERATORS`, which - # authority `constraints:` share (`v0.3 §4.5` -- one implementation, not two), - # and §11 puts matching a grant on a data label out of scope. - # - # **Narrowed to derived subjects, and the first version was not.** Testing every - # list-valued subject changed `_in` for ordinary arguments: `value_in: [[1, 2]]` - # against `value = [1, 2]` means *this exact list is one of the operands* and has - # since v0.1, and intersecting broke it. A rule about a new subject may not - # quietly re-mean an operator for the old ones. - return any(_equal(item, candidate) for item in value for candidate in self.operand) - return any(_equal(value, item) for item in self.operand) - if not _is_int(value): - _LOG.warning( - "%s: condition %s ignored: argument %r is %s, not int", - action_name, - self.key, - self.argument, - _type_name(value), - ) - return False - return _NUMERIC_COMPARE[self.op](value, self.operand) - - @dataclass(frozen=True) class _Rule: decision: Decision @@ -673,80 +476,6 @@ def evaluate( return Evaluation(Decision.DENY, NO_MATCHING_RULE, _in_registry_order(self.controls, order)) -class _StrictLoader(yaml.SafeLoader): # type: ignore[misc] # PyYAML ships no stubs - """`yaml.SafeLoader` that refuses a repeated mapping key instead of resolving it. - - YAML says a duplicated key is an error and PyYAML resolves it to the last one anyway, - silently. That is a fail-**open** in the authority document: a grant written as - - actions: ["payments.refund"] - actions: ["**"] - - -- the shape of a half-finished narrowing edit -- loads as `("**",)` with no warning, and - `ctrlrun verify` reads this same loader, so nothing downstream catches it either. Every - other mistake in these documents is refused, the key sets being closed at every level, so - a clean load reads as "the document is what I meant". - - The node carries the line, which is exactly what the message needs. - """ - - def construct_mapping( - self, - node: Any, # noqa: ANN401 - PyYAML's node type, and PyYAML ships no stubs - deep: bool = False, - ) -> dict[Any, Any]: - seen: set[Any] = set() - for key_node, _ in node.value: - key = self.construct_object(key_node, deep=True) - try: - duplicate = key in seen - except TypeError: # an unhashable key; the base class refuses it below - continue - if duplicate: - mark = key_node.start_mark - raise yaml.constructor.ConstructorError( - None, - None, - f"duplicate key {key!r} on line {mark.line + 1}, column {mark.column + 1}", - mark, - ) - seen.add(key) - return super().construct_mapping(node, deep) # type: ignore[no-any-return] - - -def strict_load(text: str, source: str) -> Any: # noqa: ANN401 - any YAML scalar or node - """`yaml.safe_load`, refusing a repeated key. The one loader for every CTRLRun document. - - **`yaml.YAMLError` is not the whole contract.** PyYAML converts a scalar before it has - decided the document is well formed, and three conversions raise the interpreter's own - exception rather than a `YAMLError`: - - - `"\\U0001f600"` with too many digits overflows converting the codepoint to a C int, - which is `OverflowError`. The fuzzer found this one after 174,380 executions; - - `"\\U00110000"` is a legal-looking escape above the Unicode maximum, and `chr()` says - `ValueError`; - - `2026-99-99` is a `ValueError` from `datetime`, and is the one that matters: a mistyped - date is a thing a person writes in a real policy, not a thing a fuzzer invents. - - Each was a crash where the caller was promised a refusal, and `Policy.from_yaml` says - "anything malformed raises `PolicyError`" without qualification. The document is refused - either way, so nothing unsafe was ever admitted; what leaked was the exception type, and a - caller that catches `PolicyError` around a policy load would not have caught these. - - `ValueError` and `OverflowError` are therefore refusals too. The catch is deliberately not - `Exception`: this function calls one thing, so a `MemoryError` or a `KeyboardInterrupt` - here is not the document's fault and must not be reported as one. `RecursionError` is left - uncaught for the same reason, and `fuzz/properties.py` says so where it excludes it. - """ - try: - # `_StrictLoader` derives from `SafeLoader`, so this constructs no arbitrary object. - return yaml.load(text, Loader=_StrictLoader) - except yaml.YAMLError as exc: - raise PolicyError(f"{source}: not valid YAML: {exc}") from exc - except (ValueError, OverflowError) as exc: - raise PolicyError(f"{source}: not valid YAML: {type(exc).__name__}: {exc}") from exc - - @dataclass(frozen=True) class Policy: """Action-level autonomy policy: which actions may run, and under which conditions. @@ -1188,26 +917,6 @@ def _plain(value: object) -> PlainValue: ) -def require_v3(document: Mapping[Any, Any], schema: str, source: str) -> None: - """Refuse a `ctrlrun.policy/v3` key in an older document (SPEC-v0.3 §12.1). - - Shared with `authority.py`, which reads the same key from a document the policy loader - may never see: SPEC-v0.3 §8.3's `--authority` file carries `schema` and `authority` and - nothing else, so the check has to exist on both paths rather than on whichever runs first. - """ - # v4 is a superset: a `v4` document may use every `v3` key. Comparing for equality here was - # right while v3 was the newest and becomes a bug the moment it is not. SPEC-v0.7 §5.3: the - # membership test that replaced it had the same shape, so `v5` reads it through `_at_least`. - if _at_least(schema, POLICY_SCHEMA_V3): - return - for key, consequence in _V3_TOP_LEVEL_KEYS.items(): - if key in document: - raise PolicyError( - f"{source}: {key!r} needs 'schema: {POLICY_SCHEMA_V3}'; this document " - f"declares {schema!r}, and {consequence}" - ) - - def require_v4(document: Mapping[Any, Any], schema: str, source: str) -> None: """Refuse a `ctrlrun.policy/v4` key in an older document (SPEC-v0.6 §7.1). @@ -1227,54 +936,6 @@ def require_v4(document: Mapping[Any, Any], schema: str, source: str) -> None: ) -#: SPEC-v0.9 §10.1 — the grant-entry keys that need `ctrlrun.policy/v7`, and what an older -#: reader would do with each. Both are authorization dimensions, so both fail the same way: an -#: older reader ignores the key and grants more than the document says. -_V7_GRANT_KEYS: Final[Mapping[str, str]] = { - "tasks": ( - "an older reader would ignore the binding and authorise the grant on every task, which " - "is the whole of what the key restricts" - ), - "budgets": ( - "an older reader would ignore the limit and let the grant spend without bound, which is " - "the whole of what the key restricts" - ), -} - - -def require_v7(document: Mapping[Any, Any], schema: str, source: str) -> None: - """Refuse a `ctrlrun.policy/v7` grant key in an older document (SPEC-v0.9 §10.1). - - `require_v3`'s shape, two versions up, and shared with `authority.py` for the same reason: - `SPEC-v0.3 §8.3`'s `--authority` file carries `schema` and `authority` and nothing else, so - a gate that lived only in the policy loader would not run on that path at all. The keys are - one level deeper than v3's and v4's, hence the walk rather than a membership test. - """ - if _at_least(schema, POLICY_SCHEMA_V7): - return - section = document.get("authority") - if not isinstance(section, Mapping): - return - entries: list[Any] = [] - grants = section.get("grants") - if isinstance(grants, list): - entries.extend(grants) - elif isinstance(grants, Mapping): - entries.extend(grants.values()) - envelopes = section.get("break_glass") - if isinstance(envelopes, Mapping): - entries.extend(envelopes.values()) - for entry in entries: - if not isinstance(entry, Mapping): - continue - for key, consequence in _V7_GRANT_KEYS.items(): - if key in entry: - raise PolicyError( - f"{source}: {key!r} on a grant needs 'schema: {POLICY_SCHEMA_V7}'; this " - f"document declares {schema!r}, and {consequence}" - ) - - def _parse_mode(document: Mapping[Any, Any], source: str) -> Literal["observe", "enforce"]: """The top-level `mode:`, or the fail-closed default (SPEC-v0.3 §6.1). @@ -1294,47 +955,6 @@ def _parse_mode(document: Mapping[Any, Any], source: str) -> Literal["observe", return OBSERVE if value == OBSERVE else ENFORCE -def reject_nested_mode(mapping: Mapping[Any, Any], where: str) -> None: - """Refuse a `mode:` anywhere but the top level of the policy document (SPEC-v0.3 §6.1). - - The closed key sets of `v0.1 §3.1` would already refuse it as unknown, wherever they - reach. This runs first and for its *message*: "unknown key 'mode'" reads as "CTRLRun has - no such setting", and the author who wrote it here believes they have observed one action - while enforcing the rest. A partially-enforced configuration is the failure mode the - top-level-only rule exists to prevent, so the error says which rule was broken. - - Shared with `authority.py`, which owns the two nestings inside an `authority:` section and - parses documents the policy loader never reads (§4.8). - """ - if MODE_KEY in mapping: - raise PolicyError( - f"{where}: 'mode' is top level and nothing else (SPEC-v0.3 §6.1). A configuration " - "where some actions are observed and some are enforced is one where nobody can say " - "whether an action was permitted or merely watched; move it beside 'schema:'" - ) - - -def parse_conditions( - mapping: Mapping[Any, Any], *, where: str, allow_derived: bool = False -) -> Mapping[str, Condition]: - """Parse a `when:`-shaped mapping into conditions, keyed by the raw condition key. - - Public since SPEC-v0.3 §11: a grant's `constraints:` is in exactly this syntax and MUST be - parsed by this code (§4.5). The key is injective given §3.2's longest-suffix split, which - is what lets `Grant`'s containment check look a dimension up by name. - - `allow_derived` admits §7.4's derived subjects and **defaults to off**, so `authority.py` -- - which calls this without it — sees exactly the surface it saw in v0.3. A grant naming - `data_scope` is refused as it always was, which is what keeps §11's *"matching a grant on a - data label"* out of v0.6 rather than letting it in through a shared parser. - """ - conditions: dict[str, Condition] = {} - for key, operand in mapping.items(): - condition = _parse_condition(key, operand, where, allow_derived) - conditions[condition.key] = condition - return conditions - - def _reject_unknown_keys(mapping: Mapping[Any, Any], allowed: Iterable[str], where: str) -> None: unknown = sorted(repr(key) for key in mapping if key not in set(allowed)) if unknown: @@ -1923,109 +1543,3 @@ def _parse_rule(rule: object, where: str, known: frozenset[str] = frozenset()) - conditions=tuple(parse_conditions(when, where=where, allow_derived=True).values()), controls=cited, ) - - -def _parse_condition( - key: object, operand: object, where: str, allow_derived: bool = False -) -> Condition: - if not isinstance(key, str): - raise PolicyError(f"{where}: condition keys must be strings, got {key!r}") - argument, op = _split_condition_key(key, where, allow_derived) - return Condition( - key=key, - argument=argument, - op=op, - operand=_parse_operand(op, operand, where, key), - ) - - -def _split_condition_key(key: str, where: str, allow_derived: bool = False) -> tuple[str, str]: - for op in _OPERATORS_BY_LENGTH: - suffix = f"_{op}" - if key.endswith(suffix) and len(key) > len(suffix): - argument = key[: -len(suffix)] - if argument in DERIVED_SUBJECTS and allow_derived: - return argument, op - if argument in DERIVED_SUBJECTS: - # Reached only where derived subjects are not admitted -- an authority - # `constraints:` mapping. §11 puts *"matching a grant on a data label"* out of - # v0.6, and the message says which surface refused it rather than claiming the - # name is an `Action` field, which `data_scope` is not. - raise PolicyError( - f"{where}: condition {key!r} addresses {argument!r}, which a policy rule may " - f"see and a grant may not. Matching a grant on {argument!r} is not in v0.6; " - "write the rule in the policy instead." - ) - if argument in RESERVED_ARGUMENTS: - raise PolicyError( - f"{where}: condition {key!r} names the Action field {argument!r}, not an " - "argument; a v0.1 condition can only address the action's arguments, so " - "this rule would never match. If the protected function really does take " - f"an argument called {argument!r}, rename it." - ) - return argument, op - raise PolicyError( - f"{where}: condition {key!r} must be '_' where op is one of " - f"{', '.join(sorted(_OPERATORS))}" - ) - - -def _parse_operand(op: str, operand: object, where: str, key: str) -> object: - if op in _NUMERIC_COMPARE: - if not _is_int(operand): - # SPEC-v0.3 §4.5 — the message names the representation rule, because the operator - # who wrote `amount_lte: "2000.00"` has hit a real limit and not a typo: only - # integer arguments can be bounded, so a deployment representing money as decimal - # strings cannot express an amount ceiling in a grant at all. - raise PolicyError( - f"{where}: condition {key!r}: a numeric operator needs an int operand, " - f"got {_type_name(operand)}. Only integers can be bounded, so an amount that " - "a rule or a grant compares is written in integer minor units " - "(amount_lte: 200000), never as a decimal string" - ) - return operand - if op == "in": - if not isinstance(operand, list): - raise PolicyError( - f"{where}: condition {key!r}: '_in' needs a list operand, got {_type_name(operand)}" - ) - return tuple(_checked_operand(item, where, key) for item in operand) - checked = _checked_operand(operand, where, key) - if op in {"eq", "neq"} and isinstance(checked, list | tuple): - # A derived, set-valued subject is compared with `frozenset(...)`, so every element - # has to be hashable. `data_scope_eq: [[phi]]` used to load clean and then raise - # `TypeError: unhashable type: 'list'` on every evaluation of the action -- out of - # `Control.execute`, and not as a `CTRLRunError`, so an application catching the - # kernel's own errors did not catch it. Refuse here, where the message can name the - # condition and the operator can find the line. - for item in checked: - if isinstance(item, list | tuple | Mapping): - raise PolicyError( - f"{where}: condition {key!r}: a set-valued operand holds strings, " - f"got {_type_name(item)}. Write the labels as a flat list " - "(data_scope_eq: [phi, pci]), not nested" - ) - return checked - - -def _checked_operand(operand: object, where: str, key: str) -> object: - """Validate an operand against the argument types allowed by SPEC-v0.1 §2.3.""" - if isinstance(operand, float): - raise PolicyError( - f"{where}: condition {key!r}: float operands are not allowed; use integer minor " - "units (amount_lte: 50000) or a decimal string" - ) - if operand is None or isinstance(operand, str | int): # bool is a subclass of int - return operand - if isinstance(operand, Mapping): - for name in operand: - if not isinstance(name, str): - raise PolicyError( - f"{where}: condition {key!r}: operand keys must be strings, got {name!r}" - ) - return {name: _checked_operand(value, where, key) for name, value in operand.items()} - if isinstance(operand, list): - return [_checked_operand(item, where, key) for item in operand] - raise PolicyError( - f"{where}: condition {key!r}: {_type_name(operand)} is not an allowed operand type" - ) diff --git a/tests/test_module_graph.py b/tests/test_module_graph.py index bc6b411a..207c0ccb 100644 --- a/tests/test_module_graph.py +++ b/tests/test_module_graph.py @@ -114,26 +114,25 @@ def test_the_import_order_graph_has_no_cycle() -> None: ) -#: The one layering cycle this repository has decided to keep, and the only one. +#: **Empty, and it is meant to stay empty.** #: -#: `authority.py` imports `Condition` and `parse_conditions` from `policy.py` deliberately and at -#: module level: a grant's `constraints:` is a rule's `when:` syntax and `SPEC-v0.3.md` §4.5 says -#: the two axes MUST share one evaluator. §6 records that as an exception. The reverse edge is -#: `policy.py` reaching `authority.py` from inside `_canonical_authority` and -#: `hash_with_authority`, and it cannot be removed by relocation: `_from_section` *constructs* an -#: `Authority` and `canonical_grants` *consumes* one, so neither moves below `policy.py`, and -#: moving the two callers up to `control.py` would change what `policy_hash` is taken over -- -#: which is evidence in every receipt, not an implementation detail. +#: It held `policy <-> authority` for one commit. `authority.py` imports the condition evaluator +#: from `policy.py` deliberately, because SPEC-v0.3 §4.5 requires the two axes to share one: a +#: second evaluator would be a second place for `True` to start comparing equal to `1`. The +#: reverse edge is `policy.py` reaching `authority.py` from `_canonical_authority` and +#: `hash_with_authority`, and neither direction could be removed on its own. #: -#: So it stays, named, until someone decides that question. An allow-list of one fails the moment -#: a second appears, which a plain "no cycles" assertion softened to a skip never would. -RECORDED_LAYERING_CYCLES: frozenset[frozenset[str]] = frozenset( - {frozenset({"policy", "authority"})} -) +#: Moving the **shared** half down to `grammar.py` removed the cycle without touching either. +#: §4.5's requirement is better served than before, because the one evaluator is now owned by +#: neither axis. +#: +#: A new entry here is a decision, not a fix. Add one only with the reason it cannot be +#: relocated, the way that one carried its reason while it stood. +RECORDED_LAYERING_CYCLES: frozenset[frozenset[str]] = frozenset() def test_the_layering_graph_has_only_the_one_recorded_cycle() -> None: - """§6's actual rule, including deferred imports, with one documented exception. + """§6's actual rule, including deferred imports, and there is no exception left. A function-level import is a real edge for the module map and not one for import order, and conflating them is exactly what let `state -> receipt -> policy -> authority -> state` read as diff --git a/tests/test_properties.py b/tests/test_properties.py new file mode 100644 index 00000000..da2fe5f4 --- /dev/null +++ b/tests/test_properties.py @@ -0,0 +1,239 @@ +# SPDX-FileCopyrightText: 2026 The CTRLRun contributors +# SPDX-License-Identifier: Apache-2.0 +"""Invariants stated directly, over inputs nobody chose by hand. + +**Why this file exists, from this repository's own history.** v0.11 item 1 found that +`verify_chain`'s docstring had claimed since v0.6 that position comes from the store's `seq` +column, and it was false: both backends selected `json, hash` and ordered by a column they never +read, so every position came out of the document, which is the half a tamperer controls. One +`UPDATE` setting a document's `seq` to 99 reported `missing 2`, `content_altered 99`, +`missing 100` and `link_broken 3` -- four breaks at three positions, two of them rows that do not +exist. + +That is a **property**: one tamper is one break, at the row that was tampered with. It survived +five milestones of example tests because every example tampered in a way its author had already +thought of, and the author who writes the tamper is the author who writes the expectation. + +`SPEC-v0.11.md` §13.2 says a test passing is not the evidence and running the real thing is. +This is the other half of that: for a claim shaped like *for every X*, the evidence is a +generator rather than a list. + +**Determinism is pinned, and that is not optional here.** A suite that fails once in a while +teaches people to re-run it, and this project's gate is only worth anything while green means +green -- which the cookbook race (#207) had just finished demonstrating. `derandomize=True` makes +each run draw the same inputs, so a red run reproduces from the same command, and +`deadline=None` keeps a busy `-n auto` worker from failing a test for being slow rather than for +being wrong. A counterexample found in CI is reproducible locally by construction. + +The stores here are real SQLite files with real `Control.execute` writes, not fixtures: these are +the same objects the CLI reads. +""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from ctrlrun import Control, Policy, SQLiteStateStore +from ctrlrun.action import Action, Principal +from ctrlrun.receipt import CHAIN_BREAKS, Receipt, UnreadableReceipt, verify_chain + +T0 = datetime(2026, 1, 1, 12, 0, tzinfo=UTC) +LEASE = timedelta(minutes=5) +ALLOW = "schema: ctrlrun.policy/v1\nactions:\n stripe.refund:\n decision: allow\n" + +#: Every property here builds a real store, so the example count is deliberately modest: the cost +#: is a SQLite file and N `Control.execute` calls, not a pure function call. `derandomize` is what +#: makes a small count trustworthy -- the same inputs every run, so "it passed" means the same +#: thing twice. +PROFILE = settings( + max_examples=40, + derandomize=True, + deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) + + +def _chain(database: Path, count: int) -> SQLiteStateStore: + store = SQLiteStateStore(database, clock=lambda: T0) + control = Control(Policy.from_yaml(ALLOW), store, clock=lambda: T0) + for index in range(count): + control.execute( + Action( + name="stripe.refund", + arguments={"payment_id": f"p{index}", "amount": 1000 + index}, + principal=Principal(agent="a"), + ), + lambda: {"ok": True}, + f"refund:p{index}", + lease=LEASE, + ) + return store + + +# --- the chain reader ------------------------------------------------------------------------- + + +@PROFILE +@given(size=st.integers(min_value=2, max_value=6), where=st.integers(min_value=0, max_value=5)) +def test_an_untouched_chain_of_any_length_verifies(tmp_path_factory, size, where) -> None: + """The positive control, and it has to come first. + + Every property below asserts something about a *tampered* chain. If an untouched chain of + the same shape did not verify, those would all be measuring the wrong thing, and each of + them would still pass. + """ + store = _chain(tmp_path_factory.mktemp("clean") / "state.db", size) + report = verify_chain(store) + store.close() + + assert report.ok, report.breaks + assert report.verified == size + + +@PROFILE +@given( + size=st.integers(min_value=2, max_value=6), + victim=st.integers(min_value=1, max_value=6), + forged=st.integers(min_value=1, max_value=400), +) +def test_every_break_a_tamper_reports_names_a_row_that_exists( + tmp_path_factory, size, victim, forged +) -> None: + """`SPEC-v0.6.md` §6.5, and **the property that actually catches v0.11 item 1's defect**. + + The first version of this test asserted *one tamper is one break*. That is false, and + hypothesis said so on its second example: altering row `n` also breaks the link at `n + 1`, + because `n + 1` carries `prev_hash` over what `n` used to hash to. Two breaks is the correct + answer and the test was wrong, which is worth leaving in the record rather than quietly + fixing, because it is the same mistake in miniature that the guarded property is about -- + an author writing down what they expected instead of what holds. + + What was actually wrong in v0.11 item 1 was not the count. Rewriting one document's `seq` to + 99 on an eight-row chain reported `missing 2`, `content_altered 99`, `missing 100` and + `link_broken 3`: **two of those name rows that do not exist**, because position came from the + document, which is the half a tamperer controls, rather than from the `seq` column. So the + invariant is that every reported position is a row the store actually holds. No arrangement + of a tamperer's chosen values can conjure a break at a row that was never written. + + The bound is asserted too, at two rather than one: a tamper is local, and a reader that + cascaded would be reporting damage the tamperer did not do. + """ + database = tmp_path_factory.mktemp("altered") / "state.db" + store = _chain(database, size) + store.close() + target = victim if victim <= size else size + + connection = sqlite3.connect(database) + stored = connection.execute("SELECT json FROM receipts WHERE seq = ?", (target,)).fetchone()[0] + document = json.loads(stored) + was = document["seq"] + document["seq"] = forged + connection.execute("UPDATE receipts SET json = ? WHERE seq = ?", (json.dumps(document), target)) + connection.commit() + connection.close() + + reopened = SQLiteStateStore(database, clock=lambda: T0) + report = verify_chain(reopened) + reopened.close() + + if forged == was: # writing back the value it already had edits nothing + assert report.ok, report.breaks + return + + assert not report.ok, "a rewritten receipt verified as intact" + positions = {item.seq for item in report.breaks} + assert positions <= set(range(1, size + 1)), ( + f"reported a break at {sorted(positions - set(range(1, size + 1)))}, " + f"and this store holds seq 1..{size}" + ) + assert target in positions, f"edited seq {target}, reported {sorted(positions)}" + assert len(report.breaks) <= 2, f"one local tamper cascaded into {report.breaks}" + assert all(item.name in CHAIN_BREAKS for item in report.breaks) + + +@PROFILE +@given( + size=st.integers(min_value=2, max_value=6), + victim=st.integers(min_value=1, max_value=6), + junk=st.text(min_size=0, max_size=12), +) +def test_one_unreadable_row_costs_exactly_one_row(tmp_path_factory, size, victim, junk) -> None: + """`SPEC-v0.11.md` §5, rule 3: a malformed row names itself and blinds nothing else. + + The generator writes arbitrary text into one row's `json`, which reaches the case the + original tests missed and a review caught: every tamper they ran changed a row's *content*, + and `{}` and a float are both valid JSON, so the parse failure path went unexercised. Here + most drawn strings are not JSON at all. + + What is asserted is the blast radius, which is what the rule is about: exactly one row comes + back unreadable, it is the row that was edited, and every other row still reads as a + `Receipt`. + """ + database = tmp_path_factory.mktemp("unreadable") / "state.db" + store = _chain(database, size) + store.close() + target = victim if victim <= size else size + + connection = sqlite3.connect(database) + connection.execute("UPDATE receipts SET json = ? WHERE seq = ?", (junk, target)) + connection.commit() + connection.close() + + reopened = SQLiteStateStore(database, clock=lambda: T0) + rows = reopened.receipts() + reopened.close() + + assert len(rows) == size, "a row disappeared rather than being named" + unreadable = [row for row in rows if isinstance(row, UnreadableReceipt)] + assert len(unreadable) == 1, f"one bad row produced {len(unreadable)} unreadable" + assert unreadable[0].seq == target + assert all(isinstance(row, Receipt) for row in rows if row.seq != target), ( + "one unreadable row blinded another row" + ) + + +@PROFILE +@given(size=st.integers(min_value=2, max_value=6), victim=st.integers(min_value=1, max_value=6)) +def test_a_deleted_row_is_reported_and_never_silently_skipped( + tmp_path_factory, size, victim +) -> None: + """A gap in the middle is `missing`, and the walk does not renumber around it. + + The interesting half is the **last** row: deleting it is a truncation, and `SPEC-v0.11.md` + §2.1 is the whole reason the anchor exists. The chain alone cannot catch that one, because + the head it would check against is a row in the same database. Asserting "some break is + reported" for every victim would quietly encode the opposite, so the two cases are separated + here and the truncation is asserted as **undetected**, which is what §2.4 states. + """ + database = tmp_path_factory.mktemp("deleted") / "state.db" + store = _chain(database, size) + store.close() + target = victim if victim <= size else size + + connection = sqlite3.connect(database) + connection.execute("DELETE FROM receipts WHERE seq = ?", (target,)) + connection.commit() + connection.close() + + reopened = SQLiteStateStore(database, clock=lambda: T0) + report = verify_chain(reopened) + reopened.close() + + if target == size: + # A truncation with the head left behind is `head_mismatch`: the head still names a row + # that is gone. Rewinding the head as well is the two-statement attack §2.1 measures, and + # that one the chain cannot see at all. + assert not report.ok + assert {item.name for item in report.breaks} <= set(CHAIN_BREAKS) + else: + assert not report.ok, f"deleting seq {target} of {size} went unreported" + assert any(item.seq == target for item in report.breaks), report.breaks + assert all(item.name in CHAIN_BREAKS for item in report.breaks), ( + "a break kind outside the closed set of six" + ) From 75075f86b629cebcbb77c9eb9f61d0a5c87b90ea Mon Sep 17 00:00:00 2001 From: arpan Date: Tue, 15 Sep 2026 03:49:46 +0530 Subject: [PATCH 2/2] Lock hypothesis, which the sdist job needs and pyproject alone does not supply Adding it to the `dev` extra was half the change. `requirements/*.txt` are hash-pinned locks generated from that extra by `scripts/lock.sh`, and the `package` job's sdist step installs from `requirements/ci.txt` with `--require-hashes`, so it went red with `No module named 'hypothesis'` while every `check` job passed. That split is the point of the sdist step: an sdist that ships tests it cannot run reads as broken to anyone packaging it downstream, and the guard found exactly that. Regenerated with `scripts/lock.sh`, which touches three locks. The diff is `hypothesis` and `sortedcontainers`, its one dependency, and nothing else moved. Signed-off-by: arpan --- requirements/adapters.txt | 87 +++++++++++++++++++++++++++++++++++++++ requirements/ci.txt | 87 +++++++++++++++++++++++++++++++++++++++ requirements/docs.txt | 87 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) diff --git a/requirements/adapters.txt b/requirements/adapters.txt index 28c61fba..bb0a71b2 100644 --- a/requirements/adapters.txt +++ b/requirements/adapters.txt @@ -604,6 +604,89 @@ httpx2-jsfetch==1.0 ; python_full_version >= '3.12' and sys_platform == 'emscrip --hash=sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60 \ --hash=sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32 # via httpx2 +hypothesis==6.168.0 \ + --hash=sha256:046fe4bcfce2a2fa186ba9d96bbb62c25c2f6c2e4071f0783ed6b5cc481d0669 \ + --hash=sha256:076a2096c34448931c3cfeb2eb7a6b843a56ffdce5e4e3a025bfdf8f935666d9 \ + --hash=sha256:085c9aa246487c56a40ca89003d285cbffdbb5be4097ba6d0139f9c21003c04a \ + --hash=sha256:0ba3838c4a92e0b9730d1ed7e67e4950c152ad79d0a0c7594065262db84c55c4 \ + --hash=sha256:112b0900059bf9d7d6528ed729770629ab146e0d133c4143b9bd4a01dc002bcc \ + --hash=sha256:16864797de4b024e4c6cebd44598af932f870aad811341bc5bc24c738801ff76 \ + --hash=sha256:1894782fae5d9a7bb44e6dcf848ccb09ccb5babab48d8b5c31a0a7fc025b82a1 \ + --hash=sha256:1d1aa5b3484e329295d88488a5ba06243909e65c2ab616513c2d36721de4ed1d \ + --hash=sha256:1f4cd0ff11bd470a1a846296ed5fe55e84214194850370994fd1370fe73d3099 \ + --hash=sha256:2085ee74ac3ab6b70e2f7ffae9b4cb74c246da2f574b2de81a0818a8a30f659f \ + --hash=sha256:2264f15a1c80329e3ad48e39c44bd5c9429b7b04c9ee62cdd72f4b10aaac9f29 \ + --hash=sha256:24b52a2b1c8db6e1e516f9295c8e4ef7ef63303ff24fbbc5b35f4ff71dcd732c \ + --hash=sha256:283eda952bcb1987ccba1c8b634db0e8a960e1e92e2daa7003bc2392f19cea01 \ + --hash=sha256:2a380b521b5a76a9e8917d64adcf7f861a45a4360a34b1579af14c5df8eb0377 \ + --hash=sha256:2a838218ff1eab8d7b4bf66b96037fce0a802f61f2fa5fd4b784696cac365ce7 \ + --hash=sha256:348d9b93fd4129f67f9bab94f3d70709a9372bbe0e0d22731325ce85d5eb409f \ + --hash=sha256:34e3c8b66047ba92f8b8df5e427074058d92db58038f007da4bf9d14e934ad3c \ + --hash=sha256:35f1262831b5acc74ded15f629965daffcd657f6016ee04fc9605f6eb2b334c0 \ + --hash=sha256:3b3ce1cce70b25a37ed1a38a53ce7204785726c675c0f41a0f83c338a7e47b3d \ + --hash=sha256:3bc00fd8cda04b58e37a1163e8a65389b247b4f5ee547ae37d244a4960995517 \ + --hash=sha256:3f6dcf66270278d078bed01b401f47db4e26456cd909d8e23c6b9366a6c0b131 \ + --hash=sha256:3f7486bed33225d02f6aa78a4c4ba2b6f84992a82571cdda1bf08dce41d13507 \ + --hash=sha256:4085b61e25d3dcc6c9151d4115269870aee8cdb921611ee5c989b2786449be09 \ + --hash=sha256:45fcfa05f746e253350f55f216bcef59754f5f2b85745f1fc2bb8ba81dd517a9 \ + --hash=sha256:47b89491ff02e3ae9b302c440457938e87b47a45b9a1d98ff5575b6910d779e2 \ + --hash=sha256:489d5c060f49f495b64215cae627c71730cffd5ef59dc4d7f431932e6e6d2e67 \ + --hash=sha256:4d7d29dd63ad9fdc4aa1d65fa272449e14aaf6c6bb8451091818c2945533a43a \ + --hash=sha256:527452b43e79e6dfbf9cb69145a940547a3cd177c556698a3fc939ed2354c4b3 \ + --hash=sha256:53469a1a7c4861b12c9a8622f762d7d1fd7bcf171884e1018ed5a8f063a5c063 \ + --hash=sha256:5427a3c951080c18170486f775df6a82153882b819eca6b8e7ed77693634e5ab \ + --hash=sha256:5920d267f7d8cfd376672f2bde5905cdf284d47519582e41ce7c142d48ee46c4 \ + --hash=sha256:5b54769033b84477931d2072e7133a7555e0de5c53fd5ca3bbde960762d7d31b \ + --hash=sha256:5f099b1c8fc49ec2d9d7944e661addb97d7c38e818fb8d1f78073c43895a87f6 \ + --hash=sha256:6b750390dac4429da0cb70ab3fe758457f0cea3d9c843d48c59d0690d1189fda \ + --hash=sha256:6de30e559eb151de14a5f74bceb4d97792a9315ada2a1816b5da825cd7d28edc \ + --hash=sha256:6f0dd437ec01140676192422b61f2f833b3ce6a3213da9b7e196ad6b3777e795 \ + --hash=sha256:6ff259260015f9be3756dcd4bc11c08e007314dec6b43d9a89084c4f34f94475 \ + --hash=sha256:719b45b0512e3535a6a0077c2f7c6053b02ac0e72d60693f66f98790a33855b2 \ + --hash=sha256:72af51087b7b5ab21c49f0d502f803c20897678652835596bd2a8b169a39135e \ + --hash=sha256:73084b76e4a79cd0f7883ce80fc60c9f374ce7dcad8f520b39db40470ce1852f \ + --hash=sha256:732ae5d47482f99d8028cca096729625f05690a83f5e7ce31466e266155792f4 \ + --hash=sha256:754016594fe78cef91790e0922f60d183c52f531255fbfa30dac495b813e2128 \ + --hash=sha256:76d4d36ed2fd62de11382f1d608169c1ffa9a49d3b9351146d8ff87cb81a66f7 \ + --hash=sha256:7d55562bf8d41cfa18559c33f30cadf44ceac8e517509d7a022a9feace621f28 \ + --hash=sha256:8067e6b4b48e5cfdc849a1a20c9d4972b3f532b3e3edb5e2b5dfd106045a5236 \ + --hash=sha256:812a84c4cc7f7ae4fcb39a5647cc2698e6c18254f8423126425578f1dcdac782 \ + --hash=sha256:891b2d281ede45130e7fa0a22fd65336cc77ef2f780ec3792e8de6fc274a02c8 \ + --hash=sha256:8e4b2d434e0dd134f3d31ac1efc1825bf99730dfe70fec005ff66d7211836d79 \ + --hash=sha256:9018b20acdb061b2ef4b2fa7f558ca5db97ffea316e0a528bc003a24b2ac996e \ + --hash=sha256:91e3de666a6c4f7543000d1710e25055d63ef3032c98bd2ab338b3087bdaa780 \ + --hash=sha256:92cff497b92e2285ff6a94193fdee04aba483a4115d501c1f9a570bd103fcd20 \ + --hash=sha256:93413d1b0af50a7b165d66278c529174bf2fd1773c78027735dc0b50d1d3fd27 \ + --hash=sha256:990026952d5b2eca290c88f639ac639233f47e13dae338c6dfb6e4774bcab349 \ + --hash=sha256:9a2079cd09919956dd388f1a1f8ea5a79f2b2437650fbeda31d8661217ffefef \ + --hash=sha256:9a72ed7afa1f7e30488b8a5754fca0ad9755518bdb77d6f0b003cadf7437a5f9 \ + --hash=sha256:9ba679f183c67adcb6f4ad93694beafb6da99fe691757f4e57b04ae77e581ba8 \ + --hash=sha256:9d9a8574f80fc859313aee56167d202e8625c0eedd200971130f0839f06d1c93 \ + --hash=sha256:a0d28418c104d7268fdebcc09bc49f7b6569b5eb942430c6859f53ec8d4edf63 \ + --hash=sha256:a4956f41ab1ec6e6ef9262a35970e9f3e2caaaa1cdafe0d413156c6934dd99d8 \ + --hash=sha256:a74b0945acbbd552c7c2d0a99a3b5232962b8848c8eed1829451800a9bfcf00b \ + --hash=sha256:a9650c4882fdbdd8e90bdae602a8bfa8c6f09dc5d06afec5b9b23982e8f60a04 \ + --hash=sha256:b5449a64eb37d9a4aa6ac9cd2ab0fd1a24145adf421ef1536884f73f39824887 \ + --hash=sha256:bc935a5d5f86fd8f5af951b8fbe00307f6f7c596f82a9a27c17d974f6ab0a26c \ + --hash=sha256:bfef4d46dbf1704a7b8fa3a78778651a2cb18870ca0a70da19c381646822b149 \ + --hash=sha256:c3af200b322f710c76c2189866246cdcff2039165dd77edff1a7bf1157162fb0 \ + --hash=sha256:cb10aa59b0af45badca76911f5323f40d24fdbe00d01b7b67fef8648c99411b5 \ + --hash=sha256:cd0c1dcf308e919c8ae708054d0ad61921ae87634a9aea574a9851da584cebc1 \ + --hash=sha256:d0620fa320fa66649e6bfd71e94f3f86115fffebb7e3c6dcece19d1aaff8e07f \ + --hash=sha256:d0bdb77f976740b8cd5ec697327ea343d02d052b9916d213b5d4c65d823415cd \ + --hash=sha256:db2751c27bffc8491a96d72969649089d5400115e4b7c49bf7167ebbdcc84193 \ + --hash=sha256:deb02de608268928d779aa889b0a9d67794b1cc0c54a322cf19e386be8a46ca7 \ + --hash=sha256:e21e30b76b6d3adb87c550576132a3204f4c257ec43353f6c09b9d59bb762abc \ + --hash=sha256:e2df8afacf9261070795db36db4a394e3ccdbb663fd2d38c7a9fba0c836dcecc \ + --hash=sha256:e86820053afad84677f301c0b892a226be1df49790800a65668ae7cc8a1ac571 \ + --hash=sha256:ec0886fe0be9091669937989f9a662beca42ae14a4a6dab25491c2c63365f88d \ + --hash=sha256:ecf0ab13cef899efb816ffdd7963e0679f372520884ce06756c7642f3df94213 \ + --hash=sha256:f62bdabf278db9ff61df5f3203d608949f0d893d0e30cdac3f2330e67e41ae68 \ + --hash=sha256:f77af7721ff35a58fa8797decd14c932c350a2548686c6e9b844db710a3a2441 \ + --hash=sha256:f89d8e998d3c936ffbbd1c3686c96f0378f6558aecc5967a3035a857f2bab0ad \ + --hash=sha256:fb8cdf45361e259df86e19f8cd042ce2d6c7e6ad88fa631b78a4e3a83c2e572d \ + --hash=sha256:fcc5bad4300a751804ce41f0e10d77f85272668160708ce39ec579bca8984843 + # via ctrlrun (pyproject.toml) idna==3.19 \ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 @@ -1771,6 +1854,10 @@ sniffio==1.3.1 \ # via # langsmith # openai +sortedcontainers==2.4.0 \ + --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ + --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 + # via hypothesis sse-starlette==3.4.11 \ --hash=sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade \ --hash=sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453 diff --git a/requirements/ci.txt b/requirements/ci.txt index 7d274bd7..5428303a 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -555,6 +555,89 @@ httpx==0.28.1 \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad # via ctrlrun (pyproject.toml) +hypothesis==6.168.0 \ + --hash=sha256:046fe4bcfce2a2fa186ba9d96bbb62c25c2f6c2e4071f0783ed6b5cc481d0669 \ + --hash=sha256:076a2096c34448931c3cfeb2eb7a6b843a56ffdce5e4e3a025bfdf8f935666d9 \ + --hash=sha256:085c9aa246487c56a40ca89003d285cbffdbb5be4097ba6d0139f9c21003c04a \ + --hash=sha256:0ba3838c4a92e0b9730d1ed7e67e4950c152ad79d0a0c7594065262db84c55c4 \ + --hash=sha256:112b0900059bf9d7d6528ed729770629ab146e0d133c4143b9bd4a01dc002bcc \ + --hash=sha256:16864797de4b024e4c6cebd44598af932f870aad811341bc5bc24c738801ff76 \ + --hash=sha256:1894782fae5d9a7bb44e6dcf848ccb09ccb5babab48d8b5c31a0a7fc025b82a1 \ + --hash=sha256:1d1aa5b3484e329295d88488a5ba06243909e65c2ab616513c2d36721de4ed1d \ + --hash=sha256:1f4cd0ff11bd470a1a846296ed5fe55e84214194850370994fd1370fe73d3099 \ + --hash=sha256:2085ee74ac3ab6b70e2f7ffae9b4cb74c246da2f574b2de81a0818a8a30f659f \ + --hash=sha256:2264f15a1c80329e3ad48e39c44bd5c9429b7b04c9ee62cdd72f4b10aaac9f29 \ + --hash=sha256:24b52a2b1c8db6e1e516f9295c8e4ef7ef63303ff24fbbc5b35f4ff71dcd732c \ + --hash=sha256:283eda952bcb1987ccba1c8b634db0e8a960e1e92e2daa7003bc2392f19cea01 \ + --hash=sha256:2a380b521b5a76a9e8917d64adcf7f861a45a4360a34b1579af14c5df8eb0377 \ + --hash=sha256:2a838218ff1eab8d7b4bf66b96037fce0a802f61f2fa5fd4b784696cac365ce7 \ + --hash=sha256:348d9b93fd4129f67f9bab94f3d70709a9372bbe0e0d22731325ce85d5eb409f \ + --hash=sha256:34e3c8b66047ba92f8b8df5e427074058d92db58038f007da4bf9d14e934ad3c \ + --hash=sha256:35f1262831b5acc74ded15f629965daffcd657f6016ee04fc9605f6eb2b334c0 \ + --hash=sha256:3b3ce1cce70b25a37ed1a38a53ce7204785726c675c0f41a0f83c338a7e47b3d \ + --hash=sha256:3bc00fd8cda04b58e37a1163e8a65389b247b4f5ee547ae37d244a4960995517 \ + --hash=sha256:3f6dcf66270278d078bed01b401f47db4e26456cd909d8e23c6b9366a6c0b131 \ + --hash=sha256:3f7486bed33225d02f6aa78a4c4ba2b6f84992a82571cdda1bf08dce41d13507 \ + --hash=sha256:4085b61e25d3dcc6c9151d4115269870aee8cdb921611ee5c989b2786449be09 \ + --hash=sha256:45fcfa05f746e253350f55f216bcef59754f5f2b85745f1fc2bb8ba81dd517a9 \ + --hash=sha256:47b89491ff02e3ae9b302c440457938e87b47a45b9a1d98ff5575b6910d779e2 \ + --hash=sha256:489d5c060f49f495b64215cae627c71730cffd5ef59dc4d7f431932e6e6d2e67 \ + --hash=sha256:4d7d29dd63ad9fdc4aa1d65fa272449e14aaf6c6bb8451091818c2945533a43a \ + --hash=sha256:527452b43e79e6dfbf9cb69145a940547a3cd177c556698a3fc939ed2354c4b3 \ + --hash=sha256:53469a1a7c4861b12c9a8622f762d7d1fd7bcf171884e1018ed5a8f063a5c063 \ + --hash=sha256:5427a3c951080c18170486f775df6a82153882b819eca6b8e7ed77693634e5ab \ + --hash=sha256:5920d267f7d8cfd376672f2bde5905cdf284d47519582e41ce7c142d48ee46c4 \ + --hash=sha256:5b54769033b84477931d2072e7133a7555e0de5c53fd5ca3bbde960762d7d31b \ + --hash=sha256:5f099b1c8fc49ec2d9d7944e661addb97d7c38e818fb8d1f78073c43895a87f6 \ + --hash=sha256:6b750390dac4429da0cb70ab3fe758457f0cea3d9c843d48c59d0690d1189fda \ + --hash=sha256:6de30e559eb151de14a5f74bceb4d97792a9315ada2a1816b5da825cd7d28edc \ + --hash=sha256:6f0dd437ec01140676192422b61f2f833b3ce6a3213da9b7e196ad6b3777e795 \ + --hash=sha256:6ff259260015f9be3756dcd4bc11c08e007314dec6b43d9a89084c4f34f94475 \ + --hash=sha256:719b45b0512e3535a6a0077c2f7c6053b02ac0e72d60693f66f98790a33855b2 \ + --hash=sha256:72af51087b7b5ab21c49f0d502f803c20897678652835596bd2a8b169a39135e \ + --hash=sha256:73084b76e4a79cd0f7883ce80fc60c9f374ce7dcad8f520b39db40470ce1852f \ + --hash=sha256:732ae5d47482f99d8028cca096729625f05690a83f5e7ce31466e266155792f4 \ + --hash=sha256:754016594fe78cef91790e0922f60d183c52f531255fbfa30dac495b813e2128 \ + --hash=sha256:76d4d36ed2fd62de11382f1d608169c1ffa9a49d3b9351146d8ff87cb81a66f7 \ + --hash=sha256:7d55562bf8d41cfa18559c33f30cadf44ceac8e517509d7a022a9feace621f28 \ + --hash=sha256:8067e6b4b48e5cfdc849a1a20c9d4972b3f532b3e3edb5e2b5dfd106045a5236 \ + --hash=sha256:812a84c4cc7f7ae4fcb39a5647cc2698e6c18254f8423126425578f1dcdac782 \ + --hash=sha256:891b2d281ede45130e7fa0a22fd65336cc77ef2f780ec3792e8de6fc274a02c8 \ + --hash=sha256:8e4b2d434e0dd134f3d31ac1efc1825bf99730dfe70fec005ff66d7211836d79 \ + --hash=sha256:9018b20acdb061b2ef4b2fa7f558ca5db97ffea316e0a528bc003a24b2ac996e \ + --hash=sha256:91e3de666a6c4f7543000d1710e25055d63ef3032c98bd2ab338b3087bdaa780 \ + --hash=sha256:92cff497b92e2285ff6a94193fdee04aba483a4115d501c1f9a570bd103fcd20 \ + --hash=sha256:93413d1b0af50a7b165d66278c529174bf2fd1773c78027735dc0b50d1d3fd27 \ + --hash=sha256:990026952d5b2eca290c88f639ac639233f47e13dae338c6dfb6e4774bcab349 \ + --hash=sha256:9a2079cd09919956dd388f1a1f8ea5a79f2b2437650fbeda31d8661217ffefef \ + --hash=sha256:9a72ed7afa1f7e30488b8a5754fca0ad9755518bdb77d6f0b003cadf7437a5f9 \ + --hash=sha256:9ba679f183c67adcb6f4ad93694beafb6da99fe691757f4e57b04ae77e581ba8 \ + --hash=sha256:9d9a8574f80fc859313aee56167d202e8625c0eedd200971130f0839f06d1c93 \ + --hash=sha256:a0d28418c104d7268fdebcc09bc49f7b6569b5eb942430c6859f53ec8d4edf63 \ + --hash=sha256:a4956f41ab1ec6e6ef9262a35970e9f3e2caaaa1cdafe0d413156c6934dd99d8 \ + --hash=sha256:a74b0945acbbd552c7c2d0a99a3b5232962b8848c8eed1829451800a9bfcf00b \ + --hash=sha256:a9650c4882fdbdd8e90bdae602a8bfa8c6f09dc5d06afec5b9b23982e8f60a04 \ + --hash=sha256:b5449a64eb37d9a4aa6ac9cd2ab0fd1a24145adf421ef1536884f73f39824887 \ + --hash=sha256:bc935a5d5f86fd8f5af951b8fbe00307f6f7c596f82a9a27c17d974f6ab0a26c \ + --hash=sha256:bfef4d46dbf1704a7b8fa3a78778651a2cb18870ca0a70da19c381646822b149 \ + --hash=sha256:c3af200b322f710c76c2189866246cdcff2039165dd77edff1a7bf1157162fb0 \ + --hash=sha256:cb10aa59b0af45badca76911f5323f40d24fdbe00d01b7b67fef8648c99411b5 \ + --hash=sha256:cd0c1dcf308e919c8ae708054d0ad61921ae87634a9aea574a9851da584cebc1 \ + --hash=sha256:d0620fa320fa66649e6bfd71e94f3f86115fffebb7e3c6dcece19d1aaff8e07f \ + --hash=sha256:d0bdb77f976740b8cd5ec697327ea343d02d052b9916d213b5d4c65d823415cd \ + --hash=sha256:db2751c27bffc8491a96d72969649089d5400115e4b7c49bf7167ebbdcc84193 \ + --hash=sha256:deb02de608268928d779aa889b0a9d67794b1cc0c54a322cf19e386be8a46ca7 \ + --hash=sha256:e21e30b76b6d3adb87c550576132a3204f4c257ec43353f6c09b9d59bb762abc \ + --hash=sha256:e2df8afacf9261070795db36db4a394e3ccdbb663fd2d38c7a9fba0c836dcecc \ + --hash=sha256:e86820053afad84677f301c0b892a226be1df49790800a65668ae7cc8a1ac571 \ + --hash=sha256:ec0886fe0be9091669937989f9a662beca42ae14a4a6dab25491c2c63365f88d \ + --hash=sha256:ecf0ab13cef899efb816ffdd7963e0679f372520884ce06756c7642f3df94213 \ + --hash=sha256:f62bdabf278db9ff61df5f3203d608949f0d893d0e30cdac3f2330e67e41ae68 \ + --hash=sha256:f77af7721ff35a58fa8797decd14c932c350a2548686c6e9b844db710a3a2441 \ + --hash=sha256:f89d8e998d3c936ffbbd1c3686c96f0378f6558aecc5967a3035a857f2bab0ad \ + --hash=sha256:fb8cdf45361e259df86e19f8cd042ce2d6c7e6ad88fa631b78a4e3a83c2e572d \ + --hash=sha256:fcc5bad4300a751804ce41f0e10d77f85272668160708ce39ec579bca8984843 + # via ctrlrun (pyproject.toml) idna==3.19 \ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 @@ -961,6 +1044,10 @@ setuptools==84.0.0 \ --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \ --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 # via -r requirements/in/backend.in +sortedcontainers==2.4.0 \ + --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ + --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 + # via hypothesis tomli==2.4.1 ; python_full_version <= '3.11' \ --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ diff --git a/requirements/docs.txt b/requirements/docs.txt index e6126e8d..e4d2995c 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -570,6 +570,89 @@ httpx==0.28.1 \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad # via ctrlrun (pyproject.toml) +hypothesis==6.168.0 \ + --hash=sha256:046fe4bcfce2a2fa186ba9d96bbb62c25c2f6c2e4071f0783ed6b5cc481d0669 \ + --hash=sha256:076a2096c34448931c3cfeb2eb7a6b843a56ffdce5e4e3a025bfdf8f935666d9 \ + --hash=sha256:085c9aa246487c56a40ca89003d285cbffdbb5be4097ba6d0139f9c21003c04a \ + --hash=sha256:0ba3838c4a92e0b9730d1ed7e67e4950c152ad79d0a0c7594065262db84c55c4 \ + --hash=sha256:112b0900059bf9d7d6528ed729770629ab146e0d133c4143b9bd4a01dc002bcc \ + --hash=sha256:16864797de4b024e4c6cebd44598af932f870aad811341bc5bc24c738801ff76 \ + --hash=sha256:1894782fae5d9a7bb44e6dcf848ccb09ccb5babab48d8b5c31a0a7fc025b82a1 \ + --hash=sha256:1d1aa5b3484e329295d88488a5ba06243909e65c2ab616513c2d36721de4ed1d \ + --hash=sha256:1f4cd0ff11bd470a1a846296ed5fe55e84214194850370994fd1370fe73d3099 \ + --hash=sha256:2085ee74ac3ab6b70e2f7ffae9b4cb74c246da2f574b2de81a0818a8a30f659f \ + --hash=sha256:2264f15a1c80329e3ad48e39c44bd5c9429b7b04c9ee62cdd72f4b10aaac9f29 \ + --hash=sha256:24b52a2b1c8db6e1e516f9295c8e4ef7ef63303ff24fbbc5b35f4ff71dcd732c \ + --hash=sha256:283eda952bcb1987ccba1c8b634db0e8a960e1e92e2daa7003bc2392f19cea01 \ + --hash=sha256:2a380b521b5a76a9e8917d64adcf7f861a45a4360a34b1579af14c5df8eb0377 \ + --hash=sha256:2a838218ff1eab8d7b4bf66b96037fce0a802f61f2fa5fd4b784696cac365ce7 \ + --hash=sha256:348d9b93fd4129f67f9bab94f3d70709a9372bbe0e0d22731325ce85d5eb409f \ + --hash=sha256:34e3c8b66047ba92f8b8df5e427074058d92db58038f007da4bf9d14e934ad3c \ + --hash=sha256:35f1262831b5acc74ded15f629965daffcd657f6016ee04fc9605f6eb2b334c0 \ + --hash=sha256:3b3ce1cce70b25a37ed1a38a53ce7204785726c675c0f41a0f83c338a7e47b3d \ + --hash=sha256:3bc00fd8cda04b58e37a1163e8a65389b247b4f5ee547ae37d244a4960995517 \ + --hash=sha256:3f6dcf66270278d078bed01b401f47db4e26456cd909d8e23c6b9366a6c0b131 \ + --hash=sha256:3f7486bed33225d02f6aa78a4c4ba2b6f84992a82571cdda1bf08dce41d13507 \ + --hash=sha256:4085b61e25d3dcc6c9151d4115269870aee8cdb921611ee5c989b2786449be09 \ + --hash=sha256:45fcfa05f746e253350f55f216bcef59754f5f2b85745f1fc2bb8ba81dd517a9 \ + --hash=sha256:47b89491ff02e3ae9b302c440457938e87b47a45b9a1d98ff5575b6910d779e2 \ + --hash=sha256:489d5c060f49f495b64215cae627c71730cffd5ef59dc4d7f431932e6e6d2e67 \ + --hash=sha256:4d7d29dd63ad9fdc4aa1d65fa272449e14aaf6c6bb8451091818c2945533a43a \ + --hash=sha256:527452b43e79e6dfbf9cb69145a940547a3cd177c556698a3fc939ed2354c4b3 \ + --hash=sha256:53469a1a7c4861b12c9a8622f762d7d1fd7bcf171884e1018ed5a8f063a5c063 \ + --hash=sha256:5427a3c951080c18170486f775df6a82153882b819eca6b8e7ed77693634e5ab \ + --hash=sha256:5920d267f7d8cfd376672f2bde5905cdf284d47519582e41ce7c142d48ee46c4 \ + --hash=sha256:5b54769033b84477931d2072e7133a7555e0de5c53fd5ca3bbde960762d7d31b \ + --hash=sha256:5f099b1c8fc49ec2d9d7944e661addb97d7c38e818fb8d1f78073c43895a87f6 \ + --hash=sha256:6b750390dac4429da0cb70ab3fe758457f0cea3d9c843d48c59d0690d1189fda \ + --hash=sha256:6de30e559eb151de14a5f74bceb4d97792a9315ada2a1816b5da825cd7d28edc \ + --hash=sha256:6f0dd437ec01140676192422b61f2f833b3ce6a3213da9b7e196ad6b3777e795 \ + --hash=sha256:6ff259260015f9be3756dcd4bc11c08e007314dec6b43d9a89084c4f34f94475 \ + --hash=sha256:719b45b0512e3535a6a0077c2f7c6053b02ac0e72d60693f66f98790a33855b2 \ + --hash=sha256:72af51087b7b5ab21c49f0d502f803c20897678652835596bd2a8b169a39135e \ + --hash=sha256:73084b76e4a79cd0f7883ce80fc60c9f374ce7dcad8f520b39db40470ce1852f \ + --hash=sha256:732ae5d47482f99d8028cca096729625f05690a83f5e7ce31466e266155792f4 \ + --hash=sha256:754016594fe78cef91790e0922f60d183c52f531255fbfa30dac495b813e2128 \ + --hash=sha256:76d4d36ed2fd62de11382f1d608169c1ffa9a49d3b9351146d8ff87cb81a66f7 \ + --hash=sha256:7d55562bf8d41cfa18559c33f30cadf44ceac8e517509d7a022a9feace621f28 \ + --hash=sha256:8067e6b4b48e5cfdc849a1a20c9d4972b3f532b3e3edb5e2b5dfd106045a5236 \ + --hash=sha256:812a84c4cc7f7ae4fcb39a5647cc2698e6c18254f8423126425578f1dcdac782 \ + --hash=sha256:891b2d281ede45130e7fa0a22fd65336cc77ef2f780ec3792e8de6fc274a02c8 \ + --hash=sha256:8e4b2d434e0dd134f3d31ac1efc1825bf99730dfe70fec005ff66d7211836d79 \ + --hash=sha256:9018b20acdb061b2ef4b2fa7f558ca5db97ffea316e0a528bc003a24b2ac996e \ + --hash=sha256:91e3de666a6c4f7543000d1710e25055d63ef3032c98bd2ab338b3087bdaa780 \ + --hash=sha256:92cff497b92e2285ff6a94193fdee04aba483a4115d501c1f9a570bd103fcd20 \ + --hash=sha256:93413d1b0af50a7b165d66278c529174bf2fd1773c78027735dc0b50d1d3fd27 \ + --hash=sha256:990026952d5b2eca290c88f639ac639233f47e13dae338c6dfb6e4774bcab349 \ + --hash=sha256:9a2079cd09919956dd388f1a1f8ea5a79f2b2437650fbeda31d8661217ffefef \ + --hash=sha256:9a72ed7afa1f7e30488b8a5754fca0ad9755518bdb77d6f0b003cadf7437a5f9 \ + --hash=sha256:9ba679f183c67adcb6f4ad93694beafb6da99fe691757f4e57b04ae77e581ba8 \ + --hash=sha256:9d9a8574f80fc859313aee56167d202e8625c0eedd200971130f0839f06d1c93 \ + --hash=sha256:a0d28418c104d7268fdebcc09bc49f7b6569b5eb942430c6859f53ec8d4edf63 \ + --hash=sha256:a4956f41ab1ec6e6ef9262a35970e9f3e2caaaa1cdafe0d413156c6934dd99d8 \ + --hash=sha256:a74b0945acbbd552c7c2d0a99a3b5232962b8848c8eed1829451800a9bfcf00b \ + --hash=sha256:a9650c4882fdbdd8e90bdae602a8bfa8c6f09dc5d06afec5b9b23982e8f60a04 \ + --hash=sha256:b5449a64eb37d9a4aa6ac9cd2ab0fd1a24145adf421ef1536884f73f39824887 \ + --hash=sha256:bc935a5d5f86fd8f5af951b8fbe00307f6f7c596f82a9a27c17d974f6ab0a26c \ + --hash=sha256:bfef4d46dbf1704a7b8fa3a78778651a2cb18870ca0a70da19c381646822b149 \ + --hash=sha256:c3af200b322f710c76c2189866246cdcff2039165dd77edff1a7bf1157162fb0 \ + --hash=sha256:cb10aa59b0af45badca76911f5323f40d24fdbe00d01b7b67fef8648c99411b5 \ + --hash=sha256:cd0c1dcf308e919c8ae708054d0ad61921ae87634a9aea574a9851da584cebc1 \ + --hash=sha256:d0620fa320fa66649e6bfd71e94f3f86115fffebb7e3c6dcece19d1aaff8e07f \ + --hash=sha256:d0bdb77f976740b8cd5ec697327ea343d02d052b9916d213b5d4c65d823415cd \ + --hash=sha256:db2751c27bffc8491a96d72969649089d5400115e4b7c49bf7167ebbdcc84193 \ + --hash=sha256:deb02de608268928d779aa889b0a9d67794b1cc0c54a322cf19e386be8a46ca7 \ + --hash=sha256:e21e30b76b6d3adb87c550576132a3204f4c257ec43353f6c09b9d59bb762abc \ + --hash=sha256:e2df8afacf9261070795db36db4a394e3ccdbb663fd2d38c7a9fba0c836dcecc \ + --hash=sha256:e86820053afad84677f301c0b892a226be1df49790800a65668ae7cc8a1ac571 \ + --hash=sha256:ec0886fe0be9091669937989f9a662beca42ae14a4a6dab25491c2c63365f88d \ + --hash=sha256:ecf0ab13cef899efb816ffdd7963e0679f372520884ce06756c7642f3df94213 \ + --hash=sha256:f62bdabf278db9ff61df5f3203d608949f0d893d0e30cdac3f2330e67e41ae68 \ + --hash=sha256:f77af7721ff35a58fa8797decd14c932c350a2548686c6e9b844db710a3a2441 \ + --hash=sha256:f89d8e998d3c936ffbbd1c3686c96f0378f6558aecc5967a3035a857f2bab0ad \ + --hash=sha256:fb8cdf45361e259df86e19f8cd042ce2d6c7e6ad88fa631b78a4e3a83c2e572d \ + --hash=sha256:fcc5bad4300a751804ce41f0e10d77f85272668160708ce39ec579bca8984843 + # via ctrlrun (pyproject.toml) idna==3.19 \ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 @@ -978,6 +1061,10 @@ setuptools==84.0.0 \ --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \ --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 # via -r requirements/in/backend.in +sortedcontainers==2.4.0 \ + --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ + --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 + # via hypothesis tomli==2.4.1 ; python_full_version <= '3.11' \ --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \