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. Every tool-call parameter 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.
- 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.
pip install agentlockFramework integrations and Ed25519 receipts are optional extras:
pip install "agentlock[langchain]" # also: crewai, autogen, 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.
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.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 |
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.
If you use AgentLock in your research, please cite:
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
@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}
}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.