agentcreds-runtime
Runtime agent-identity enforcement built on AgentCreds. Every tool call is authenticated, authorized, and audited - the caller must present a delegation token that is cryptographically authentic, rooted in a credential from a trust anchor you accept, scoped to the tool (and resource) being called, and accompanied by a fresh proof that the caller holds the leaf agent's key.
Two transports share one policy core:
- MCP (interactive, sessionful) -
IdentityEnforcer+ a FastMCP adapter. - A2A (agent-to-agent, no MCP server) -
A2AVerifier+ a self-contained header envelope.
Both enforce the same guarantees: anchor-rooted authority, proof-of-possession, revocation, on-behalf-of principal binding, argument binding, multi-issuer trust, and tamper-evident audit / ADR.
Pure-Python package (the core
agentcredsSDK is a compiled PyO3 extension it imports). Install nameagentcreds-runtime; import pathagentcreds_runtime.This is the only MCP policy enforcement point.
@agentcreds/runtime(Node) is A2A-only by design - R10 gates, step-up approval and the MCP enforcer are not ported, because a second enforcement implementation would have to be kept correct twice and the conformance suite covers this one. A Node agent talks to this PEP over the wire.
Install
pip install agentcreds-runtime # both transports
pip install "agentcreds-runtime[mcp]" # + the FastMCP adapter (official MCP SDK)
MCP: per-session enforcement
from agentcreds_runtime import IdentityEnforcer, PolicyConfig
enforcer = IdentityEnforcer(anchor, config=PolicyConfig(max_age_secs=60, audit=my_audit_sink))
challenge_cbor = enforcer.issue_challenge(session_id) # send to the client
# ... per tool call, with the presentation bytes the client returned:
decision = enforcer.authorize(session_id, tool, arguments, presentation_cbor)
if decision.denied:
handle(decision.code) # see "Denial codes" below
chain = enforcer.enforce(session_id, tool, arguments, presentation_cbor) # or: raises
Holder side: present(token, credential, challenge_cbor, leaf_agent, action=None).
FastMCP adapter
from agentcreds_runtime import IdentityEnforcer
from agentcreds_runtime.fastmcp import guard_tool
enforcer = IdentityEnforcer(anchor)
@mcp.tool()
@guard_tool(enforcer, "tool:search")
async def search(q: str, ctx: Context, agentcreds_presentation: str, agentcreds_chain=None):
return do_search(q) # reached only if enforcement passed
A2A: no MCP server
When agent A hands a task directly to agent B, A attaches a self-contained
identity header and B verifies it offline. A2A is one-shot, so the sender
mints the proof-of-possession challenge with audience set to the receiver.
from agentcreds_runtime import A2AVerifier, make_a2a_header
# Sender (caller):
header = make_a2a_header(token, vc, agent, audience="a2a://orders.example/agent")
# Receiver:
verifier = A2AVerifier(audience="a2a://orders.example/agent", anchor=anchor)
decision = verifier.authorize(header, "tool:search", {"q": "hi"})
verify runs audience-match + anchor-rooted + proof-of-possession; a header minted
for another receiver, or outside max_age_secs, is rejected.
Replay protection
A2A is callback-free, so within the freshness window a header could be re-sent
verbatim. Each header is made single-use by default (an in-process guard).
For more than one receiver replica, pass a shared guard so replays are caught
across replicas; to turn it off, set enable_replay_protection=False:
from agentcreds_runtime import A2AVerifier, RedisReplayGuard
verifier = A2AVerifier(audience=me, anchor=anchor) # default: on (in-memory)
# verifier = A2AVerifier(..., replay_guard=RedisReplayGuard(redis)) # many replicas
# verifier = A2AVerifier(..., enable_replay_protection=False) # off
MCP has the same option (
IdentityEnforcer(..., replay_guard=...)) but it is off by default: the per-session challenge means legitimate calls reuse byte-identical presentations, so single-use enforcement requires per-callrotate_challenge. A2A senders mint a fresh challenge per message, so it is safe to default on there.
On-behalf-of over A2A (the wire envelope)
There is no session to establish who the human is, so the human's verifiable identity travels with the message and is validated independently by the receiver - restoring the confused-deputy protection. The envelope is a header-name -> value mapping:
from agentcreds_runtime import make_a2a_envelope, A2AVerifier, principal_resolver_from_oidc
# Sender: capability + the human's OIDC token.
envelope = make_a2a_envelope(token, vc, agent, audience=me, principal_token=id_token)
# {"AgentCreds-A2A": "...", "AgentCreds-A2A-Principal": "AgentCreds-A2A-Principal/1...."}
# Receiver: validate the human via your IdP, then verify the capability.
verifier = A2AVerifier(audience=me, anchor=anchor,
principal_resolver=principal_resolver_from_oidc(provider))
decision = verifier.authorize_envelope(envelope, "tool:read_email", {},
resource="mailbox:alice@acme.com/42")
The core then enforces that the capability token's bound principal equals the independently verified human - so a valid token for Bob cannot drive an agent whose authority is bound to Alice.
Shared policy (both transports)
Every option below works identically on IdentityEnforcer and A2AVerifier. The
shared policy knobs live on a PolicyConfig(...) passed as config=; the trust
anchor and transport-specific options stay direct on the constructor.
| Feature | How |
|---|---|
| Revocation | config=PolicyConfig(revocation_check=...) (a callable; or revocation_check_from_list(list, anchor)). Denied -> credential_revoked. Fail-closed by default. |
| Multi-issuer | anchor_for= (a resolver; or anchor_resolver_from_registry(registry)) instead of a single anchor. Untrusted issuer -> untrusted_issuer. |
| Argument binding | Sender binds with make_a2a_header(..., action=...) / present(..., action=...); receiver sets config=PolicyConfig(require_argument_binding=True). Tampered args -> possession_failed; unbound when required -> argument_binding_required. |
| On-behalf-of | MCP: enforcer.bind_principal(session_id, human_did). A2A: the principal envelope above. Mismatch -> principal_mismatch. |
| Audit / ADR | config=PolicyConfig(audit=..., adr_sink=..., adr_stream=...) - a structured AuthzDecision for every allow and deny, carrying the security signal, the correlation vc_id, and who answers (accountable_party + accountability_source, read from the credential). With a stream, records fold into a tamper-evident hash-chain (sign_checkpoint -> AdrStream.replay). |
| Tracing an effect | Decision.record_id is the ADR's per-call id; guard_tool hands it to a handler declaring agentcreds_record_id. Log it beside whatever the call changes. An ADR proves authority was checked, not that the tool ran - and vc_id joins on the credential, which every call shares, so this id is what makes the pairing exact. |
| Declared canonicalization | config=PolicyConfig(canonicalization_profile=..., require_canonicalization_profile=...); holders declare theirs via the agentcreds_canon_profile argument. A disagreeing profile -> canonicalization_profile_mismatch rather than possession_failed, so an interop defect is distinguishable from altered arguments. Both refuse - the distinction is diagnostic, which is why the declaration may be unauthenticated. |
| Freshness by consequence | config=PolicyConfig(max_age_by_autonomy={3: 5, 0: 300}) maps the credential's autonomy_level to a tighter max_age_secs. Only ever narrows: an entry longer than the global bound is clamped. |
| No blind retry | @idempotent(store) falls back to agentcreds_record_id when the caller supplies no key, so duplicate suppression does not depend on client cooperation. Not a substitute for reconciling an indeterminate post-dispatch outcome. |
| Horizontal scale | MCP: session_store=RedisSessionStore(redis). A2A: replay_guard=RedisReplayGuard(redis). |
Denial codes
Decision.code (and the AccessDenied.code raised by enforce) is one of:
no_active_challenge, malformed_presentation, possession_failed,
not_authorized, credential_invalid, credential_revoked,
principal_mismatch, argument_binding_required, untrusted_issuer,
replayed_presentation, approval_already_consumed,
canonicalization_profile_mismatch, access_denied.
approval_already_consumed is deliberately distinct from approval_required_denied:
the first means the evidence verified and satisfied policy but its reliance unit was
already spent (a replay against a valid human approval), the second that the evidence
was unsatisfactory. The ADR records the two as separate evaluation / admission
verdicts for the same reason - collapsed into one field they are indistinguishable, and
they call for opposite responses.
Status
The policy core, the MCP IdentityEnforcer, and the A2A A2AVerifier (including
the wire envelope and replay guard) are covered by the test suite, run against the
real agentcreds wheel. The FastMCP adapter is intentionally thin over the
enforcer. See examples/ for runnable end-to-end flows.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agentcreds_runtime-0.1.0.tar.gz.
File metadata
- Download URL: agentcreds_runtime-0.1.0.tar.gz
- Upload date:
- Size: 124.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
50d0d2a5c2aec151c0ff6fb35ec226ac926e33d38b903c34b4ecd1c39fa9104d
|
|
| MD5 |
791509d2990be50de3e121a3a3f4be60
|
|
| BLAKE2b-256 |
89fc2fc4be6c35c6928472b355356d6a3fea19a782d4611d7fc096dcc4c18f5f
|
Provenance
The following attestation bundles were made for agentcreds_runtime-0.1.0.tar.gz:
Publisher:
release-runtime.yml on agentcreds/agentcreds
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agentcreds_runtime-0.1.0.tar.gz -
Subject digest:
50d0d2a5c2aec151c0ff6fb35ec226ac926e33d38b903c34b4ecd1c39fa9104d - Sigstore transparency entry: 2754586511
- Sigstore integration time:
-
Permalink:
agentcreds/agentcreds@e4c47958fad0fecfe9d5e762736022cf1a6c49d4 -
Branch / Tag:
refs/tags/runtime-v0.1.0 - Owner: https://github.com/agentcreds
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-runtime.yml@e4c47958fad0fecfe9d5e762736022cf1a6c49d4 -
Trigger Event:
push
-
Statement type:
File details
Details for the file agentcreds_runtime-0.1.0-py3-none-any.whl.
File metadata
- Download URL: agentcreds_runtime-0.1.0-py3-none-any.whl
- Upload date:
- Size: 80.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
816875ffaa9d12acacb64c8d8ce175c8974a58be365096e793ab2e1b79d3fe51
|
|
| MD5 |
e21b591c9b6d7184d7f7166cef5dadbf
|
|
| BLAKE2b-256 |
d6f8ad189d0daf875e78d296021cc4c1bf8e6487837ddd6e7cd42dd693986326
|
Provenance
The following attestation bundles were made for agentcreds_runtime-0.1.0-py3-none-any.whl:
Publisher:
release-runtime.yml on agentcreds/agentcreds
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agentcreds_runtime-0.1.0-py3-none-any.whl -
Subject digest:
816875ffaa9d12acacb64c8d8ce175c8974a58be365096e793ab2e1b79d3fe51 - Sigstore transparency entry: 2754586534
- Sigstore integration time:
-
Permalink:
agentcreds/agentcreds@e4c47958fad0fecfe9d5e762736022cf1a6c49d4 -
Branch / Tag:
refs/tags/runtime-v0.1.0 - Owner: https://github.com/agentcreds
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-runtime.yml@e4c47958fad0fecfe9d5e762736022cf1a6c49d4 -
Trigger Event:
push
-
Statement type: