Compound-path and cross-release analysis of what an AI agent is permitted to do
Project description
AgentMandate
What is your AI agent actually allowed to do?
A Python library and CLI that reads a short description of your agent's tools, called a manifest, and finds the limits it can slip past by combining actions that are each permitted on their own. It also tells you when a release widened what the agent can reach, before that release ships.
Policy engines decide one call at a time. This looks at the whole tool graph offline, so it catches the gaps that only appear across a sequence.
| What you run | Question it answers |
|---|---|
| Cedar, OPA, AgentCore Policy, AgentWard | May this agent make this call, right now? |
| AgentMandate | What can it reach by combining permitted calls, and did this release widen that? |
| AgentVerity | Were the reviewed decision routes exercised repeatably? |
| Your tests and runtime traces | Did the tools execute and the declared controls hold? |
Imagine a payment-dispute agent that can open a case and issue a
human-approved refund. Each refund is capped at 500 GBP per case, and the
whole run is capped at 500 GBP. Release 2 adds one read-only tool:
search_cases.
That tool spends nothing. It does, however, let the agent get hold of more cases, and the 500 GBP cap is measured per case. Two separately valid refunds become reachable under the manifest, so reachable extraction doubles to 1,000 GBP while every individual call still looks permitted.
AgentMandate builds the tool graph and answers the questions a per-tool check cannot:
- What legal sequence breaks a limit?
mandate reachreturns the path, not a risk score. - What did this release make possible?
mandate diffcompares effective authority, not configuration text. - Does runtime evidence match the declaration?
mandate verifyfails closed when a required control field is absent. - What must the tests exercise?
mandate obligationsturns reachable authority into reviewable test obligations. - Which compound risks need scenarios?
mandate scenariospreserves each counterexample as a neutral test skeleton without inventing an agent prompt.
Alpha. Apache-2.0.
See the whole thing working: agent-release-gate takes one agent from Python source to a gate decision, with every command in this README run against it. Six checks, one exit code, offline.
Try it
pip install "agentmandate[yaml]"
The repository includes that payment-dispute agent. In release 1, refunds are capped at 500 GBP per case, every refund requires human approval, the whole run is capped at 500 GBP, and nothing spends through a service account. It passes:
$ mandate lint examples/dispute-resolver.yaml
no single-manifest findings
$ mandate reach examples/dispute-resolver.yaml
no reachable breach within depth 8. 3 tool(s) reachable, most extractable 500 GBP
Release 2 adds one read-only tool so the agent can find existing cases instead of always opening a new one. A conventional review sees no write effect:
- name: search_cases
effect: read
produces: case
unbounded: true
$ mandate lint examples/dispute-resolver-v2.yaml
no single-manifest findings
$ mandate reach examples/dispute-resolver-v2.yaml
BREACH cumulative value 1000 GBP exceeds limit 500 GBP
1. open_case(case#1)
2. search_cases(case#2)
3. issue_refund(case#1, 500 GBP)
4. issue_refund(case#2, 500 GBP)
The lint is still clean because no single tool is wrong. The refund ceiling is measured against one case, and the new tool lets the agent obtain fresh cases, so the per-case ceiling no longer bounds the whole run. In CI:
$ mandate diff examples/dispute-resolver.yaml examples/dispute-resolver-v2.yaml
authority diff v1 -> v2
+ tool: gained search_cases
+ extractable value: 500 -> 2000 GBP
+ reachable breach: gained cumulative_value
verdict: WIDENING
a widening change needs named review before release
Exit code 1. The configuration change was read-only. The effective authority was not.
Why a config diff is not an authority diff
A pull request shows what somebody typed. It does not show what the agent can now do, because reachability composes and text does not. Adding a read tool, relaxing an enum in a schema, or removing one precondition can each open a path that did not exist, and none of them look like a permission change in review.
That is the same reason git diff never replaced type checking. The question is not what changed, it is what the change makes possible.
Starting from an existing agent
You do not have to write the first manifest by hand. From agent code:
$ mandate scan --source src/agent --agent dispute-resolver > mandate.yaml
That reads @tool, @function_tool, and @ai_function declarations and the
tools=[...] list they are passed to. It is a static read: nothing is
imported, nothing is executed, and the framework need not be installed.
One manifest describes one agent, so a source building two of them is refused
until you name the one you mean with --binding. A union would let reach
compose a path across tools that never share a run. What the read could not
enumerate is reported rather than dropped, because a tool missing from today's
manifest shows up in tomorrow's diff as authority that was never added. See
docs/inventory.md.
Or from an MCP catalogue:
$ mandate scan examples/mcp-tools.json --agent dispute-resolver > mandate.yaml
The skeleton is loadable straight away, and every judgement the catalogue could not supply is marked:
- name: issue_refund
# REVIEW: effect guessed from the name. read | write | irreversible
effect: irreversible
# REVIEW: does this spend the caller's authority or a service account?
principal: caller
requires: [case]
# REVIEW: amount looks like a value argument. A ceiling needs scope_key too.
# value_arg: amount
# scope_key: case
# ceiling: { amount: 0, currency: GBP }
requires_approval: true
Unrecognised verbs are proposed as irreversible, because under-calling an
effect is the more expensive mistake.
In a pull request
- uses: mrwersa/agentmandate@v0.8.0
with:
manifest: mandate.yaml
baseline: mandate-released.yaml # optional: did this widen authority?
source: src/agent # optional: has the manifest drifted?
The counterexample lands in the job summary as a rendered graph rather than a
log line, and sarif-file is an output you hand to
github/codeql-action/upload-sarif so it annotates the diff.
Uploading is deliberately your step, not the action's: it needs
security-events: write, and an action that asks for a token permission it
could avoid is one more reason for a security team to say no.
fail-on: never reports without blocking, which is how to turn this on over
an existing repository without stopping everyone on the first day.
Only the checks you give inputs for run. A manifest alone is enough for lint
and reach.
Findings where you already look
# .github/workflows/agent-authority.yml
- run: mandate reach mandate.yaml --sarif > authority.sarif
continue-on-error: true # let the upload happen, then fail the gate
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: authority.sarif
- run: mandate reach mandate.yaml # the actual gate
The breach is then annotated on the pull request that introduced it, rather
than sitting in a log somebody has to open. Findings are error, not
warning: they already exit non-zero, and a UI that disagrees with the exit
code is how a gate stops being believed.
--graph emits Mermaid, which GitHub renders inline in a comment:
flowchart LR
s0(["search_cases<br/>case#1"])
s1(["search_cases<br/>case#2"])
s0 --> s1
s2["issue_refund<br/>case#1 · 500 GBP"]
s1 --> s2
s3["issue_refund<br/>case#2 · 500 GBP"]
s2 --> s3
breach["cumulative value 1000 GBP exceeds limit 500 GBP"]
s3 --> breach
One node per step, not per tool, because the same tool called twice on different bindings is usually the whole point. Rounded is a read, boxed changes something.
Keeping the manifest honest
A manifest is a claim. Two things quietly falsify it: somebody adds a tool to the agent and nobody edits the YAML, or a signature changes and the argument a ceiling was counted against stops existing.
$ mandate drift mandate.yaml --source src/agent
UNDECLARED issue_credit_note
the agent is given this tool and the mandate does not declare it, so
every reach and diff run so far analysed a smaller graph than the real one
ARGUMENT issue_refund
value_arg names 'amount', which is not an argument this tool takes any
more (it takes: case_id, total, currency). A ceiling counted against an
argument that does not exist is not a ceiling.
REMOVED close_case
the mandate declares this tool and the agent is not given it in source,
so the analysis is defending authority nobody has.
The second finding is the one worth having. The manifest still parses, reach
still runs, and the ceiling counts against nothing.
A tool list the read cannot enumerate, such as tools=load_tools(), is itself
a finding. Reporting no drift from evidence that could not see the whole list
would be the false assurance this package exists to prevent.
The manifest
Reachability needs three facts per tool that an ordinary tool schema does not carry: the effect class, which argument spends value, and which scope the ceiling is measured against.
version: 1
agent: dispute-resolver
identity: spiffe://bank/agents/dispute-resolver
limits:
total: { amount: 500, currency: GBP }
depth: 8
tools:
- name: open_case
effect: read # read | write | irreversible
produces: case # mints a binding of scope "case"
- name: issue_refund
effect: irreversible
principal: caller # caller | service
requires: [case]
value_arg: amount
scope_key: case # the ceiling is per case
ceiling: { amount: 500, currency: GBP }
requires_approval: true
Asking for full preconditions and postconditions would be more expressive and would not get written. This is the minimum that makes compound analysis possible.
A ceiling is the maximum cumulative value one tool may spend against one binding of its scope_key. unbounded: true marks a tool that can be called repeatedly to mint fresh bindings, which is what turns a per-scope ceiling into no ceiling at all.
Commands
| Command | What it does |
|---|---|
mandate scan |
Derives a manifest skeleton from agent source (--source) or an MCP tools/list catalogue, with a REVIEW marker on every guess |
mandate drift |
Compares the declared mandate against the agent's source and fails when the two have separated |
mandate lint |
Single-manifest control checks: separation of duties, ungated irreversible effects, service-account principals, ceilings scoped to nothing |
mandate reach |
Bounded search for a legal call sequence that breaches a limit, reported as a counterexample |
mandate diff |
Effective-authority comparison of two manifests, including limits, preconditions, approvals, effects, and scope minting. --record emits a change record |
mandate verify |
Replays recorded tool calls against the manifest and fails closed when evidence required by a declared control is missing. Reads OpenTelemetry traces with --otel |
mandate obligations |
Derives reviewable test obligations from reachable authority, and renders reviewed ones as an AgentVerity decision suite |
mandate scenarios |
Exports reachable breach paths with blank environment, agent-input, and expected-control fields for human review and execution by an external evaluation harness |
Every analysis command takes --json and exits non-zero on a finding, so they drop into CI unchanged. scan writes a manifest to standard output and is a one-off, not a gate.
| Exit code | Meaning |
|---|---|
0 |
Clean |
1 |
A finding: lint error, reachable breach, widening diff, or a non-conformant replay |
2 |
Usage error or a malformed manifest |
In a pull request, the useful gate is diff against the manifest on the default
branch, so a change that widens authority stops and gets a named reviewer:
- name: Authority diff
run: |
git show origin/main:mandate.yaml > /tmp/released.yaml
mandate diff /tmp/released.yaml mandate.yaml
verify is what keeps the rest honest. A manifest nobody checks is a wish, and the declaration drifts from the implementation the moment someone ships a connector change.
For a spending tool, each trace record must carry the scope, value, currency,
approval state, and executing principal. Missing or malformed control evidence
does not pass as an empty value.
From authority to evaluation
AgentMandate produces two different test inputs:
obligationsnames consequential decision points that reviewed bounded decision tests should reachscenariospreserves compound counterexample paths that a multi-step scenario test should attempt
It does not execute either test. Promptfoo, LangSmith, AgentCore Evaluations, pytest, or an internal harness owns behaviour and outcome grading. AgentVerity can qualify the repeated bounded decisions after correctness passes.
reachability -> reviewed obligations and scenarios -> external evaluation
^ |
| v
manifest <- reviewed production incidents <- runtime policy and traces
Read the complete evaluation-loop workflow.
Where it fits, and what already exists
This is analysis, not enforcement. It runs in CI against a manifest, it does not sit in the request path.
| Tool | What it does | Relationship |
|---|---|---|
| Policy in Amazon Bedrock AgentCore | Evaluates all applicable Cedar policies for each gateway tool invocation, with default-deny, forbid-wins, and analysis that flags always-allow and always-deny policies | Enforces each invocation. Its documented analysis is policy-level, not a model of a sequence of permitted calls |
| AgentWard | Runtime proxy enforcing policy per call, diffs two policy files | Enforces. Diffs declared text rather than reachable authority |
| AgentShield | Scans agent configuration and MCP servers, drift gate over findings | Scans. Drift is over finding counts, not permission direction |
| AgentGuard | Attribute-based access control for tool calls | Enforces |
| OPA, Cedar | Decide one authorisation at a time | Enforces |
Use those to enforce. AgentMandate is the offline half: it analyses sequences of individually permitted calls and compares effective authority across releases.
If you already run AgentCore Policy, the gap is specific. The policy engine answers "may this principal invoke this tool now" by evaluating all applicable policies, and its documented analysis catches policy-level problems such as an unconditional allow. It does not model whether four separately permitted calls compose into a 1,000 GBP breach or whether a release widened what the agent can reach. AgentMandate is vendor-neutral and runs in CI before deployment, so it complements the gateway rather than duplicating it.
The closest prior art in a neighbouring domain is IAM Access Analyzer, which derives reachable access from policy by automated reasoning rather than waiting for a log event. This is that idea pointed at agent tool graphs.
The lint command deliberately overlaps the scanners above. A tool that reported only compound findings would need one of them running alongside it to be usable at all.
Scope
Read every finding as permitted by the reviewed manifest within this bounded abstraction. It is not proof that the model will choose the path or that an undeclared downstream invariant will accept it.
What this does not do, on purpose:
- No enforcement. No proxy, no runtime interception, no blocking.
- No data-flow reachability. Finding that a read tool feeds an exfiltration path needs taint labels the manifest does not carry. Cumulative value and scope minting are what the current model supports honestly.
- No model behaviour. Whether the agent would take a path is a different question from whether it may. This measures permission.
- No inference of the fields that matter.
mandate scanreads agent source or an MCP catalogue and writes the skeleton, but it cannot know whether an effect is reversible or what a ceiling is measured against. It guesses conservatively and marks every guessREVIEW. Extract then annotate, never extract and trust.
Search is bounded by limits.depth. No breach at depth 8 is not proof that none exists at depth 20, and the report says when it truncated.
Documentation
- DESIGN.md — the authority model, why the search is shaped this way, and what was left out
- docs/evaluation-loop.md — how authority analysis, scenario evaluation, runtime policy, and production feedback remain distinct
- docs/test-obligations.md — decision-point obligations and the AgentVerity bridge
- CONTRIBUTING.md — branch and review workflow
- SECURITY.md — reporting, and what a manifest may contain
- STABILITY.md — what is guaranteed before 1.0
- ROADMAP.md — adoption work, planned model extensions, and the path to 1.0
- CHANGELOG.md
Development
python -m pip install -e ".[dev]"
python -m pytest -q
python -m pytest -q --cov=agentmandate --cov-fail-under=100
ruff check .
main is protected. Every change lands through a pull request with CI green.
Status
Alpha. The version is in the badge above and on PyPI, so it is not repeated here where it would go stale. The authority model is the part most likely to change, because it has not yet been pointed at enough real tool graphs to know where it is too coarse. Issues describing a graph it models badly are the most useful thing you can file.
Project details
Release history Release notifications | RSS feed
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 agentmandate-0.8.0.tar.gz.
File metadata
- Download URL: agentmandate-0.8.0.tar.gz
- Upload date:
- Size: 143.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0c345711b7ca9f334c10b917000ac1d0c0a12bae27a9f7145dcda3c3d4463cc0
|
|
| MD5 |
1ca9e5ffa8d9f45e5374d298bed0d5e5
|
|
| BLAKE2b-256 |
c8970998e811df6e1f9c2c88b4936af6ac2eae2f305dce3b8652d524a0d61eec
|
Provenance
The following attestation bundles were made for agentmandate-0.8.0.tar.gz:
Publisher:
release.yml on mrwersa/agentmandate
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agentmandate-0.8.0.tar.gz -
Subject digest:
0c345711b7ca9f334c10b917000ac1d0c0a12bae27a9f7145dcda3c3d4463cc0 - Sigstore transparency entry: 2309463291
- Sigstore integration time:
-
Permalink:
mrwersa/agentmandate@afb8e6f7bde054557b5f67d8bf0284193ab9b2d2 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/mrwersa
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@afb8e6f7bde054557b5f67d8bf0284193ab9b2d2 -
Trigger Event:
workflow_run
-
Statement type:
File details
Details for the file agentmandate-0.8.0-py3-none-any.whl.
File metadata
- Download URL: agentmandate-0.8.0-py3-none-any.whl
- Upload date:
- Size: 69.3 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 |
021ebb7fd637db7488b8c660f948e4e07a3f39676314e4040d0b7fe5405ae82a
|
|
| MD5 |
c4028fe3251dd7bb3aa12c61cdc4d1ed
|
|
| BLAKE2b-256 |
9b98776ccefb5d13e9638d0f86d94a4c4e555a4ee20603e3a8fdd6cd5ba32cc7
|
Provenance
The following attestation bundles were made for agentmandate-0.8.0-py3-none-any.whl:
Publisher:
release.yml on mrwersa/agentmandate
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agentmandate-0.8.0-py3-none-any.whl -
Subject digest:
021ebb7fd637db7488b8c660f948e4e07a3f39676314e4040d0b7fe5405ae82a - Sigstore transparency entry: 2309463450
- Sigstore integration time:
-
Permalink:
mrwersa/agentmandate@afb8e6f7bde054557b5f67d8bf0284193ab9b2d2 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/mrwersa
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@afb8e6f7bde054557b5f67d8bf0284193ab9b2d2 -
Trigger Event:
workflow_run
-
Statement type: