Protocol-aware security testing for A2A and MCP.
Batesian is a single-binary CLI that tests deployed AI agent infrastructure with real protocol traffic. It exercises authorization, identity, discovery, task isolation, callbacks, sessions, and tool boundaries, then reports evidence as a confirmed exploit or a risk indicator. Table, JSON, and SARIF output support local testing and CI.
This README describes the current main branch, which may differ from the latest release. Use a release tag for its exact behavior and see the changelog for changes by version.
Caution
Run Batesian only against systems you own or have explicit written permission to test. Scans send attack-shaped traffic and some rules can change server-side state. Use scan --dry-run to inspect the request plan without sending traffic.
The recording uses a deliberately vulnerable local fixture. Recreate it with ./docs/render-demo.ps1 from PowerShell; the demo tape defines the capture. The script removes its containers and temporary binary when it finishes.
- 60 bundled rules: 22 A2A and 38 MCP checks focused on protocol-specific weaknesses.
- Active verification: rules evaluate live responses, state transitions, and out-of-band callbacks instead of relying on banners or static heuristics.
- Evidence-aware results: confirmed behavior is separated from indicators that require manual validation.
- Honest coverage: skipped, unsupported, and errored rules remain visible instead of being reported as clean.
- CI-ready output: SARIF 2.1.0 integrates with GitHub code scanning; JSON supports custom pipelines.
- Controlled execution: dry runs, exact rule filters, proxy support, strict configuration, and explicit approval for mutating MCP tools.
Download the latest archive from GitHub Releases.
| Platform | Architectures | Archive |
|---|---|---|
| Linux | x86_64, arm64 | .tar.gz |
| macOS | x86_64, arm64 | .tar.gz |
| Windows | x86_64 | .zip |
# Example: Linux x86_64
tar -xzf batesian_<version>_linux_x86_64.tar.gz
sudo install -m 0755 batesian /usr/local/bin/batesian
batesian --versionPublished releases include SHA-256 checksums, a Sigstore bundle for the checksum file, a per-archive SBOM, and SLSA build provenance. Check each release's SBOM for its format; the current release pipeline produces CycloneDX.
The current main branch requires Go 1.26.10 or newer. A release tag's go.mod specifies its own minimum:
go install github.com/calbebop/batesian/cmd/batesian@latest@latest installs the latest tagged release. To install the current main branch instead, use @main. Pin a reviewed commit or release tag in CI.
List the available checks, probe the target, review the attack plan, and then run a scan:
batesian rules --protocol mcp --severity critical,high
batesian probe --target https://agent.example.com --protocol a2a
batesian scan --target https://agent.example.com --dry-run
batesian scan --target https://agent.example.comprobe performs lightweight reconnaissance and produces table or JSON output. scan executes the applicable attack rules and supports table, JSON, or SARIF output.
Run a smaller rule set with protocol, severity, tag, or rule-ID filters:
batesian scan --target https://mcp.example.com \
--protocol mcp \
--severity critical,high
batesian scan --target https://agent.example.com \
--rule-ids a2a-push-ssrf-001,a2a-task-idor-001The bundled catalogs contain 22 A2A rules and 38 MCP rules. Every descriptor maps to compiled attack logic, CWE references, and remediation guidance.
| Area | Examples |
|---|---|
| OAuth and token handling | Audience and scope validation, DCR, token replay, metadata fetches, redirect confusion |
| Agent identity and discovery | Agent Card trust, JWS validation, host injection, extension downgrade, extended-card access |
| Task and tenant isolation | Task IDOR, cross-principal cancellation and enumeration, context fixation, delegation integrity |
| Cache scoping | Public MCP cache hints on principal-specific discovery and listings |
| Push notifications | Callback SSRF, control-plane binding, missing callback authentication |
| MCP authorization surfaces | Unauthenticated tools, resources, prompts, completions, and logging controls |
| Transport boundaries | Origin validation, header/body routing, protocol downgrade, SSE replay, session misuse |
| Tool and manifest integrity | Argument traversal, hidden characters, description injection, duplicate-name ambiguity, manifest drift |
| Deployment exposure | Shadow listeners, secret leakage, and known vulnerable component versions |
The suite is validated against vulnerable fixtures and third-party reference implementations. See validation results for expected findings and secure controls, and the protocol coverage matrix for compatibility gates and advisory limits.
Prefer the environment variable so credentials stay out of process arguments. Read it without echoing or recording the value in shell history:
read -rsp "Batesian token: " BATESIAN_TOKEN && echo
export BATESIAN_TOKEN
batesian scan --target https://mcp.example.comBATESIAN_TOKEN works with both scan and probe. A non-empty --token overrides it. scan checks the token in batesian.yaml before the environment; probe does not load YAML config.
read -rsp "OAuth client secret: " BATESIAN_CLIENT_SECRET && echo
export BATESIAN_CLIENT_SECRET
batesian scan --target https://mcp.example.com \
--token-url https://auth.example.com/oauth/token \
--client-id my-client \
--oauth-scopes mcp:read,mcp:writeBATESIAN_CLIENT_SECRET is used for the client credentials flow. A non-empty
--client-secret overrides it, but puts the secret in process arguments.
When MCP rules are selected, Batesian sends the target's canonical URI as the
OAuth resource in token requests. Use --oauth-resource https://mcp.example.com/mcp
if the resource server's canonical URI differs from --target. A2A-only scans
omit resource unless this flag is set. --oauth-audience remains a separate,
provider-specific parameter.
OAuth token responses must declare a Bearer token_type; other or missing
types are rejected before the token can be used in a scan.
Batesian prints the authorization URL to stderr for manual sign-in. It contains a one-time state value, so keep it private and avoid saving terminal logs during sign-in.
batesian scan --target https://mcp.example.com \
--auth-url https://auth.example.com/authorize \
--token-url https://auth.example.com/oauth/token \
--oauth-issuer https://auth.example.com \
--client-id my-client \
--oauth-scopes mcp:readThe PKCE flow sends the same resource in both the authorization and token
requests.
Set --oauth-issuer to the exact issuer in the authorization server metadata,
which may include a path. Batesian rejects callbacks with a missing or different
iss when this flag is set. It also rejects an iss received without a
configured issuer. Servers that omit iss remain compatible when the flag is
unset. This manual endpoint flow does not discover or validate authorization
server metadata; verify the issuer and endpoints before using it.
Cross-tenant and cross-principal checks require distinct identities. Add routing headers when the target derives tenant identity from a header rather than the token:
# batesian.yaml
target: https://agent.example.com
principals:
- name: tenant-a
token: "${TOKEN_A}"
tenant: A
headers:
X-Tenant-Id: A
- name: tenant-b
token: "${TOKEN_B}"
tenant: B
headers:
X-Tenant-Id: BSet TOKEN_A and TOKEN_B in the environment, then run batesian scan from
the config directory. For rules that use a single owner credential, also set
BATESIAN_TOKEN to the intended owner's token. An unset or empty principal
token variable stops the scan before requests are sent.
Some rules can prove that an operation succeeded but cannot know whether the deployment intended to allow it. Add exact expected_denials to batesian.yaml when you know the boundary:
target: https://agent.example.com
principals:
- name: tenant-a
token: "${TOKEN_A}"
tenant: A
- name: tenant-b
token: "${TOKEN_B}"
tenant: B
expected_denials:
- operation: a2a.tasks.cancel
caller: tenant-b
caller_tenant: B
owner: tenant-a
owner_tenant: A
- operation: mcp.prompts.get
caller: anonymous
- operation: mcp.tasks.get
caller: tenant-b
caller_tenant: B
owner: tenant-a
owner_tenant: A
- operation: mcp.resources.read
caller: tenant-b
caller_tenant: B
owner: tenant-a
owner_tenant: AAn observed access matching the operation and caller/owner names is marked confirmed; tenant fields, when supplied, must also match. No policy means the finding remains an indicator. This changes verdicts, not scan traffic or severity. A policy requires a target in the same config file and cannot be reused with a different --target. Each entry covers the entire operation, so do not use it when only selected prompts or tasks are private.
Supported operations are a2a.tasks.get, a2a.tasks.list, a2a.tasks.cancel, mcp.prompts.list, mcp.prompts.get, mcp.completion.complete, mcp.tasks.get, mcp.tasks.update, mcp.tasks.cancel, and mcp.resources.read. The A2A names cover both JSON-RPC and HTTP+JSON. A2A task reads use owner: default for the identity supplied with --token; task cancellation and modern MCP cross-principal checks use configured principal names. Error responses and unverified access are never promoted. A completed access subcheck may still be reported when another subcheck is incomplete.
The opt-in A2A gRPC rule tests task access using two configured principals. Set
--a2a-grpc-target grpcs://host:port to the card-advertised address, or add
a2a_grpc_target beside target in the config. Plaintext grpc:// is limited
to loopback. Other A2A rules use JSON-RPC or HTTP+JSON; see the
binding assessment for limits.
--dry-runrecords a redacted request plan and sends no network traffic. Header values, URL userinfo and queries, and request body values are hidden; hosts and paths remain visible. Requests that depend on live responses, acquired tokens, or callbacks cannot be expanded fully until a real scan.--proxy 127.0.0.1:8080routes scan and OAuth traffic through an intercepting proxy. Environment proxy variables are honored when the flag is absent; Go excludes loopback targets from environment proxies.--skip-tlsdisables certificate validation and should be limited to controlled labs or intercepting proxies.- Target-advertised OAuth endpoints are restricted to the target origin. Use
--oauth-origin https://login.example.comto approve an additional exact origin. - OAuth probes attempt to remove registered clients when RFC 7592 management is available. External OOB metadata checks leave the client registered so delayed callbacks remain observable; remove it manually after verification.
- Stateful rules may create A2A and MCP tasks, A2A push configurations, and OAuth clients. Scan reports list accepted and unverified objects with cleanup status even when no finding is produced. The push SSRF, callback-auth, and binding rules attempt to delete configurations after readback confirms the registered callback. External OOB probes retain registrations for manual verification. Other push configs may remain. Task cancellation is not deletion. Review leftover objects after a scan.
- The scope-confusion rule invokes a mutating tool only when its exact name is approved with
--mcp-scope-tool. This also asserts that the limited principal should be denied access to that tool. - Traversal and task rules require
--mcp-invoke-tool NAMEbefore calling a real MCP tool. Review the tool yourself; server-supplied read-only annotations are only hints. - The filesystem suffix check requires a target-bound
mcp_filesystem_suffix_probewith a disposable allowed directory and explicit approval ofget_file_info. It requests metadata for missing paths only. - The filesystem symlink check requires a target-bound
mcp_filesystem_symlink_probewith an operator-prepared link and outside file. It calls only approved metadata tools and never reads file contents. - The MCP subscription check runs only with a target-bound
mcp_subscription_probein the config. It calls the specified update tool once, so use a disposable resource and arguments. - The MCP cross-audience check needs a working target JWT and a target-bound
mcp_cross_audience_tokenissued for another resource. It uses read-only requests and reports accepted other-resource tokens as indicators. - The MCP session-principal check requires two bearer-token principals and a target-bound
mcp.sessions.useexpected denial. It compares read-only listings across stateful sessions and closes both sessions afterward. - JSON and SARIF may contain URLs, response snippets, and evidence. Treat them as sensitive security artifacts.
| Output | Intended use |
|---|---|
table |
Interactive review in a terminal |
json |
Automation and custom policy gates |
sarif |
GitHub code scanning and SARIF-compatible platforms |
For A2A probes, JSON agent_card is a parsed, redacted view, not the original response bytes. agent_card_provenance records the fetched URL and SHA-256 of those bytes.
A finding is marked confirmed when the rule proves a failed boundary, including observed access that matches an explicit expected denial. An indicator identifies a risky condition without claiming a policy violation.
scan exits non-zero for command-level failures, including invalid configuration or filters that select no rules. Findings and individual rule skips do not change the process exit code. Check rule outcomes in JSON or SARIF before treating a scan as complete; a zero exit code alone is not a clean bill of health. SARIF invocation metadata records completed, skipped, and errored rules and sets executionSuccessful to false when coverage is incomplete.
Use --scan-budget 300 to set an optional five-minute budget for rule execution. The default is unlimited. This is separate from the per-request --timeout and can also be set as scan_budget: 300 in the config; an explicit --scan-budget 0 disables a configured budget. OAuth setup and --dry-run are outside the budget. Rules cooperate with cancellation, and bounded cleanup may continue after the deadline. If the budget expires, completed findings remain in the report, interrupted and unstarted rules are marked skipped, JSON sets scan_budget_exhausted, and SARIF sets scanBudgetExhausted in invocation properties. Review incomplete coverage before relying on the result.
The following workflow uses v1.8.1 coverage metadata to upload A2A findings and fail when its card check does not complete. The target must serve an Agent Card.
name: Batesian
on: workflow_dispatch
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.26.10'
- run: go install github.com/calbebop/batesian/cmd/batesian@v1.8.1
- name: Scan
env:
AGENT_TARGET_URL: ${{ vars.AGENT_TARGET_URL }}
run: |
batesian scan --no-config --target "$AGENT_TARGET_URL" \
--protocol a2a \
--rule-ids a2a-card-trust-001 \
--output sarif > results.sarif
- uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
if: always()
with:
sarif_file: results.sarif
- name: Require complete coverage
run: |
jq -e '
(.runs | length) == 1 and
(.runs[0].invocations | length) == 1 and
(.runs[0].invocations[0] |
.executionSuccessful == true and
.properties.rulesSelected > 0 and
.properties.rulesCompleted == .properties.rulesSelected and
.properties.rulesSkipped == 0 and
.properties.rulesErrored == 0)
' results.sarifSet the AGENT_TARGET_URL repository variable before running this workflow.
Update the pinned release after review. This example uses a manual trigger;
keep credentialed scans off pull-request code. Select only rules whose
authentication, identity, and callback prerequisites the job can provide;
skipped or errored rules are not evidence of a clean target. A two-principal
example is in CI/CD integration.
Generate an annotated configuration file with:
batesian initRun init from the project directory. It creates batesian.yaml there and will not overwrite an existing file; it does not print the YAML to stdout. Explicit or discovered configuration must be readable, contain one YAML document, and use known keys.
scan searches the current directory and its parents for a config file and reports the selected path before OAuth or scan traffic begins (stderr for JSON and SARIF). Pre-scan errors also name the selected file. Use --config path to select a file or --no-config to use only flags and environment variables. The two flags cannot be combined. --no-config affects file discovery, not protocol discovery.
--rules-dir supplements the built-in catalog with strict YAML descriptors. Descriptors select compiled executors and provide report metadata; they cannot define arbitrary network behavior. A rule pack loads atomically, so one unreadable, invalid, oversized, or unsupported descriptor stops the command before scan traffic.
Rules, fixes, and documentation are welcome. See CONTRIBUTING.md for the rule architecture, test requirements, and local workflow. Vulnerable fixtures and their port assignments are documented in testdata/README.md.
Report vulnerabilities in Batesian privately according to SECURITY.md.
- A2A Protocol Specification
- MCP Authorization Specification
- MCP Security Best Practices
- Unit 42: Agent Session Smuggling in A2A Systems
- OWASP GenAI Security Project
Apache 2.0. See LICENSE.
