From a6d365784dcca11595a817091f68471a34628ca9 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:58:42 +0100 Subject: [PATCH 1/5] Complete pyproject: SPDX license, classifiers, wads CI config, testpaths Bring the packaging metadata and the wads CI SSOT block up to the ecosystem standard. The repo already had a pyproject (no setup.cfg/setup.py to convert), so this is a completion pass rather than a conversion: - license: replace the deprecated `[project.license] text = ...` table with the PEP 639 SPDX string `license = "Apache-2.0"` + `license-files = ["LICENSE"]`. Verified: the built wheel now carries `License-Expression: Apache-2.0` and ships `dist-info/licenses/LICENSE`. - classifiers: added (development status, audience, OS, Python versions, topics). - testpaths: `["tests"]` -> `["enlace_metering", "tests"]`. wads CI runs `pytest --doctest-modules` with NO path argument, so collection is driven entirely by testpaths; with only `tests` the package's own doctests would run nowhere while CI still reported green. - `[tool.wads.ci]`: explicit `project_name` (it is the `ruff check` target and the `--cov` target), plus testing / build / publish / env / quality / docs sections so nothing falls through to a moving default. - `[tool.wads.ci.publish].enabled = false`: this name has never been published to PyPI. First publication of a new name is a deliberate act, not a side effect of merging a modernization PR. `[tool.wads.ci.install].extras = "dev"` was already correct and is kept: the dev extra carries fastmcp, which `--doctest-modules` needs to import enlace_metering.middleware in CI's clean environment. Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- pyproject.toml | 61 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b3ee05d..dc481f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,15 +8,26 @@ version = "0.0.1" description = "Usage tracking + credit/quota access-gating for enlace MCP connectors (strategy-pattern policies + a durable ledger)" readme = "README.md" requires-python = ">=3.10" +license = "Apache-2.0" +license-files = ["LICENSE"] keywords = ["enlace", "metering", "usage", "quota", "credits", "billing", "mcp", "rate-limit"] authors = [{ name = "Thor Whalen" }] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Monitoring", + "Topic :: Office/Business :: Financial :: Accounting", +] # The core (policy + ledger) is dependency-free — the ledger store is any # MutableMapping (DI'd). Only the FastMCP middleware needs a dep, hence the [mcp] extra. dependencies = [] -[project.license] -text = "Apache-2.0" - [project.urls] Homepage = "https://github.com/i2mint/enlace_metering" @@ -29,7 +40,11 @@ dev = ["pytest", "fastmcp>=2.0"] packages = ["enlace_metering"] [tool.pytest.ini_options] -testpaths = ["tests"] +# wads CI runs `pytest --doctest-modules` with NO path argument, so collection is +# driven entirely by `testpaths`. The package dir must be listed or the package's +# own doctests would run nowhere while CI still reported green. +testpaths = ["enlace_metering", "tests"] +doctest_optionflags = ["NORMALIZE_WHITESPACE", "ELLIPSIS"] [tool.ruff] line-length = 88 @@ -43,8 +58,46 @@ ignore = ["D203", "E501"] [tool.ruff.lint.per-file-ignores] "tests/*" = ["D"] +# -------------------------------------------------------------------------------- +# wads CI config — SSOT for the reusable uv CI workflow (.github/workflows/ci.yml is +# a 5-line stub calling i2mint/wads/.github/workflows/uv-ci.yml@master). +# -------------------------------------------------------------------------------- [tool.wads.ci] installer = "uv" +project_name = "enlace_metering" [tool.wads.ci.install] +# `dev` carries the test-time deps (pytest + fastmcp); fastmcp is needed because +# `--doctest-modules` imports enlace_metering.middleware, which imports fastmcp. extras = "dev" + +[tool.wads.ci.testing] +python_versions = ["3.10", "3.12"] +pytest_args = ["-v", "--tb=short"] +coverage_enabled = false +test_on_windows = true + +[tool.wads.ci.build] +sdist = true +wheel = true + +[tool.wads.ci.publish] +# NOT yet published to PyPI under this name. Publishing a brand-new name is a +# deliberate act, not a side effect of a merge — flip to true for the first real +# release. See the repo issue tracking this decision. +enabled = false + +[tool.wads.ci.env] +required_envvars = [] +test_envvars = [] +extra_envvars = [] + +[tool.wads.ci.env.defaults] + +[tool.wads.ci.quality.ruff] +enabled = true + +[tool.wads.ci.docs] +enabled = true +builder = "epythet" +ignore_paths = ["tests/"] From dcfa94356eedf6ef1a640cd58a55ac6ec694feb0 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:58:58 +0100 Subject: [PATCH 2/5] Add wads uv-CI stub (the repo had no CI at all) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing tested or built this repo. Adds `.github/workflows/ci.yml` as the 5-line stub calling the reusable workflow `i2mint/wads/.github/workflows/uv-ci.yml@master`, generated from the wads template with the secrets pass-through block rendered from `[tool.wads.ci.env]` (PYPI_PASSWORD only — no test secrets are needed here). All configuration lives in `[tool.wads.ci.*]` in pyproject.toml. Publishing is disabled there, so a merge to the default branch runs validation and docs only. Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b58254c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +# wads CI — calls the reusable workflow hosted in i2mint/wads. +# +# All configuration comes from this repo's pyproject.toml [tool.wads.ci.*]. +# To customize the workflow itself (rare), replace this file with the +# full inline template `wads/data/github_ci_uv.yml` from i2mint/wads. +# +# Pinning: `@master` floats with wads. If you need version stability for +# a release-sensitive repo, change `@master` to a wads tag (e.g. `@v0.1.81`). +# CI failure does not block a published release — it blocks the publish +# step itself — so floating master is generally safe. +# +# Permissions: GitHub validates that the caller grants AT LEAST the +# permissions any job in the called workflow requests — at workflow-parse +# time, not at run-time, even if the job would be skipped via `if:`. +# The reusable workflow needs: +# contents: write for the publish job's version-bump push-back +# and for the github-pages job's gh-pages branch push +# pages: write for the github-pages job's REST API Pages config +# Both default to `write` on org-account GITHUB_TOKEN and need to be +# granted explicitly on personal-account callers (where the default is +# read-only). No `id-token: write` needed — the publish-github-pages +# action uses peaceiris/actions-gh-pages (branch-based) + REST API, +# not the OIDC `actions/deploy-pages` flow. +name: Continuous Integration +on: [push, pull_request] +jobs: + ci: + uses: i2mint/wads/.github/workflows/uv-ci.yml@master + permissions: + contents: write + pages: write + # Explicit pass-through (not `secrets: inherit`) because `inherit` does + # not reliably propagate caller-repo secrets to a reusable workflow owned + # by a different account (verified empirically: personal-account caller + + # i2mint-org workflow → `${{ secrets.PYPI_PASSWORD }}` resolved to empty). + # + # This list is the per-repo *transport*: it should contain PYPI_PASSWORD + # (for publishing) plus every secret your tests/CI need. It is generated + # from [tool.wads.ci.env] in pyproject.toml. To add one, run + # wads-secrets add VAR_NAME # updates pyproject + this block + # or just append a line below. *Which* of these become job env vars (and + # which are required) is controlled by [tool.wads.ci.env] — passing a + # secret here does not by itself put it in the environment. + # + # A secret name must also be declared in the reusable workflow's superset + # (wads/ci_secrets.py). `wads-secrets add` warns if it is not. + secrets: + PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} From 9240b7f5f035682c90c7da1f65c234b39f9d5bc0 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:58:58 +0100 Subject: [PATCH 3/5] Add LICENSE (Apache-2.0) and .editorconfig The pyproject declared Apache-2.0 but no LICENSE file existed, so the built distributions carried no licence text. Adds the standard Apache-2.0 text used across the ecosystem, plus the wads-template .editorconfig. Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- .editorconfig | 17 +++++ LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 .editorconfig create mode 100644 LICENSE diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..88bf4d0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,toml,yml,yaml}] +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From 984bc5c1b102098ab5d486cf775db48cc24898ef Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:59:39 +0100 Subject: [PATCH 4/5] Apply `ruff format` to the Python sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure formatting, no behaviour change — brings the tree in line with what the wads CI's `ruff format` step produces (whitespace/line-wrapping only; the README's hand-aligned example block is deliberately left untouched). Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- enlace_metering/middleware.py | 7 +++++-- enlace_metering/policy.py | 13 +++++++++---- tests/test_ledger.py | 8 ++++++-- tests/test_middleware.py | 8 ++++++-- tests/test_policy.py | 16 ++++++++++------ 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/enlace_metering/middleware.py b/enlace_metering/middleware.py index 26242fc..bf3d5a0 100644 --- a/enlace_metering/middleware.py +++ b/enlace_metering/middleware.py @@ -196,8 +196,11 @@ async def _gate_and_meter(self, context, call_next, email: str): proposed = None decision = self.policy( GateRequest( - principal=email, ledger=self.ledger, now=now, - proposed_cost_usd=proposed, tool=tool, + principal=email, + ledger=self.ledger, + now=now, + proposed_cost_usd=proposed, + tool=tool, ) ) if not decision.allow: diff --git a/enlace_metering/policy.py b/enlace_metering/policy.py index 03ae800..1b56dad 100644 --- a/enlace_metering/policy.py +++ b/enlace_metering/policy.py @@ -187,8 +187,9 @@ def policy(request: GateRequest) -> Decision: proposed = request.proposed_cost_usd if proposed is not None and proposed > usd: return Decision( - False, f"this call's estimated ${proposed:.2f} exceeds the " - f"per-call cap of ${usd:.2f}" + False, + f"this call's estimated ${proposed:.2f} exceeds the " + f"per-call cap of ${usd:.2f}", ) return Decision(True, f"within the ${usd:.2f} per-call cap") @@ -211,7 +212,9 @@ def policy(request: GateRequest) -> Decision: False, f"{period}ly call cap of {max_calls} reached ({count} this {period})", ) - return Decision(True, f"{max_calls - count} of {max_calls} calls left this {period}") + return Decision( + True, f"{max_calls - count} of {max_calls} calls left this {period}" + ) return policy @@ -254,7 +257,9 @@ def per_principal( def policy(request: GateRequest) -> Decision: chosen = lowered.get(request.principal.lower(), default) if chosen is None: - return Decision(False, f"no usage policy configured for {request.principal!r}") + return Decision( + False, f"no usage policy configured for {request.principal!r}" + ) return chosen(request) return policy diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 827c9f1..5ff290c 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -25,7 +25,9 @@ def test_record_keys_by_principal_month_id_and_overwrites(): led = UsageLedger(store) key = led.record(_entry("U@X.com", "abc", cost=None, status="started")) assert key == "u@x.com/2026-08/abc.json" # lowercased principal - led.record(_entry("U@X.com", "abc", cost=1.5, status="done")) # same id -> overwrite + led.record( + _entry("U@X.com", "abc", cost=1.5, status="done") + ) # same id -> overwrite assert len(store) == 1 assert store[key]["cost_usd"] == 1.5 @@ -35,7 +37,9 @@ def test_spend_since_sums_costs_in_window(): led = UsageLedger(store) led.record(_entry("a@x.com", "1", cost=2.0, ts=NOW)) led.record(_entry("a@x.com", "2", cost=3.0, ts=NOW - timedelta(hours=1))) - led.record(_entry("a@x.com", "3", cost=5.0, ts=NOW - timedelta(days=40))) # older month + led.record( + _entry("a@x.com", "3", cost=5.0, ts=NOW - timedelta(days=40)) + ) # older month led.record(_entry("b@x.com", "4", cost=99.0, ts=NOW)) # other principal # this calendar month (from Aug 1): entries 1 + 2 = 5.0; entry 3 (June/July) excluded since_month = datetime(2026, 8, 1, tzinfo=timezone.utc) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 1d67f8a..3569593 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -170,8 +170,12 @@ def test_metered_tools_exempts_free_tools(monkeypatch): now = datetime.now(timezone.utc) led.record( { - "principal": "o@x.com", "id": "prev", "month": now.strftime("%Y-%m"), - "ts": now.isoformat(), "cost_usd": 5.0, "status": "done", + "principal": "o@x.com", + "id": "prev", + "month": now.strftime("%Y-%m"), + "ts": now.isoformat(), + "cost_usd": 5.0, + "status": "done", } ) # already over a $1 cap mw = MeteringMiddleware( diff --git a/tests/test_policy.py b/tests/test_policy.py index b63c906..363c716 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -88,13 +88,15 @@ def test_balance_gate(): def test_per_principal_routing_and_deny_by_default(): - pol = P.per_principal( - {"Owner@X.com": P.unlimited()}, default=P.credit_cap(1.0) - ) - assert pol(_req(principal="owner@x.com", spend=1e6)).allow is True # owner, case-insensitive + pol = P.per_principal({"Owner@X.com": P.unlimited()}, default=P.credit_cap(1.0)) + assert ( + pol(_req(principal="owner@x.com", spend=1e6)).allow is True + ) # owner, case-insensitive assert pol(_req(principal="other@x.com", spend=5.0)).allow is False # default cap no_default = P.per_principal({"owner@x.com": P.unlimited()}) - assert no_default(_req(principal="stranger@x.com")).allow is False # deny-by-default + assert ( + no_default(_req(principal="stranger@x.com")).allow is False + ) # deny-by-default def test_require_all_first_denial_wins(): @@ -108,7 +110,9 @@ def test_require_all_first_denial_wins(): def test_period_start_boundaries(): assert P._period_start(NOW, "month") == datetime(2026, 8, 1, tzinfo=timezone.utc) assert P._period_start(NOW, "day") == datetime(2026, 8, 15, tzinfo=timezone.utc) - assert P._period_start(NOW, "hour") == datetime(2026, 8, 15, 12, tzinfo=timezone.utc) + assert P._period_start(NOW, "hour") == datetime( + 2026, 8, 15, 12, tzinfo=timezone.utc + ) # 2026-08-15 is a Saturday -> week (Mon) start is 2026-08-10 assert P._period_start(NOW, "week") == datetime(2026, 8, 10, tzinfo=timezone.utc) with pytest.raises(ValueError): From e65e1d2ebdb7b8ec9c2f29b998eb5a5254fd88ec Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:04:58 +0100 Subject: [PATCH 5/5] Fix two latent ledger bugs (key traversal via tool name; naive-timestamp TypeError) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both surfaced while reviewing the package during the packaging/CI modernization pass. Both are silent in the existing suite. 1. Traversal via the entry id. The ledger key is `{principal}/{month}/{id}.json` and the module docstring claimed the key was traversal-checked — but only `principal` was. The middleware builds the id as `{ns}-{seq}-{tool}`, and `tool` is the *client-supplied* name from a `tools/call`, which reaches `on_call_tool` BEFORE the tool is resolved. So an authorized caller could name a nonexistent tool `../../../etc/pwn` and the write-ahead row would be written to `a@b.com/2026-08/-0-../../../etc/pwn.json` — outside the ledger prefix on any path-backed store (the documented production case is a dol file store). Fix, in two layers: - `ledger.safe_key_component` (extracted from `safe_principal`) now validates the `month` and `id` components too, so any producer writing to the ledger is checked, not just the middleware; - the middleware slugifies the tool component (`[^A-Za-z0-9._-]` -> `_`, capped at 64 chars) so a hostile *or* merely unknown name is still recorded rather than refused. The raw name stays in the entry's `tool` field, so the audit trail is unchanged. 2. Naive timestamps raise instead of being tolerated. `_entry_ts` parsed `ts` with `datetime.fromisoformat` and returned it as-is; a value written without a UTC offset yields a naive datetime, and `ts >= since` then raises `TypeError: can't compare offset-naive and offset-aware datetimes`. That propagates out of `spend_since`, through the gate policy, into the fail-closed middleware — so ONE badly-stamped row would deny every subsequent gated call for that principal. The module already treats an unparseable `ts` as "include" (conservative), so raising here was inconsistent as well as fragile. `_as_utc` now reads a naive datetime as UTC, applied to both the entry timestamp and the caller-supplied `since`. Regression tests added for both (traversal in `id`/`month`, the middleware slug, naive `ts`, naive `since`). Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- enlace_metering/__init__.py | 3 ++- enlace_metering/ledger.py | 50 +++++++++++++++++++++++++++-------- enlace_metering/middleware.py | 25 +++++++++++++++++- tests/test_ledger.py | 43 +++++++++++++++++++++++++++++- tests/test_middleware.py | 24 +++++++++++++++++ 5 files changed, 131 insertions(+), 14 deletions(-) diff --git a/enlace_metering/__init__.py b/enlace_metering/__init__.py index a187d17..5148772 100644 --- a/enlace_metering/__init__.py +++ b/enlace_metering/__init__.py @@ -19,7 +19,7 @@ from __future__ import annotations -from .ledger import UsageLedger, safe_principal +from .ledger import UsageLedger, safe_key_component, safe_principal from .policy import ( Decision, GatePolicy, @@ -77,6 +77,7 @@ def __dir__(): # ledger (the meter) "UsageLedger", "safe_principal", + "safe_key_component", # middleware (lazy, [mcp]) *_LAZY.keys(), ] diff --git a/enlace_metering/ledger.py b/enlace_metering/ledger.py index 66b0c62..e663c8b 100644 --- a/enlace_metering/ledger.py +++ b/enlace_metering/ledger.py @@ -8,17 +8,30 @@ policies gate on real accumulated usage. **Storage-agnostic** — any ``MutableMapping``; there is no ``dol`` dependency (the -store is dependency-injected). The principal key component is traversal-checked so a -hostile identity can't escape its own ledger prefix. +store is dependency-injected). *Every* key component is traversal-checked, so neither +a hostile identity nor a hostile operation name can escape the ledger prefix. """ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from typing import Iterator, MutableMapping, Optional +def safe_key_component(value: str, *, what: str = "key") -> str: + """A traversal-safe key component: reject empty, separators, ``.``/``..``, NUL. + + The ledger key is ``{principal}/{month}/{id}.json``; every component is + caller-influenced (the ``id`` embeds the tool name), so every component is + checked. ``what`` names the component in the error message. + """ + v = (value or "").strip() + if not v or "/" in v or "\\" in v or v in (".", "..") or "\x00" in v: + raise ValueError(f"unsafe {what} key: {value!r}") + return v + + def safe_principal(principal: str) -> str: """A traversal-safe, lowercased key component for a principal (email / ``sub``). @@ -26,23 +39,34 @@ def safe_principal(principal: str) -> str: escape its ledger prefix. Identity is case-insensitive (enlace_auth mints ``sub = email``), so this lowercases too. """ - p = (principal or "").strip().lower() - if not p or "/" in p or "\\" in p or p in (".", "..") or "\x00" in p: - raise ValueError(f"unsafe principal key: {principal!r}") - return p + return safe_key_component((principal or "").lower(), what="principal") + + +def _as_utc(dt: datetime) -> datetime: + """Make ``dt`` timezone-aware, treating a naive value as UTC. + + The ledger's timestamps are UTC by construction (the middleware writes + ``datetime.now(timezone.utc).isoformat()``), but an entry written by another + producer — or a ``since`` passed by a host — may be naive. Normalizing here keeps + the queries total: comparing a naive to an aware datetime raises ``TypeError``, + which in a fail-closed middleware would turn one badly-stamped ledger row into a + hard failure of every subsequent gated call. + """ + return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc) def _entry_ts(entry: dict) -> Optional[datetime]: """Parse an entry's ISO ``ts`` to an aware datetime, or ``None`` if absent/bad. ``None`` is treated as "include" by the queries (conservative — a call with an - unparseable timestamp still counts toward usage). + unparseable timestamp still counts toward usage). A parsed-but-naive timestamp is + read as UTC (see :func:`_as_utc`). """ raw = entry.get("ts") if not isinstance(raw, str): return None try: - return datetime.fromisoformat(raw) + return _as_utc(datetime.fromisoformat(raw)) except ValueError: return None @@ -63,7 +87,9 @@ class UsageLedger: def record(self, entry: dict) -> str: principal = safe_principal(entry["principal"]) - key = f"{principal}/{entry['month']}/{entry['id']}.json" + month = safe_key_component(str(entry["month"]), what="month") + entry_id = safe_key_component(str(entry["id"]), what="id") + key = f"{principal}/{month}/{entry_id}.json" self.store[key] = entry return key @@ -84,6 +110,7 @@ def _entries_for( def spend_since(self, principal: str, since: datetime) -> float: """Total ``cost_usd`` across the principal's entries at/after ``since``.""" + since = _as_utc(since) since_month = since.strftime("%Y-%m") total = 0.0 for entry in self._entries_for(principal, since_month=since_month): @@ -96,6 +123,7 @@ def spend_since(self, principal: str, since: datetime) -> float: def count_since(self, principal: str, since: datetime) -> int: """Number of the principal's entries at/after ``since``.""" + since = _as_utc(since) since_month = since.strftime("%Y-%m") n = 0 for entry in self._entries_for(principal, since_month=since_month): @@ -105,4 +133,4 @@ def count_since(self, principal: str, since: datetime) -> int: return n -__all__ = ["UsageLedger", "safe_principal"] +__all__ = ["UsageLedger", "safe_principal", "safe_key_component"] diff --git a/enlace_metering/middleware.py b/enlace_metering/middleware.py index bf3d5a0..6ab49c5 100644 --- a/enlace_metering/middleware.py +++ b/enlace_metering/middleware.py @@ -24,6 +24,7 @@ import itertools import logging +import re import time from contextvars import ContextVar from datetime import datetime, timezone @@ -106,6 +107,28 @@ def _default_extract_cost(result: Any) -> Optional[float]: return None +#: Characters allowed in the tool slug embedded in a ledger entry id. The tool name in +#: a ``tools/call`` is client-supplied and reaches the middleware *before* the tool is +#: resolved, so it must never flow verbatim into a storage key. +_TOOL_SLUG_RE = re.compile(r"[^A-Za-z0-9._-]") + +#: Cap on the tool slug so a pathological name can't produce an unwritable key. +_TOOL_SLUG_MAX = 64 + + +def _tool_slug(tool: Optional[str]) -> str: + """A key-safe slug for a client-supplied tool name (the raw name is still recorded). + + The ledger entry id is ``{ns}-{seq}-{slug}`` and the ledger key embeds that id, so + an unresolved/hostile name like ``../../etc/x`` must not reach the store. Anything + outside ``[A-Za-z0-9._-]`` becomes ``_``; the raw name stays in the entry's + ``tool`` field, so the audit trail is unchanged. + """ + return _TOOL_SLUG_RE.sub("_", str(tool) if tool is not None else "unknown")[ + :_TOOL_SLUG_MAX + ] + + class MeteringMiddleware(Middleware): """FastMCP middleware: authorize the caller, gate on usage policy, meter the call. @@ -208,7 +231,7 @@ async def _gate_and_meter(self, context, call_next, email: str): t0 = time.time_ns() entry = { - "id": f"{t0}-{next(self._seq)}-{tool}", + "id": f"{t0}-{next(self._seq)}-{_tool_slug(tool)}", "principal": email, "month": now.strftime("%Y-%m"), "ts": now.isoformat(), diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 5ff290c..4276b05 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -4,7 +4,7 @@ import pytest -from enlace_metering.ledger import UsageLedger, safe_principal +from enlace_metering.ledger import UsageLedger, safe_key_component, safe_principal NOW = datetime(2026, 8, 15, 12, 0, 0, tzinfo=timezone.utc) @@ -69,3 +69,44 @@ def test_safe_principal_rejects_traversal(): with pytest.raises(ValueError): safe_principal(bad) assert safe_principal("Owner@X.com") == "owner@x.com" + + +def test_record_rejects_traversal_in_id_and_month(): + # The entry id embeds the (client-supplied) tool name, so it is as + # caller-influenced as the principal and must be traversal-checked too. + store = {} + led = UsageLedger(store) + with pytest.raises(ValueError): + led.record(_entry("a@x.com", "1-0-../../../etc/pwn")) + with pytest.raises(ValueError): + led.record(_entry("a@x.com", "ok", month="../..")) + assert store == {} + + +def test_safe_key_component_preserves_case(): + # Unlike safe_principal, a generic component is not lowercased (ids are opaque). + assert safe_key_component(" AbC ", what="id") == "AbC" + with pytest.raises(ValueError): + safe_key_component("a/b", what="id") + + +def test_naive_entry_timestamp_is_read_as_utc(): + # A ts written without an offset must not blow up the aware/naive comparison + # (that would turn one bad row into a hard failure of every gated call). + store = {} + led = UsageLedger(store) + entry = _entry("a@x.com", "naive", cost=1.5) + entry["ts"] = NOW.replace(tzinfo=None).isoformat() # "2026-08-15T12:00:00" + led.record(entry) + assert led.spend_since("a@x.com", NOW - timedelta(hours=1)) == pytest.approx(1.5) + assert led.count_since("a@x.com", NOW - timedelta(hours=1)) == 1 + assert led.spend_since("a@x.com", NOW + timedelta(hours=1)) == 0.0 + + +def test_naive_since_is_read_as_utc(): + store = {} + led = UsageLedger(store) + led.record(_entry("a@x.com", "1", cost=2.0)) + naive_since = (NOW - timedelta(hours=1)).replace(tzinfo=None) + assert led.spend_since("a@x.com", naive_since) == pytest.approx(2.0) + assert led.count_since("a@x.com", naive_since) == 1 diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 3569593..342fb25 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -192,6 +192,30 @@ async def _next(ctx): assert isinstance(r, _Result) +def test_hostile_tool_name_cannot_escape_the_ledger_prefix(monkeypatch): + # `tools/call` carries a client-supplied name and the middleware runs BEFORE the + # tool is resolved, so the name reaches the write-ahead row even when no such tool + # exists. It must not flow verbatim into the storage key (a dol file store would + # write outside the ledger tree). + store = {} + mw = MeteringMiddleware(UsageLedger(store), policy=unlimited(), allowed={"o@x.com"}) + monkeypatch.setattr(mw_mod, "token_email", lambda: "o@x.com") + + async def _boom(ctx): + raise RuntimeError("no such tool") + + with pytest.raises(RuntimeError): + _run(mw.on_call_tool(_Ctx("../../../etc/pwn"), _boom)) + (key,) = store + principal, month, entry_id = key.split("/") # exactly three components + assert principal == "o@x.com" + assert entry_id.endswith(".json") + assert "\\" not in key and ".." not in key.split("/") # no traversal segment + # the raw name is still recorded, so the audit trail is unchanged + (entry,) = store.values() + assert entry["tool"] == "../../../etc/pwn" + + def test_current_email_requires_context(): with pytest.raises(ToolError): mw_mod.current_email()