Skip to content

Commit 2d911c6

Browse files
committed
Merge v1.10.2-jwt-and-audit: verified JWT identity, shared denial predicate
2 parents 3d95406 + 48f13fa commit 2d911c6

12 files changed

Lines changed: 3746 additions & 89 deletions

‎CHANGELOG.md‎

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

88
## [Unreleased]
99

10+
## [1.10.2] - 2026-09-10
11+
12+
Three findings, one from each direction. The same external reviewer ran a second recheck, this time against 1.10.1: all 65 prior cases pass, so 1.10.1 held, and of the 31 cases they added 30 pass and 1 fails. Credit for that one goes to the external reviewer's 1.10.1 recheck. A pre-release check against the published 1.10.1 wheel then found the second, which the report filed as a documented limitation and which is not one: it is an authorization bypass reachable by any party that can set a single request header. Credit for it goes to a pre-release check against the published 1.10.1 wheel. The third came from this release itself: the session that prepared 1.10.2 measured a behavior of its own JWT fix on both adapters, reasoned about why it was deliberate, and wrote it down as a documented limit rather than recognising it as a gap, and rereading that reasoning is what turned it into a finding. Their combined 96 cases, `tests/test_v110_system_review.py` at 65 and `tests/test_v1101_system_followup.py` at 31, are the definition of done and both are held verbatim.
13+
14+
The three are the same shape of mistake in different places: a check that was written against one spelling of the thing it checks. The first reads a denial and knows only one of the two strings this codebase writes for a denial. The second reads an identity and never asks whether it was signed. The third asks, but only of the requests that offered it something to ask about. `pytest` is 1746 passing and 9 skipped, 0 failed, on CPython 3.14.6 with the `crypto` and `mcp` extras at `mcp 2.2.0` plus `fastapi` 0.141.1, `flask` 3.1.3 and `python-jose` 3.5.0; on the same interpreter without `mcp` and without `python-jose`, and with `fastapi`, `flask` and PyNaCl still present, it is 1685 passing and 70 skipped, 0 failed, the 25 new skips being 9 oracle cases guarded on `mcp` and 16 engine cases guarded on `python-jose`. `mypy agentlock/ --ignore-missing-imports` reports 0 errors and `ruff check .` is clean.
15+
16+
### Not additive
17+
18+
1.10.1 and earlier accepted UNVERIFIED bearer token claims as identity, in BOTH HTTP adapters. Each base64 decoded the middle segment of an `Authorization: Bearer` value and returned the payload as claims without checking a signature, and 1.10.0's E5 then made those claims outrank the `X-AgentLock-User-Id` and `X-AgentLock-Role` headers. As of 1.10.2 a bearer token carries NO identity at all unless `jwt_key` is configured, and when it is, a token that fails verification is refused with 401 rather than falling back to the headers.
19+
20+
A deployment that was relying on the old behavior has exactly two options and no third one. **Configure `jwt_key`**, with the key and algorithms its issuer actually signs with, and the token becomes the identity input in the strong sense: verified, expiry enforced where the token carries an `exp`, and the only identity there is. Choosing this option means every request to a gated route has to carry a verified bearer token, because the `X-AgentLock-User-Id` and `X-AgentLock-Role` headers stop being read at all while a key is configured; a request that presents no token is refused with 401 rather than identified by header. Or **leave `jwt_key` unset**, which is the default, and accept that the `X-AgentLock-*` headers are the identity input: they are trusted-upstream inputs, they prove nothing on their own, and a deployment exposed directly to untrusted clients has to strip client-supplied `X-AgentLock-*` at its edge. This applies to a deployment that was relying on an upstream proxy to verify the token just as much as to one that was relying on nothing at all. If the proxy really does verify, then either it forwards a verified identity in the headers and the second option is the correct one, or `jwt_key` is configured here as well and the token is checked twice, which costs a signature verification and is the only arrangement in which this engine knows the token was checked. What is no longer available is the arrangement 1.10.0 and 1.10.1 shipped, in which the engine treated the token as proof because a proxy was assumed to have looked at it. That is the fix, not a side effect of it.
21+
22+
### Security
23+
24+
- **A bearer token is identity only once it has been verified (`fastapi`, `flask`).** Both adapters base64 decoded the middle segment of an `Authorization: Bearer` token and returned it as claims with no signature check. Both said so in their own docstrings, and while that was the bottom of the precedence order it was a weakness of a fallback. 1.10.0's E5 moved it to the TOP: a payload naming a subject became authoritative and the `X-AgentLock-User-Id` and `X-AgentLock-Role` headers were then ignored entirely. Read against an unverified decode that rule inverts, because the fastest way to become anyone is to put them in a token. Measured against the published 1.10.1 wheel: a token with `"alg": "none"`, a payload of `{"sub": "alice", "role": "admin"}` and fifteen bytes that are not a signature, sent to an admin route alongside headers naming an unprivileged user, turned a 403 with the handler not running into a 200 with the handler executed, on both adapters. Verification is now **opt in**, and the two states are exhaustive. With `jwt_key` set, the token is verified with expiry enforced; `"none"` is refused as an algorithm however `jwt_algorithms` is written, and a list naming nothing else falls back to `HS256` rather than to the library's default; a token that fails verification is **401** with reason `jwt_invalid` and the identity headers are **not** consulted as a fallback, because falling back on a bad token hands a forger the identity the token existed to gate. With `jwt_key` unset, which is the default, a bearer token is ignored for identity **entirely** and the identity headers apply exactly as they did before 1.10.0. `AgentLockMiddleware`, `require_agentlock`, `agentlock_required` and `AgentLockFlask` all take `jwt_key` and `jwt_algorithms`. The unverified decode helpers are removed rather than deprecated: a function whose only behavior is to return unverified claims has no correct caller. `python-jose` is imported lazily and only on the verification path, and if `jwt_key` is configured while it is not installed, construction raises `IntegrationUnsupportedError` rather than the first request discovering it, because an adapter that learns under load that it cannot check a signature is an adapter that fails open. It was already a declared dependency of the `fastapi` extra and imported by nothing; the `flask` extra now declares it too. The identity headers remain **trusted-upstream inputs** in both states and the docstrings now say so in those words: they carry no proof of anything, and a deployment exposed directly to untrusted clients has to strip client-supplied `X-AgentLock-*` at its edge or authenticate by verified token instead. Credit: a pre-release check against the published 1.10.1 wheel.
25+
26+
- **A configured `jwt_key` makes the verified token the only identity (`fastapi`, `flask`).** The fix above closed the unverified read, and then enforced it only on clients that presented a token. `_verify_jwt_claims` returned `None` for any `Authorization` value it did not recognise as a bearer credential, `None` was documented as meaning the request carries no token, and both call sites read the identity headers when they got it. So under a configured key there were three request classes and not two: a recognised token that verified was identity, a recognised token that did not was 401, and everything else was identified by `X-AgentLock-User-Id` and `X-AgentLock-Role`. A client that simply omitted the `Authorization` header was trusted on its own headers by an integration explicitly configured to verify. Measured on both adapters with `jwt_key` set: no `Authorization` header at all, alongside headers naming an admin, ran the admin handler with 200. So did a `Basic` scheme. So did a forged `"alg": "none"` token under a lowercase `bearer` scheme, because a scheme this did not match was not a bad token but no token. As of this release the two classes are exhaustive. A request carrying no bearer credential, whether the header is absent, empty, or carries another scheme, is **401** with reason `jwt_required`; a bearer credential that fails verification stays **401** with reason `jwt_invalid`; and the identity headers are **never** consulted while `jwt_key` is set, including when a verified token carries no `sub` claim, which now yields an empty user id and an ordinary gate denial rather than a header read. The scheme is matched case insensitively, as RFC 7235 defines it, so `bearer` is verified rather than ignored: matching it exactly did not reject such a request, it failed to see the token, which threw away genuine tokens and routed forged ones onto the header path. `require_agentlock` raises `ValueError` when it is built with both a `jwt_key` and `use_jwt=False`, because those two arguments say verify tokens with this key and do not read tokens, and the only way to honor both is to identify a request by header under a configured key. Found by this release's own session, which had measured the scheme spelling half of it and filed it as a documented limit.
27+
28+
- **An execution reported after a TIMEOUT denial is flagged as one.** `DeferralManager` writes two strings for a denial: `check_timeouts` takes a `timeout_action` defaulting to `"deny"` and writes it verbatim, and `resolve_commit_queue` writes `"deny"` on the expiry branch it enforces itself and `"denied"` on the branch where its per-record predicate says so. `confirm_execution` compared against `"denied"` alone. So a host reporting that it executed an action the gate had DENIED by timeout did not get `execution_after_denial`; it fell through to the ordinary completion path and was logged as a routine finished call, with the true resolution surviving only as a metadata field no filter looking for the serious action would read. The comment directly above that branch states the intent it failed to carry out: an action denied at commit and reported as executed "is the single most serious thing this log can carry, and it gets its own action so that no filter can mistake it for a routine execution." A denial through policy took the other branch and was classified correctly, which is why every test that denied through policy passed while the default timeout path was silently misfiled. `agentlock.is_denial_resolution(value)` is now the single predicate, true for both spellings, compared casefolded and stripped, and every comparison of a deferral resolution against a denial string goes through it. Neither assignment site changed and neither string is deprecated, so a caller reading `record.resolution` sees what it always saw. The audit metadata now carries the record's OWN string rather than a hardcoded `"denied"`, so the log still says which branch denied. Credit: the external reviewer's 1.10.1 recheck.
29+
30+
### Limits
31+
32+
- **A verified token that carries no `exp` claim is accepted, and it never expires.** Both adapters verify with `verify_exp` on and set no `require`, so an expiry is enforced when the token carries one and there is nothing to enforce when it does not. Measured on both adapters: a token signed with the configured key whose `exp` is sixty seconds in the past is 401, and the same token with no `exp` claim at all is accepted and the handler runs. Token lifetime is the issuer's decision and this engine does not second-guess it, because a verifier that invented a maximum age would reject tokens its own issuer had deliberately minted without one. A deployment that needs every token to expire has to make its issuer say so; nothing on this side will notice a token that does not.
33+
1034
## [1.10.1] - 2026-09-10
1135

1236
The recheck release. The same external reviewer re-ran their oracle against the published 1.10.0 wheel. All 33 of the original cases pass, so the integration hardening held. They added 32 cases, of which 7 fail, and the 7 reduce to three findings, all of them in code 1.10.0 itself wrote or last touched. Their combined 65-case file replaces `tests/test_v110_system_review.py` and is again the definition of done. Credit for all three findings goes to the external reviewer's 1.10.0 recheck. A pre-release red pass against the built wheel then found a fourth, in the recipient restriction the third fix had just rewritten, and it is fixed here too rather than deferred, along with two MCP payloads the output walker does not descend into, which are stated as limits rather than changed.

‎CITATION.cff‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ authors:
77
- family-names: Grice
88
given-names: David
99
orcid: "https://orcid.org/0009-0005-5388-123X"
10-
version: 1.10.1
10+
version: 1.10.2
1111
doi: 10.5281/zenodo.22681594
1212
identifiers:
1313
- type: doi

‎README.md‎

Lines changed: 39 additions & 0 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.10.2 | verified identity and one denial predicate: a bearer token carries identity only once it has been verified against a configured key, a token that fails verification is refused rather than falling back to the identity headers, and a configured key refuses a request that presents no token at all rather than identifying it by header; an execution reported after a TIMEOUT denial is classified as one rather than as an ordinary completion | 1746 with the `crypto` and `mcp` extras plus `fastapi`, `flask` and `python-jose`, 9 skipped |
317318
| 1.10.1 | recheck: the reviewer re-ran the oracle against the published 1.10.0 wheel and added 32 cases; resolved path containment resolves before it normalizes and returns the path it checked; one MCP payload walker serving both the declared transformation and the data policy, covering embedded resources; recipient restriction parses the whole value rather than its first address | 1688 with the `crypto` and `mcp` extras plus `fastapi` and `flask`, 9 skipped |
318319
| 1.10.0 | integration hardening: one execution contract, so a declared transformation reaches the tool and the caller on every path, in every shape it returns and in both payloads of an MCP result; the authenticated session's role authoritative over the caller's claim; resolved path containment; server identity and route mapping authoritative over the client; parameter and novel lineage re-checked at deferred commit; terminal deferral states; tokens consumed before async calls | 1616 with the `crypto` and `mcp` extras plus `fastapi` and `flask`, 9 skipped |
319320
| 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 |
@@ -374,6 +375,23 @@ Without `mcp`, and with `fastapi`, `flask` and PyNaCl still present but no
374375
release and all 19 are guarded on `mcp`, being 10 oracle cases and 9 engine
375376
cases. Nothing fails.
376377

378+
For 1.10.2 it is 1746 passing and 9 skipped on CPython 3.14.6 with `mcp
379+
2.2.0`, `fastapi` 0.141.1, `flask` 3.1.3, `python-jose` 3.5.0 and PyNaCl
380+
present, the 58 added tests being the 31 cases the reviewer added in their
381+
1.10.1 recheck, which arrived as a second oracle file rather than as an append
382+
and brings the two files to 96 between them, and the 27 engine tests covering
383+
what those 31 reach from outside cannot: both spellings of a denied deferral
384+
through `confirm_execution`; the verified, unverified, wrong key, expired and
385+
no key states of bearer identity over both HTTP adapters and both flask entry
386+
points; the absent, differently spelled and non bearer forms of the
387+
`Authorization` header under a configured key; and a configured key paired with
388+
a disabled token path, which is refused when the dependency is built. Without
389+
`mcp` and without `python-jose`, and with `fastapi`, `flask` and PyNaCl still
390+
present, it is 1685 passing and 70 skipped; 25 of the 70 are new in this
391+
release, being 9 oracle cases guarded on `mcp` and 16 engine cases guarded on
392+
`python-jose`, which is the verification backend and cannot be faked. Nothing
393+
fails.
394+
377395
The 1.9.0 argument binding, and the 1.9.1 binding rules that refuse a
378396
`**kwargs` key naming another parameter, bind through a `functools.partial` to
379397
the function underneath, read a recipient for the characters it holds rather
@@ -405,6 +423,27 @@ request is judged against when the route mapping names one. None of this
405423
reaches the standalone adapters, which ship separately and apply no declared
406424
transformation at all.
407425

426+
Over HTTP, 1.10.2 finishes the identity half of that, because 1.10.0 promoted a
427+
claim it had never checked. Both adapters read a bearer token's payload without
428+
verifying its signature, and E5 then made those claims outrank the
429+
`X-AgentLock-*` identity headers, so a token any client could type outranked a
430+
header a deployment could strip at its edge. Verification is now opt in and the
431+
two states are exhaustive. Configure `jwt_key` and the verified bearer token is
432+
the ONLY identity: it is checked with expiry enforced and `"none"` refused as an
433+
algorithm however `jwt_algorithms` is written, the scheme is matched case
434+
insensitively so `bearer` and `Bearer` are the same credential, a token that
435+
verifies is authoritative, one that does not is 401 with reason `jwt_invalid`,
436+
and a request carrying no bearer credential at all is 401 with reason
437+
`jwt_required`. The identity headers are not consulted in any of those cases,
438+
because a request shape that reaches them is a request shape a caller can aim
439+
for, and the first draft of this fix left two of them: an absent header and a
440+
scheme spelled another way both reached the headers under a configured key.
441+
Leave `jwt_key` unset, the default, and a bearer token carries no identity at
442+
all and the headers are the identity input. They are trusted-upstream inputs
443+
either way: they prove nothing on their own, and a deployment facing untrusted
444+
clients directly has to strip client-supplied `X-AgentLock-*` at its edge or
445+
authenticate by verified token instead.
446+
408447
The contract every wrapper now follows is one sentence long: `authorize()`
409448
returns the parameters the call will actually run with and the transformation
410449
its return value will pass through, and the wrapper does both.

‎agentlock/__init__.py‎

Lines changed: 7 additions & 2 deletions
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.10.1"
37+
__version__ = "1.10.2"
3838

3939
from agentlock.action_class_audit import (
4040
ActionClassAudit,
@@ -57,7 +57,11 @@ def send_email(to: str, subject: str, body: str) -> str:
5757
from agentlock.chain import GENESIS_HASH, ChainedContextEntry, ContextChain
5858
from agentlock.context import ContextProvenance, ContextState, ContextTracker
5959
from agentlock.decorators import agentlock
60-
from agentlock.defer import DeferralManager, DeferralRecord
60+
from agentlock.defer import (
61+
DeferralManager,
62+
DeferralRecord,
63+
is_denial_resolution,
64+
)
6165
from agentlock.exceptions import (
6266
AgentLockError,
6367
ApprovalRequiredError,
@@ -236,6 +240,7 @@ def send_email(to: str, subject: str, body: str) -> str:
236240
"DeferralManager",
237241
"DeferralRecord",
238242
"DeferredError",
243+
"is_denial_resolution",
239244
# STEP_UP (v1.2)
240245
"StepUpManager",
241246
"StepUpRequest",

0 commit comments

Comments
 (0)