An adversarially benchmarked reference implementation for pre-action agent authorization
Your AI agent needs a login screen. AgentLock is that login screen.
Pre-action authorization for LLM agent tool calls. The gate decides from provenance, not content: there is nothing for an attacker to phrase around.
Tool-using agents read attacker-reachable content: inboxes, channels, web pages, retrieved documents. Indirect prompt injection turns that content into control. Content filters classify strings, but the harmful thing about an agent action is usually a relationship the tool-call text never expresses: who the recipient is, what sequence of reads preceded a write, whether the user ever asked for this action at all. The full argument is in the paper (DOI below). AgentLock gates actions on where the session's content came from instead of what it says.
Every item entering the context window is recorded with a provenance authority: authoritative (the user's request), derived (benign system data), or untrusted (anything a third party can influence). Three mechanisms enforce on that record:
- Session write-gate. Once untrusted content enters the session, consequential writes are blocked. The gate never reads the payload, so there is no wording that gets around it.
- Parameter lineage, opt-in per tool. Register a tool at permissions version 1.3 or later with param_lineage_enabled set on its lineage policy, and every parameter of a call to that tool is checked for values that trace to untrusted content but not to the user's own request. An attacker-planted URL or email is denied even when the call looks legitimate. It is off by default, and it only sees what a caller has recorded: a write reported without an untrusted context source produces no untrusted entry, so there is nothing to trace to and the check returns no match.
- Deferred commit. Consequential actions are queued and re-decided at end of turn against the complete session provenance, so content that arrives after the call can still deny it.
v1.4 adds selective action-class gating: instead of blocking every consequential write on taint, you declare which action classes each tool belongs to, and the gate blocks exactly the classes where blocking is the only sound defense. Deletion and membership changes stay gated, because their malice needs no distinctive parameter value for lineage to catch. Value-carrying writes can be released to parameter lineage, which covers them. Declarations that add gating are free; the declaration that removes it (is_value_carrying) can only come from the tool's trusted registration block, never from a caller, because a wrong addition over-blocks and a wrong removal fails open.
Most security tools show you their wins. Here is the whole table (gpt-4o-mini, tool_knowledge attack, single runs at the benchmark's default sampling; the attacked cells are 140 episodes on travel and 105 on slack, and the benign ceilings are 20 and 21 tasks):
| suite | undefended, attacked | uniform gate (v1.3) | selective gate (v1.4) | benign ceiling |
|---|---|---|---|---|
| travel | 36.43% | 30.00% | 51.43% | 65.00% |
| slack | 52.38% | 4.76% | 4.76% | 76.19% |
Defense-effective attack success is 0.00% in every defended cell above except travel under the uniform gate, which sits at 2.14%. That residual is persuasion: three episodes whose injection goal is met in the model's text output, with no tool call to gate. Selective gating takes it to 0.00%. Banking and workspace hold 0.00% defense-effective attack success across two models in the v1.3 evaluation (paper, Table 3).
Travel is the win. Selective gating restores utility to 79% of the benign ceiling, from 46% under the uniform gate, and the defended agent beats the undefended one under attack, because blocked injections stop derailing it.
Slack is the cost, stated plainly. The defense floors utility at 4.76% whether or not an attack is present, and on slack it costs more utility than the attack it prevents. Every benign slack task reads a channel before writing, so the write-gate denies the write. Selective gating cannot fix this: slack's floor comes from taint-gated outbound and membership writes plus lineage blocks on reads, not from value-carrying writes.
The sign of the effect is set by workload shape. If your agent's benign workflow is read-then-write over attacker-reachable content, this gate is expensive, and you should know that before deploying. v1.4 ships audit_action_classes() so you can find out which case you are in from your own traffic instead of guessing.
The attack-success numbers above use defense-effective ASR, which excludes verified benchmark scoring artifacts (episodes where a blocked call is scored as a success). We reported the underlying checker issue upstream (agentdojo #168) and disclose official ASR alongside it in the paper. Single-run rates on this benchmark carry sampling noise; we measured the spread across replicates and report it in the benchmark notes.
- Grice, D. (2026). Provenance-Based Pre-Action Authorization for LLM Agents: A Structural Defense Evaluated on AgentDojo with AgentLock. Zenodo. https://doi.org/10.5281/zenodo.21270300
- Grice, D. (2026). Selective Provenance Gating: Recovering Agent Utility Where Recovery Is Sound. Zenodo. https://doi.org/10.5281/zenodo.21363120
Paper 2 builds on Paper 1. Each record pins the engine commit it measured.
pip install agentlockFramework integrations and Ed25519 receipts are optional extras:
pip install "agentlock[autogen]" # also: mcp, fastapi, flask
pip install "agentlock[crypto]" # Ed25519 signed receipts
pip install "agentlock[all]" # everythingRegister a tool with a lineage policy, then authorize the same call before and after untrusted content enters the session. The tool call is identical both times. Only the provenance of the session changed.
from agentlock import (
AgentLockPermissions,
AuthorizationGate,
ContextSource,
LineagePolicyConfig,
)
gate = AuthorizationGate()
gate.register_tool("send_email", AgentLockPermissions(
risk_level="high",
allowed_roles=["user"],
lineage_policy=LineagePolicyConfig(enabled=True, decision="deny"),
))
session = gate.create_session(user_id="alice", role="user")
# The user's own instruction is authoritative.
gate.notify_context_write(
session.session_id, ContextSource.USER_MESSAGE, "hash-of-user-request",
)
first = gate.authorize(
"send_email", user_id="alice", role="user",
parameters={"to": "bob@corp.com"}, is_external=True,
)
print(first.decision.value, first.allowed) # allow True
# A web page enters the session. Nothing about the tool call changes.
gate.notify_context_write(
session.session_id, ContextSource.WEB_CONTENT, "hash-of-web-page",
tool_name="fetch_url",
)
second = gate.authorize(
"send_email", user_id="alice", role="user",
parameters={"to": "bob@corp.com"}, is_external=True,
)
print(second.decision.value, second.allowed) # deny False
print(second.denial["reason"]) # untrusted_lineageTurn off the blanket consequential gate, declare each tool's class, and audit what you did.
from agentlock import (
ActionClassConfig,
AgentLockPermissions,
AuthorizationGate,
LineagePolicyConfig,
format_action_class_audit,
)
# Release value-carrying writes to parameter lineage; keep the taint gate
# on the value-free classes.
policy = LineagePolicyConfig(enabled=True, gate_consequential=False, decision="deny")
gate = AuthorizationGate()
gate.register_tool("reserve_hotel", AgentLockPermissions(
risk_level="high", allowed_roles=["user"], lineage_policy=policy,
action_class=ActionClassConfig(is_value_carrying=True),
))
gate.register_tool("delete_email", AgentLockPermissions(
risk_level="high", allowed_roles=["user"], lineage_policy=policy,
action_class=ActionClassConfig(is_deletion=True),
))
gate.register_tool("append_to_file", AgentLockPermissions(
risk_level="high", allowed_roles=["user"], lineage_policy=policy,
))
print(format_action_class_audit(gate.audit_action_classes()))UNDECLARED -- no action_class in the trusted permission block
append_to_file [high risk, selective]
why: no action_class, and gate_consequential=False. A call that
asserts no class at all matches no disjunct and is never
taint-gated. This is the residual hazard.
>> REQUIRES HUMAN DECISION (no suggestion available)
NOT COVERED -- the session write-gate cannot block these tools
reserve_hotel [high risk, selective]
declared: is_value_carrying
why: declares is_value_carrying with gate_consequential=False,
DELIBERATELY un-gated. Parameter/novel lineage is the
covering control. This is the intended configuration.
DECLARED -- action class on the trusted side
delete_email [high risk, selective]
declared: is_deletion
why: declared and taint-gated via is_deletion
Run the audit before flipping any gate flag. It reports every tool as DECLARED, UNDECLARED, or NOT_COVERED, backed by what your own traffic actually asserted, and it will never confidently suggest the one declaration that weakens gating: that one requires a human.
- Persuasion. If the injection's goal is achieved in the model's text output, there is no tool call to gate. Travel's entire residual in our evaluation is this mechanism.
- Read goals. If the goal is achieved by a read, a write-gate cannot block it; parameter lineage covers the subset with distinctive values.
- Misclassification. The gate is only as correct as the authority labels on your tools. A single mislabeled tool accounted for the entire slack residual before we found it. Classification auditing is a deployment requirement, not an afterthought, which is why v1.4 ships the audit.
- Laundering that breaks carriage. As of 1.7, enforcement is bounded by
carriage rather than by hop count: a value relayed through an
intermediate tool is denied
param_lineageciting the relay entry, because the relaying write records the untrusted entry as its parent and decision-time checks walk the recorded link. The link is established only when the ingesting call's parameters carry the prior entry's whole content, at or above the containment floor. A value that the intermediate tool rewrites, paraphrases, or truncates below the floor, or that never reaches that call's parameters, does not link, and the chain stops there. - Recognizing an encoding of something it never saw. v1.6 matches encoded forms by encoding the untrusted values it already has and looking for them, never by decoding your parameters. That is what keeps a benign value that merely looks like base64 from being misread. The price is that a payload whose plaintext never entered the session as untrusted content has nothing to match against.
- Selection influence. Untrusted content that merely chooses among values the user already supplied plants nothing, so there is nothing for a provenance match to fire on. The session write-gate covers the gated case; parameter lineage does not.
- Guessing which call you meant. An execution token is bound to the
parameters the gate authorized, the empty call included. As of 1.9,
execute()must be handed the same parametersauthorize()saw or it raisesTokenInvalidError; the gate has no way to tell which of two differing parameter sets was the honest one, so it refuses both. - Telling a legitimate quotation from an attack. A summary that genuinely quotes an attacker-supplied address really does carry that value, so it is denied. Deciding it was benign would mean judging what the value is for, which is the content judgement this gate refuses to make.
The v1.6 encoding claim is measured at the engine, with a lineage policy configured and untrusted sources declared. It is not a claim about any adapter's defaults. The novelty branch has a real false-positive cost, it is measured on a frozen corpus rather than estimated, and it is off by default.
Full statement, both registers, every number with its corpus and denominator: docs/LIMITATIONS_v16.md. How the v1.6 work was predicted, measured and corrected, and where the raw amendment chain is: docs/DESIGN_NOTES_v16.md.
We found two defects in our own engine during v1.4 development: a version comparison that failed open at schema version 1.10, and a deferred-commit path that ignored action-class declarations. Both were caught by our own verification gates before release, both are fixed, and both are in the changelog. That is how we intend to keep working.
| version | highlights | tests |
|---|---|---|
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 1.6.0 | value-identity normalization; encoded-form attribution, bare and composite, base64/hex/natural-URL, zero decode | 1364 (1351 without optional extras) |
| 1.5.0 | grant basis, execution confirmation, provenance on denials, deferred-resolution logging; LangChain and CrewAI adapters moved out of core | 1141 |
| 1.4.0 | selective action-class gating, novel lineage, action-class audit, needs_approval surfacing | 1041 |
| 1.3.0 | provenance-lineage gating, parameter lineage, deferred commit, AgentDojo evaluation | 868 |
| 1.2.x | adaptive hardening, decision types (final Apache 2.0 line) | 847 |
Both counts are measured with the crypto and mcp extras installed
(pip install -e ".[crypto,mcp]"). For 1.6.0 that is 1364 passing and 0
skipped; a bare install runs 1351 passed and skips the 13 optional-extra
tests, 12 of which need PyNaCl and 1 of which needs mcp. For 1.7.0 it
is 1418 passing and 7 skipped, the 7 being pre-increment-3 baselines that
stand down once the broadened reachability predicate is present; a bare
install additionally skips the same 13 optional-extra tests. For 1.8.0 it
is 1495 passing and 8 skipped, the 8 being those 7 baselines plus the
AutoGen integration test, which needs the autogen extra and so runs
only on Python below 3.14; a bare install runs 1479 passed and 24
skipped. For 1.9.0 it is 1503 passing and 9 skipped under mcp 2.x, the
9 being those 8 plus the mcp 1.x test, which selects on the installed
SDK major; under mcp 1.x it is 1502 passing and 10 skipped, the two 2.x
tests taking the place of the 1.x one. For 1.9.1 it is 1520 passing and 9
skipped under mcp 2.x and 1519 passing and 10 skipped under mcp 1.x, the
seventeen added tests being the binding collision class and the binding red
pass class, none of which is guarded by an extra; a bare install runs 1515
passed and 14 skipped. For 1.10.0 it is 1616 passing and 9 skipped on
CPython 3.14.6 with mcp 2.2.0, fastapi, flask and PyNaCl present, the
96 added tests being the external review's 33-test oracle, the 30 engine
tests covering what the oracle reaches from outside cannot, and the 33 of
TestRedPass closing two pre-release red passes against the built wheel.
That figure needs the two web frameworks as well as the two extras, which
no earlier row did: four of TestRedPass's cases drive the FastAPI and
Flask route mapping. Without mcp, and with fastapi, flask and PyNaCl
still present, it is 1595 passing and 30 skipped, the 30 being 22 guarded on
mcp, 1 on autogen, and the 7 pre-increment-3 baselines; sixteen of the
mcp-guarded ones are new in this release. Under mcp 1.30.0, on CPython
3.13.14 with neither web framework, the engine suite is 1595 passing and 26
skipped with the review file's four mcp cases deselected, which construct
the 2.x Server and cannot run against a 1.x SDK at all; the engine's 1.x
hook is covered in every environment by tests/test_v110_hardening.py,
TestRedPass's own session-role and structured-content cases over that hook
included. On CPython 3.13.14 with all four present it is 1617 passing and 8
skipped. Nothing fails in any of these environments.
For 1.10.1 it is 1688 passing and 9 skipped on CPython 3.14.6 with mcp 2.2.0, fastapi, flask and PyNaCl present, the 72 added tests being the
32 cases the reviewer appended to the oracle in their 1.10.0 recheck, which
brings that file to 65, and the 40 engine tests covering what those 32 reach
from outside cannot: the same MCP shapes over the 1.x hook, the resolved path
a whitelisted callable actually receives, the recipient edge forms, and the 13
cases of the pre-release red pass against this release's own branch wheel.
Without mcp, and with fastapi, flask and PyNaCl still present but no
autogen, it is 1652 passing and 45 skipped; 19 of the 45 are new in this
release and all 19 are guarded on mcp, being 10 oracle cases and 9 engine
cases. Nothing fails.
The 1.9.0 argument binding, and the 1.9.1 binding rules that refuse a
**kwargs key naming another parameter, bind through a functools.partial to
the function underneath, read a recipient for the characters it holds rather
than for what its methods say, and refuse a declared recipient parameter the
signature can never carry, cover the engine's own decorators and in-repo
integrations, which is the whole of what those two releases change. The standalone adapters ship from their own
repositories and are updated separately. At their current releases,
crewai-agentlock 0.2.0 and langchain-agentlock 0.1.0 authorize keyword
arguments only, and of those two only crewai-agentlock carries positional
arguments past the gate into the wrapped call; mcp-agentlock 0.2.1,
openai-agentlock 0.1.0 and openclaw-agentlock 0.1.0 hand the gate the same
argument mapping they hand the tool and have no positional route. No standalone
adapter applies the wrapped function's defaults, so a parameter the caller
omits and the function defaults is not seen by the gate in any of them, and
none of them carries any of the 1.9.1 binding rules. 1.9.0 also makes an execution
token bind the empty call: the parameters passed to execute() must be the
parameters passed to authorize(), and None and {} are the same call.
1.10.0 closes the gap between a decision and its execution. Through 1.9.1 a
declared parameter or output transformation was computed by authorize() and
then reached the tool on no path and the caller on one. It now travels with
the grant: AuthResult carries effective_parameters beside
modify_output_fn, the execution token is bound to the effective parameters
rather than the requested ones, and gate.call(), both decorators, both MCP
hooks and the AutoGen map all apply both. 1.10.0 also makes the server, and
not the client, decide two things the client was deciding: which identity an
MCP call runs under when the host configured one, and which tool an HTTP
request is judged against when the route mapping names one. None of this
reaches the standalone adapters, which ship separately and apply no declared
transformation at all.
The contract every wrapper now follows is one sentence long: authorize()
returns the parameters the call will actually run with and the transformation
its return value will pass through, and the wrapper does both.
AuthResult.effective_parameters is the call after every declared parameter
transformation, and it is what the execution token's parameters_hash is taken
over, so the grant names the call rather than the request.
AuthResult.modify_output_fn is the declared output transformation, and
agentlock.modify.apply_output_modifier is the single applier that runs it over
whatever shape the tool returned: a str, and recursively a dict, list,
tuple, set or frozenset with the container type preserved, and bytes
through a UTF-8 round trip. Every route does those same two things. gate.execute()
validates the token against the effective parameters, calls the tool with them and
applies the modifier to what comes back; gate.call() forwards both into it; the
two decorators rebuild the call through binding.apply_effective_parameters, so
that even a positional-only parameter can be transformed, and then apply the
modifier; the AutoGen map goes through gate.execute(); and both MCP hooks pass
the effective parameters to the handler and walk both payloads of the result.
Because there is one applier and one binding inverse, adding an execution route
means calling them rather than reimplementing them, and the shape of a tool's
return no longer decides whether its declared policy runs. What the contract does
not cover is listed under Limits in the changelog: the two HTTP adapters authorize
a request without its body and so run none of the per-parameter checks, and
anything past a __wrapped__ boundary is the application's own code.
Full feature history: v1.1, v1.2, v1.3. Changelog: CHANGELOG.md.
Provenance-Based Pre-Action Authorization for LLM Agents (Grice, 2026). DOI: 10.5281/zenodo.21270300. The v1.4 selective-gating evaluation was pre-registered before the benchmark runs; prediction files and the full benchmark report ship with the release.
Selective Provenance Gating: Recovering Agent Utility Where Recovery Is Sound (Grice, 2026). DOI: 10.5281/zenodo.21363120. It builds on the first paper and evaluates the selective action-class gating introduced in v1.4.
If you use AgentLock in your research, please cite the paper that matches the mechanism you rely on:
Grice, D. (2026). Provenance-Based Pre-Action Authorization for LLM Agents: A Structural Defense Evaluated on AgentDojo with AgentLock. Zenodo. https://doi.org/10.5281/zenodo.21270300
Grice, D. (2026). Selective Provenance Gating: Recovering Agent Utility Where Recovery Is Sound. Zenodo. https://doi.org/10.5281/zenodo.21363120
Software archive (all versions): https://doi.org/10.5281/zenodo.22681594.
The version DOI for each release is minted at publication and added to
CITATION.cff and to this line in a follow-up commit.
@misc{grice2026agentlock,
author = {Grice, David},
title = {Provenance-Based Pre-Action Authorization for LLM Agents:
A Structural Defense Evaluated on AgentDojo with AgentLock},
year = {2026},
publisher = {Zenodo},
doi = {10.5281/zenodo.21270300},
url = {https://doi.org/10.5281/zenodo.21270300}
}@misc{grice2026selective,
author = {Grice, David},
title = {Selective Provenance Gating: Recovering Agent Utility
Where Recovery Is Sound},
year = {2026},
publisher = {Zenodo},
doi = {10.5281/zenodo.21363120},
url = {https://doi.org/10.5281/zenodo.21363120}
}CITATION.cff at the repository root carries the software citation.
Research commits authored as schen-analytics were made under an alternate
GitHub identity of the author, configured on the research machine
(see paper, Appendix B).
AGPL-3.0-or-later for v1.3.0 and later, with commercial licenses for closed-source use: see COMMERCIAL.md or contact licensing@agentlock.dev. Versions 1.2.x and earlier remain Apache 2.0.
Report vulnerabilities to security@agentlock.dev. Supported versions and policy: SECURITY.md.
See CONTRIBUTING.md. The test suite must pass under
-W error::UserWarning.