Skip to content

Commit 4b0409f

Browse files
committed
Merge v1.9.1-binding-collision: binding completeness, red pass closed
2 parents 80c85a6 + a61e489 commit 4b0409f

13 files changed

Lines changed: 2152 additions & 64 deletions

‎CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.9.1] - 2026-09-10
11+
12+
A patch release closing four binding gaps, all found by the same external review that found the three 1.9.0 gaps: one in the variadic keyword flattening, and three more found in a red pass against the 1.9.1 build before it was released. No new feature, no new denial reason and no schema change. The version did not move between the two rounds, because the first was never released.
13+
14+
Suite: **1515 passed, 14 skipped on CPython 3.14.6 with no optional extras**, **1520 passed, 9 skipped on CPython 3.14.6 with `mcp 2.2.0`**, **1519 passed, 10 skipped on CPython 3.13.14 with `mcp 1.30.0`**, and **1521 passed, 8 skipped on CPython 3.13.14 with `mcp 2.2.0` and `pyautogen 0.9.0`**. 0 failed in every environment. `mypy agentlock/ --ignore-missing-imports` reports 0 errors.
15+
16+
### Security
17+
18+
- **A `**kwargs` key that names another parameter is refused instead of flattened over it.** `bind_call_parameters` flattened a `VAR_KEYWORD` mapping onto the top-level parameters dict with `parameters.update(value)`. A key in that mapping equal to the name of another bound parameter overwrote the bound value, and the gate was then shown a value the function does not receive. Two shapes carried it. With `def send(to, /, **extras)`, the call `send("attacker@evil.test", to="bob@company.com")` bound `to` to the attacker positionally, put `to="bob@company.com"` in `extras` because `to` is positional only, and flattened the contact over the attacker: the gate authorized a known contact and the function ran with the attacker. With `def f(*args, **kw)`, the call `f(1, 2, args="spoof")` showed the gate `args="spoof"` while the function received `args=(1, 2)`. The sync wrapper, the async wrapper and the AutoGen `protect_functions` map all reached the same binding and all three carried it. Before flattening, the binding now computes the set of names of every other parameter in the signature and raises `BindingError` if any key of the mapping is in it, naming the colliding key and the function. Credited to the same external review.
19+
- **This is a call-time refusal and it reaches the caller.** The error is raised from inside the binding, before `authorize()` is called, so the call is never authorized and the function never runs. No wrapper catches it: it propagates like `TokenInvalidError` does. A deployment calling a function whose signature genuinely has both a parameter and a `**kwargs` key of the same name will see `BindingError` where it previously saw execution.
20+
- **A key equal to the `**kwargs` parameter's own name is not a collision and stays allowed.** With `def g(**kw)`, the call `g(kw="spoof")` has no parameter named `kw` for the key to shadow: `kw` is the variadic itself, and the gate and the function see the same mapping. Nothing is hidden, so nothing is refused, and this call binds exactly as it did at 1.9.0.
21+
- **A `functools.partial` is bound through to the function underneath.** `inspect.signature` of a partial describes the call still to be made, not the function that will run, so the arguments the partial already carries are absent from it. With `def send(to, /, **extras)`, `partial(send, "attacker@evil.test")` called with `to="bob@company.com"` showed the gate the contact, which had landed in `extras`, and ran the function with the attacker the partial supplied. The binding now collapses a chain of partials into the call it stands for before binding: the callable becomes `partial.func`, the positional arguments become `partial.args` in front of the call's own, and the keywords become the partial's underneath the call's, repeatedly for nested partials. The collision rule above then applies to the partial's own arguments too, which is what refuses the shape just described. `partialmethod` is collapsed the same way. Wrappers invoke the callable underneath, because that is what the binding is taken against.
22+
- **A recipient is read for the characters it holds, not for what its methods say.** `_normalize_recipient` called `strip` and `casefold` on the value it was given. A `str` subclass satisfies every `isinstance` check the gate makes while overriding those two to name a known contact and `__str__` to name somewhere else, so the gate authorized the contact and the application, sending to `str(value)`, delivered to the attacker. Every recipient string is now coerced with `str.__str__`, which is the slot rather than the instance's override, before it is stripped, casefolded or compared, and before it is placed in the request context. Subclasses are not rejected: a subclass whose data is a known contact is allowed even if its methods claim otherwise, which is the same rule read in the other direction.
23+
- **A declared recipient parameter that can never be observed is refused at wrap time.** A block declaring `scope.recipient_parameter="to"` over `def send(*args)` was accepted. That signature has no `to` and no `**kwargs` for one to arrive in, so pipeline step 8 looked up a key that could never be present, found nothing, skipped, and the declared recipient policy decided nothing for every call. The wrapper now raises `BindingError` at decoration when the declared name is neither a parameter of the signature nor able to arrive through `**kwargs`, before the tool is registered. `def send(*args, **kw)` still wraps: `**kw` can carry it.
24+
- **A call that hides its recipient in `*args` is refused at call time.** `def send(*args, **kw)` can carry a `to`, so the pair is allowed to be built, but the call `send("attacker@evil.test")` binds `args=("attacker@evil.test",)` and no `to` at all, and step 8 skips again. When a recipient parameter is declared, the bound parameters carry no argument of that name, and positional arguments did arrive in `*args`, the binding now raises `BindingError`. The gate does not guess which unnamed positional was meant to be the recipient. A call with no positional extras is not this case: the declared parameter is simply absent, and the existing skip is unchanged.
25+
- **Threat model.** The gate binds to the callable's signature as `inspect` reports it, following `__wrapped__`. A wrapper that advertises one signature and alters the arguments before calling the inner function is the application's own code, on the trusted side of the boundary, and is outside what the gate can check. What the gate guarantees is that the call it authorizes is the call it binds, and that the wrappers invoke the function from that same binding.
26+
27+
### Changed
28+
29+
- **`BindingError`'s documentation now states all four reasons it is raised and where each is raised from.** Through 1.9.0 the class docstring read "Raised at wrap time, never at call time", which the collision rule makes false. This release adds two more reasons, one at each moment: an unobservable declared recipient parameter at wrap time, and a recipient hidden in `*args` at call time. The unreadable-signature case is still a wrap-time refusal and is unchanged.
30+
- **`AgentLockMCPServer` is unchanged, and the standalone adapters carry none of these rules.** The MCP integration does not bind call arguments to a Python signature; it passes the SDK's `arguments` mapping, which has names already. The five standalone adapters ship from their own repositories and are updated separately: none of them binds through partials, coerces recipient strings, or checks that a declared recipient parameter is observable.
31+
1032
## [1.9.0] - 2026-09-09
1133

1234
The enforcement completeness release. An external review of the published 1.8.0 wheel found three places where the engine did not enforce what its own documentation said it enforced. All three are fixed. This release adds no detection feature, no new denial reason and no schema change.

‎CITATION.cff‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ authors:
77
- family-names: Grice
88
given-names: David
99
orcid: "https://orcid.org/0009-0005-5388-123X"
10-
version: 1.9.0
10+
version: 1.9.1
1111
doi: 10.5281/zenodo.22681594
1212
identifiers:
1313
- type: doi
1414
value: 10.5281/zenodo.22681594
1515
description: "Concept DOI, resolves to the latest version"
16-
date-released: 2026-09-09
16+
date-released: 2026-09-10
1717
license: AGPL-3.0
1818
repository-code: "https://github.com/webpro255/agentlock"
1919
url: "https://agentlock.dev"

‎README.md‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ changelog. That is how we intend to keep working.
314314

315315
| version | highlights | tests |
316316
|---------|-----------|-------|
317+
| 1.9.1 | binding completeness: a `**kwargs` key that names another parameter is refused rather than flattened over it; partials bound through to the function underneath; recipients read for the characters they hold; an unobservable declared recipient parameter refused | 1520 with the `crypto` and `mcp` extras, 9 skipped |
317318
| 1.9.0 | enforcement completeness: all call arguments reach the gate, tokens bind the empty call, MCP wrapper fails closed and supports both SDK majors | 1503 with the `crypto` and `mcp` extras, 9 skipped |
318319
| 1.8.0 | recipient policy enforcement at pipeline Step 8; declared recipient parameter read from the trusted permission block; recipient sets | 1495 with the `crypto` and `mcp` extras, 8 skipped |
319320
| 1.7.0 | cross-hop provenance linking; parent attribution at ingestion by whole-content carriage; taint-reachability walk at decision time | 1418 with optional extras, 7 skipped |
@@ -336,22 +337,29 @@ only on Python below 3.14; a bare install runs 1479 passed and 24
336337
skipped. For 1.9.0 it is 1503 passing and 9 skipped under `mcp 2.x`, the
337338
9 being those 8 plus the mcp 1.x test, which selects on the installed
338339
SDK major; under `mcp 1.x` it is 1502 passing and 10 skipped, the two 2.x
339-
tests taking the place of the 1.x one. Nothing fails in any of these
340-
environments.
341-
342-
The 1.9.0 argument binding covers the engine's own decorators and in-repo
343-
integrations, which is the whole of what that release changes. The standalone
344-
adapters ship from their own repositories and are updated separately. At their
345-
current releases, `crewai-agentlock` 0.2.0 and `langchain-agentlock` 0.1.0
346-
authorize keyword arguments only, and of those two only `crewai-agentlock`
347-
carries positional arguments past the gate into the wrapped call;
348-
`mcp-agentlock` 0.2.1, `openai-agentlock` 0.1.0 and `openclaw-agentlock` 0.1.0
349-
hand the gate the same argument mapping they hand the tool and have no
350-
positional route. No standalone adapter applies the wrapped function's
351-
defaults, so a parameter the caller omits and the function defaults is not seen
352-
by the gate in any of them. The same release makes an execution token bind the
353-
empty call: the parameters passed to `execute()` must be the parameters passed
354-
to `authorize()`, and `None` and `{}` are the same call.
340+
tests taking the place of the 1.x one. For 1.9.1 it is 1520 passing and 9
341+
skipped under `mcp 2.x` and 1519 passing and 10 skipped under `mcp 1.x`, the
342+
seventeen added tests being the binding collision class and the binding red
343+
pass class, none of which is guarded by an extra; a bare install runs 1515
344+
passed and 14 skipped. Nothing fails in any of these environments.
345+
346+
The 1.9.0 argument binding, and the 1.9.1 binding rules that refuse a
347+
`**kwargs` key naming another parameter, bind through a `functools.partial` to
348+
the function underneath, read a recipient for the characters it holds rather
349+
than for what its methods say, and refuse a declared recipient parameter the
350+
signature can never carry, cover the engine's own decorators and in-repo
351+
integrations, which is the whole of what those two releases change. The standalone adapters ship from their own
352+
repositories and are updated separately. At their current releases,
353+
`crewai-agentlock` 0.2.0 and `langchain-agentlock` 0.1.0 authorize keyword
354+
arguments only, and of those two only `crewai-agentlock` carries positional
355+
arguments past the gate into the wrapped call; `mcp-agentlock` 0.2.1,
356+
`openai-agentlock` 0.1.0 and `openclaw-agentlock` 0.1.0 hand the gate the same
357+
argument mapping they hand the tool and have no positional route. No standalone
358+
adapter applies the wrapped function's defaults, so a parameter the caller
359+
omits and the function defaults is not seen by the gate in any of them, and
360+
none of them carries any of the 1.9.1 binding rules. 1.9.0 also makes an execution
361+
token bind the empty call: the parameters passed to `execute()` must be the
362+
parameters passed to `authorize()`, and `None` and `{}` are the same call.
355363

356364
Full feature history:
357365
[v1.1](docs/history.md#v11-memory--context-permissions),

‎agentlock/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def send_email(to: str, subject: str, body: str) -> str:
3434
SPDX-License-Identifier: AGPL-3.0-or-later
3535
"""
3636

37-
__version__ = "1.9.0"
37+
__version__ = "1.9.1"
3838

3939
from agentlock.action_class_audit import (
4040
ActionClassAudit,

0 commit comments

Comments
 (0)