Skip to main content

attenu-guard

When one AI agent hands work to another, the frameworks we tested don't check the second one's permissions against the first one's. attenu-guard gives it the permissions you declare for its task, never more than the parent holds, and refuses the rest, inside your process, with no network call in the deny path. Every check(), allowed or denied, lands on a hash-chained log you can verify offline.

It is for people building agents that hand work to other agents.

Apache-2.0 · Python 3.9+ · zero runtime dependencies · TypeScript port: attenu-guard-ts, on npm as attenu-guard

attenu.io · Docs · Attenu Derive — what each agent may do, read from your app · Internet-Draft · Changelog

Where it came from: a sub-agent in Open SWE pushed a workflow file its parent was held on. Reported, fixed by LangChain the same day, advisory published. The write-up.

pip install attenu-guard
attenu-guard demo   # one delegation, one poisoned instruction, the export refused, the chain verified
from attenu_guard import Authority, Guard, RowLimit, AuthorityDenied

# The orchestrator holds broad authority. Every decision lands in log.jsonl.
parent = Guard.issue("orchestrator", Authority(
    scopes={"crm.*", "mail.send"}, ceilings=[RowLimit(100_000)], ttl=3600),
    audit_path="log.jsonl")

# It delegates a narrow task. The child gets the meet of what the parent held
# and what the task needs, computed and enforced, not suggested.
child = parent.delegate("summarizer", Authority(
    scopes={"crm.read"}, ceilings=[RowLimit(5_000)], ttl=900), task="summarize Q3")

print(child.check("crm.read", context={"rows": 4_200}))

try:
    child.enforce("crm.export")
except AuthorityDenied as denied:
    print(denied)

Prints:

Decision(allowed=True, reasons=(), determining_node='chain:n1', call_id=None)
denied: scope_not_granted requested=crm.export: scope 'crm.export' not covered by held scopes ['crm.read']

Then render the log as a delegation tree and verify its hash chain:

attenu-guard view log.jsonl
orchestrator  [root]
    summarizer  «summarize Q3»
        · crm.read ✓
        · crm.export ✗ scope_not_granted

audit chain: 4 events · verification: OK

The guard will not overwrite an existing ledger, so delete log.jsonl before running the snippet again.

check() returns a Decision with machine-readable reason codes for your audit trail, enforce() is the hard-stop gate that raises, and would_allow() is a dry-run that writes nothing. The export is refused because the sub-agent never held that permission, so an injected instruction has nothing to widen.

Adapters for 19 frameworks and the A2A protocol, each integrated unmodified, each with offline tests, and all but OpenHands and AstrBot with an offline demo (matrix); install one with pip install 'attenu-guard[<extra>]'. MCP is a recipe rather than an adapter: a server that verifies the delegation chain before it runs a tool (server verifier).

Enforced live on Google's published adk-samples customer-service and financial-advisor apps, denying a real model mid-run (evidence). The same adapters run live on CrewAI and LangGraph, on crews and graphs we built for a travel-booking domain rather than on third-party apps. A3 states that limit.

Have a bundle to check? pipx run attenu-guard verify bundle.json checks integrity, child ⊆ parent and containment from the file alone, no account, no network. The auditor's walkthrough has three sample bundles (clean, tampered, widened) and takes a minute.

Just want to see it run? Clone the repo (git clone https://github.com/attenu-io/attenu-guard && cd attenu-guard); no install needed, the examples bootstrap the src/ path themselves: python3 examples/poisoned_summarizer.py, and python3 tests/run_properties.py for the invariants.

attenu-guard demo — the poisoned summariser: one legitimate read allowed, the exfiltration blocked, the subtree revoked, the audit chain verified

An open enforcement layer for OWASP ASI07 (insecure inter-agent communication) and ASI08 (cascading failures): delegated authority stays inside the parent's limits, and every check the guard records stays verifiable offline.

What happens at a handoff today

In the frameworks we audited, the handoff itself is not something the system can see: identity tokens describe two parties — user and agent — so "child ⊆ parent" cannot be written down, and policy checks fire when a tool is invoked rather than at the moment permissions are passed down. Verified against released code, and pinned by tests that fail the day the behaviour changes:

System What it does at a handoff (framework rows verified against the released code — see docs/INTEGRATIONS.md; the identity and protocol rows are from their published specs)
OpenAI Agents SDK 0.22 passes the entire conversation to the sub-agent (Handoff.input_filter=None by default: "the new agent sees the entire conversation history"); no parent/child relation exists, so nothing checks child ⊆ parent
LangChain deepagents 0.7 a sub-agent's permissions replace the parent's rules entirely (graph.py) — a child can be granted what its parent is denied
Google ADK 2.7.1 disallow_transfer_to_peers is enforced on the legacy llm_flows path since 2.7.1 (#3850, fix fa18d26a) — but the 2.x default workflow path (workflow/utils/_transfer_utils.py, sibling case) still carries no check: on 2.7.1 the peer transfer goes through (pinned by tests/integrations/test_google_adk.py, which fails the day it stops). Either way ADK checks who may transfer; it does not check what authority passes, and no record exists to verify afterwards
CrewAI 1.15 a delegated coworker runs with its own full tool list; the tool-hook dispatcher swallows exceptions and runs the tool (fail-open) unless you raise its one blessed exception
AutoGen 0.7 Handoff carries target/description/message only; the receiver offers the model its own full tool list
Microsoft Entra the parent→child construct has two settings, all allowed or none; it cannot express child ⊆ parent (details)
MCP scope flow is accumulation-biased (step-up unions); the request carries no agent authority at all: CallToolRequestParams has name, arguments, meta, task and nothing that says which agent is calling with how much of it. Shipped here as a recipe rather than an adapter, a server that verifies the chain before it runs a tool (server_verifier)
A2A authenticates the hop, carries no delegated authority

attenu-guard makes child ⊆ parent a computed, enforced, offline-verifiable invariant — in your framework, in your process — no proxy, and no network call in the deny path.

What you get

  • Authority — an immutable capability (scopes + a list of typed, extensible Ceiling bounds + TTL) with meet, the lattice operation that can only ever shrink, and is_narrower_than, the provable subsumption relation.
  • Guard — issue() a root, delegate() a sub-agent with attenuated authority, check() → Decision, enforce() → raises, would_allow() → dry-run, revoke() a whole subtree.
  • Typed ceilings — RowLimit, SpendCap, CallLimit, EgressRank, Allow, Deny, Prefix, or your own via register_ceiling. Unknown ceiling types fail closed, never silently unbounded.
  • Chain invariants — depth, fanout, and aggregate budget ceilings; cascade revocation (revoke any node, every descendant denies immediately).
  • Hash-chained audit log — an open, versioned schema; attenu-guard view log.jsonl renders the tree and verifies it; tampering is provable offline. Every deny says why (disposition: held_pending_grant — waiting on a human · withheld_tier2 · unresolved — no authority known for the tool · out_of_authority — real over-reach), so "held" never reads as "denied"; evidence.export_bundle / verify_bundle / delegation_graph / denials give an auditor an offline-verifiable bundle and the folds a console renders. The bundle proves authorization and lifecycle, not execution or outcome — see what the evidence does not prove. AuditLog(sinks=…) copies entries to local sinks after the write (never the network) — sinks.SpoolSink is a bounded, fsync'd, resumable write-ahead spool carrying the ingest idempotency key (boot_id, chain_id, seq, hash); attenu_guard.identity gives a product an identity before it has a key (.attenu/product.json, per-process boot_id, assigned chain ids).
  • Wire format (attenu_guard.wire) — serialize/load the delegation chain as signed Delegation Tokens and verify child ⊆ parent offline, across services, with no authorization server in the path. This is the reference implementation of the current working Internet-Draft in docs/; 20 interop test vectors live in tests/vectors/ and ship inside the installed package as attenu_guard.vectors, so an implementation in any language can score its own verifier with nothing but pip install attenu-guard.
  • Scenario harness — declarative JSON/YAML authorization tests (attenu-guard scenarios file.json); see scenarios/.
  • Adapters — shipped, tested integrations for the major agent frameworks as attenu_guard.adapters.<name>: LangGraph, LangChain create_agent / deepagents, OpenAI Agents SDK, Google ADK, Pydantic AI, CrewAI, AutoGen, Microsoft Agent Framework, AG2, Claude Agent SDK, smolagents, AWS Strands, LlamaIndex, Semantic Kernel, Agno, Haystack, CAMEL-AI, OpenHands, AstrBot — and, for the A2A protocol, a client interceptor plus a guarded AgentExecutor that carries the attenuated chain across a hop between processes. Each has offline tests, and all but OpenHands and AstrBot have an offline demo under examples/integrations/; install one with pip install 'attenu-guard[<extra>]'. Hooks, versions and what each framework enforces itself: docs/INTEGRATIONS.md.

Observe mode: record first, gate later

You do not have to decide a policy to get value out of this. Point it at agents that already exist, let every call through, and read what they did. Nothing is refused, so there is nothing to get wrong.

from attenu_guard import Authority, Guard

# Wide on purpose: you are watching, not gating.
# Note the scope grammar: "*" is only valid as the final segment, so list the
# families your app uses. A bare "*" is rejected.
WIDE = Authority(scopes={"crm.*", "mail.*", "fs.*"}, ceilings=[], ttl=3600)

root  = Guard.issue("orchestrator", WIDE, max_depth=4)
child = root.delegate("summarizer", WIDE, task="summarize")

root.check("crm.read")            # allowed, and recorded
child.check("crm.read")
child.check("mail.send")

for e in root.audit_log().entries:
    print(e.get("event"), e.get("node"), e.get("scope"))
root chain:n0 None
spawn chain:n1 None
allow chain:n0 crm.read
allow chain:n1 crm.read
allow chain:n1 mail.send

Every hand-off and every call is on the hash-chained log, with the parent each child came from. Read it, and you can see whether a child ever made a call its parent would not have been allowed to make. Then narrow the authorities and the same code starts refusing.

On somebody else's app, you will not know the tool names in advance. The adapters take two hooks for exactly this: default_policy(tool_name) -> ToolPolicy and default_subagent_authority(name) -> Authority. When nothing was declared for a tool or a sub-agent, the hook's result is used as if it had been, so the call is authorized and recorded with a generated scope instead of denied. default_policy takes precedence over allow_unlisted, which silently passes unlisted tools without recording a scope for them.

What this does not do. A wide authority is a choice you are making, not a magic pass: a tool that is listed and falls outside the authority still denies. Observe mode changes what you declare, not how check() decides. And a call that never reaches the hook is neither recorded nor refused — see docs/THREAT-MODEL.md.

Canonicalization and compatibility

Versions 0.7 and later use RFC 8785 JCS for every signed or hash-linked artifact: Delegation Token protected headers and payloads, parent commitments, integrity seals, audit entries, anchors, and evidence bundles. Tokens and metadata-bearing artifacts emit "c14n":"JCS" as an informational label. Verifiers enforce JCS from canonical bytes and hashes, not from that label; non-canonical input, duplicate object member names, non-finite numbers, and lone UTF-16 surrogates are rejected.

This is a deliberate wire-format break from versions through 0.6.1. Current versions have no legacy or dual-format reader. Producers and verifiers in different languages must move together; the 20 packaged interop vectors pin the required bytes and rejection reasons.

Prove the safety claims yourself

git clone https://github.com/attenu-io/attenu-guard && cd attenu-guard
python3 tests/run_properties.py      # 4,000 random delegation trees per invariant, zero deps
python3 tests/red_team.py            # 17 adversarial attacks, black- & white-box; 0 must break
python3 examples/poisoned_summarizer.py
attenu-guard demo                    # needs pip install attenu-guard

The property suite asserts — over thousands of random chains — that attenuation never widens, holds transitively down a chain, that a revoked subtree authorizes nothing, and that audit tampering is detected. The red-team harness (see docs/RED-TEAM.md) additionally tries to break the protocol — privilege escalation, chain splicing, expired-grant reuse — and every genuine finding is fixed and pinned as a regression. If you can break one, the core claim is false; please tell us.

Writing

Runs, not claims, on what each agent framework does at a handoff — one post per finding, versions pinned, output pasted: attenu.io/blog (feed). Start with Does a sub-agent inherit its parent's permissions? Five frameworks, five answers.

Standards

The protocol is designed to be IETF-acceptable: it reuses the OAuth/JOSE stack (JWT, RFC 9396 authorization_details, DPoP, Token Status List) and invents only the one missing piece — cryptographically-linked, subsumption-enforced, offline multi-hop attenuation. See docs/STANDARDS-ALIGNMENT.md and the Internet-Draft draft-asor-wimse-agent-delegation-chain (published revision -01, individual submission, targeting the WIMSE working group; source in docs/).

What this is not

This library does not decide authority for you. You write the Authority for each delegation — or you let attenu-derive, the open engine, compute it from your app's declared structure (agents, roster, tools, what each task calls) and approve it before it is enforced. The library is the enforcement shim and the open schema; it is useful entirely on its own, forever, with no account and no network. Attenu also has a console for denials and decisions. It is not public yet, and it is never in the deny path.

attenu-guard only sees calls that go through its hook. A tool called some other way is neither refused nor logged, and an allow means authorized, not executed. See docs/THREAT-MODEL.md and the documented limitations in docs/RED-TEAM.md.

License

Apache-2.0. Contributions under the DCO. Security policy in SECURITY.md.

Metadata

Release files for attenu-guard 0.18.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for attenu-guard 0.18.0
File Size Uploaded
attenu_guard-0.18.0.tar.gz 2.6 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for attenu-guard 0.18.0
File Interpreter ABI Platform
attenu_guard-0.18.0-py3-none-any.whl Python 3 none any Details

Total release size: 3.1 MB

Release files / attenu_guard-0.18.0.tar.gz

Download URL attenu_guard-0.18.0.tar.gz
Size 2.6 MB
Tags Source
SHA-256 checksum
How to use checksums
2de59cc92c4beddbaae80f31ab654969e2e620c809d5f2a13ae447eca14a76f1
BLAKE2b-256 checksum
How to use checksums
bd6994f5dd01e3b540d56945a7240c1bb678e4cc98d6384984df5e9749e4cbb9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.

Transparency log

Release files / attenu_guard-0.18.0-py3-none-any.whl

Download URL attenu_guard-0.18.0-py3-none-any.whl
Size 410.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f3214951fc7705ce2cc047b7cf33fd489a27db686d628da741838cd1315ebfb3
BLAKE2b-256 checksum
How to use checksums
f5b9104b0f4be0ec5462e7e86b14ce74dcbb6aea77a4febec42133c744b8f77e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.

Transparency log

Release history Release notifications | RSS feed

0.20.0

2 release files

0.19.2

2 release files

This release

0.18.0 This release

2 release files

0.17.0

2 release files

0.16.2

2 release files

0.16.1

2 release files

0.10.0

2 release files

0.9.0

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page