Token audience and startup hooks¶
Two checks added in 0.10.27. Both run before @Airlock reads any argument, right after
the kill switch, and neither changes anything for a config that does not set it:
| Setting | Default | What it checks |
|---|---|---|
expected_token_audience |
None, nothing checked |
the aud claim of the OAuth access token behind each guarded call |
token_audience_mode |
"warn" |
what a wrong or missing audience does once an audience is set |
startup_hook_check |
"off" |
executable .pth lines and sitecustomize / usercustomize in the running interpreter |
startup_hook_allowlist |
{} |
{file name: sha256} of the startup hooks you know |
A mode is "off", "warn" or "deny"; anything else raises ValueError when the config
is built, so a typo never reads as the default.
Token audience¶
CVE-2026-115413 (High, CVSS 3.1 7.8, CWE-346): the AWS API MCP Server before 1.6.0, in OAuth mode, did not validate the access token's audience. A token the same identity provider issued to another application ran AWS operations with the server's own credentials. The fix in 1.6.0 also has the operator configure the expected audience. One identity provider serving several applications is the ordinary case, which is why a token being valid is not enough: it has to be valid for this server.
agent_airlock.mcp_spec.oauth.validate_access_token_audience has shipped for releases,
but until 0.10.27 nothing on the call path called it. Set the server's canonical resource
URI and every guarded call checks the token behind it:
from agent_airlock import Airlock, AirlockConfig, AirlockContext
config = AirlockConfig(
expected_token_audience="https://mcp.example.test",
token_audience_mode="deny",
)
@Airlock(config=config)
def list_buckets() -> list[str]:
return []
# The claims your server decoded from the bearer token after verifying its signature,
# expiry and issuer. Placeholder values.
claims = {"sub": "placeholder-subject", "aud": "https://mcp.example.test"}
with AirlockContext(metadata={"access_token_claims": claims}):
list_buckets() # runs; a token for any other audience is refused
Where the claims come from¶
The host puts the decoded claims on the call's AirlockContext metadata under
access_token_claims (agent_airlock.core.ACCESS_TOKEN_CLAIMS_KEY): with
with AirlockContext(metadata=...) around the call, or on the context of the run wrapper
a framework passes as the tool's first positional argument. A run wrapper is an object
model JSON cannot have built: a framework's own object that is not a pydantic model, an
object whose context is an AirlockContext, or an instance of a plain host class (see
Agent Identity). That is the channel the per-model-tier
budget reads its tags from, and the model cannot write it. Until 0.10.28 a tool's own
model or dataclass with a context field, passed first, was read as a run wrapper, so its
JSON could supply the claims. A tool argument named
access_token_claims is the model's, and is never read for this check: the call is judged
on the host's claims or, with none, refused under deny as carrying no claims.
The check reads the audience and nothing else. Verifying the token's signature, expiry and issuer is the host's job, done before it hands the claims over; the check trusts the mapping it is given.
What matches¶
aud may be a string or a list of strings, and both sides are canonicalised
(canonicalize_resource_uri): scheme and host lowercased, a trailing slash dropped, and a
bare host with or without its root / treated as one resource (RFC 3986 §6.2.3). The
comparison also reads a scheme's default port (:443 for https, :80 for http) as no
port. An IPv6 literal keeps its brackets, so https://[::1]:80 and https://[::1:80]
stay two resources. So a token for ["https://mcp.example.test/"] or for
https://mcp.example.test:443 matches https://mcp.example.test. A list may name other
audiences beside this server, a client id among them. Refused, under deny:
- an
audnaming another resource, or a path this server does not have; - a missing or empty
aud; - a list that holds anything but strings, even beside a matching one (RFC 7519 §4.1.3);
- a string
audthat is not a URI, or is malformed (a port out of range, an unclosed IPv6 bracket); until 0.10.28 a malformed one raised out of the call underwarnanddenyalike; - claims that are not a mapping, and no claims at all.
Modes¶
token_audience_mode |
Wrong or missing audience |
|---|---|
"warn" (default) |
one structured airlock_token_audience_warn_only event per call, then the call runs |
"deny" |
the call is refused and on_blocked is called |
"off" |
not checked |
warn is the default so that turning the check on cannot break a deployment whose host
does not attach claims yet: set the audience, read the warnings, then switch to deny.
A refusal is the usual blocked response: block_reason "token_audience", an error and
fix_hints for the model, and metadata.violation, one of no_claims,
claims_unreadable or audience_mismatch. The audience the token named is in the
on_blocked reason and the log, not in the text the model reads.
The policy_presets.token_audience_defaults(expected_audience=...) preset returns the
same check as a check(claims) callable that raises TokenAudienceError, and an
airlock_config with token_audience_mode="deny" to pass to @Airlock(config=...).
Startup hooks¶
While the interpreter starts, CPython's site module runs two kinds of hook, before the
first line of your program and before import agent_airlock:
- a line of a
*.pthfile in a site directory that starts withimportand a space or a tab is passed toexec()(every other line is a directory added tosys.path); - a module named
sitecustomize, then one namedusercustomize, is imported when the import system finds one.
Either runs in every process that interpreter starts, so a compromised dependency that
drops a .pth file persists: its line runs before the program it rides in, every time.
What this check cannot do. These hooks run before any Python code of yours, this
library included, so the check cannot stop them running and cannot undo what they did. It
tells you the interpreter is not clean. Under deny it also keeps every tool guarded by a
config that sets it, credential-bearing ones included, from running in that interpreter;
a tool whose config leaves the check off still runs. Scanning packages when
they are installed is not this library's job; use your dependency scanner for that.
from agent_airlock.runtime import check_startup_hooks
report = check_startup_hooks()
for finding in report.findings:
print(finding.kind.value, finding.path, finding.sha256)
check_startup_hooks() walks site.getsitepackages(), site.getusersitepackages() and
every sys.path entry that is a directory. It lists each *.pth file holding an
executable line, and finds sitecustomize and usercustomize with
importlib.util.find_spec, which locates a module without running it. Each finding has a
path, a kind (pth, sitecustomize, usercustomize) and the sha256 of its
content. A path-only .pth file is not a finding.
It reports a hook whether or not this interpreter's start-up ran it: python -S, a user
site the interpreter does not load, a .pth file site skips because it is hidden.
Another interpreter on the same machine may run it.
Allowlist and modes¶
Most environments carry hooks of their own. A virtualenv with this repo's [dev] extras
has coverage's a1_coverage.pth; a long-lived interpreter adds setuptools'
distutils-precedence.pth and the -nspkg.pth files of legacy namespace packages. List
the ones you know by file name and SHA-256. A known name whose content has changed is a
finding again, and a key that is a path or a value that is not a SHA-256 digest raises
ValueError rather than never matching. An entry pins the hook file's own bytes, not the
modules its import lines load: distutils-precedence.pth imports _distutils_hack, and a
change to that module is not a finding, because the check does not hash it:
from agent_airlock import Airlock, AirlockConfig
from agent_airlock.runtime import check_startup_hooks
# Review each hook before you trust it: this copies whatever is there.
allowlist = {finding.name: finding.sha256 for finding in check_startup_hooks().findings}
config = AirlockConfig(startup_hook_check="deny", startup_hook_allowlist=allowlist)
@Airlock(config=config)
def read_secret() -> str:
return "PLACEHOLDER"
startup_hook_check |
A hook no allowlist entry covers |
|---|---|
"off" (default) |
nothing is walked |
"warn" |
one structured airlock_startup_hook_found event per hook, then every call runs |
"deny" |
every call guarded by a config with "deny" is refused, naming the files |
The walk runs once per process, the first time a guarded call is admitted with the check
on; nothing is walked at import or when a function is decorated. Each hook is logged once,
not on every call. A hook added after that walk is not seen until the process restarts;
check_startup_hooks() itself walks every time it is called.
A refusal has block_reason "startup_hook". The error names the files; their full
paths and digests are in metadata.startup_hooks and the on_blocked reason.
TOML¶
Both checks read from [airlock] in AirlockConfig.from_toml():
[airlock]
expected_token_audience = "https://mcp.example.test"
token_audience_mode = "deny"
startup_hook_check = "warn"
[airlock.startup_hook_allowlist]
"distutils-precedence.pth" = "<the file's sha256, 64 hex characters>"
A mode is read case-insensitively. An unknown mode, a non-string audience or an
allowlist that is not a table raises ValueError when the file is loaded.
See also: Configuration,
the AirlockConfig reference.