bastionprobe
Pentest for AI agents. Fire indirect prompt-injection payloads at your agent and get a report of which ones land. Every payload that gets through is a hole a runtime guard should close.
The offensive twin of agentbastion (the runtime firewall). agentbastion blocks attacks in production; bastionprobe launches them before you ship. Scan with bastionprobe → find holes → plug them with agentbastion → re-scan → green.
bastionprobe = Burp Suite for agents (red team, pre-ship, on demand)
agentbastion = WAF for agents (blue team, production, always on)
Part of the bastion trilogy — attack (bastionprobe), prevent (agentbastion), investigate (bastiontrace) — all sharing one payload corpus, bastioncorpus.
The wedge: indirect prompt injection
The hardest injection to defend and the one most agents are blind to: the payload doesn't come from the user — it rides inside a tool result the agent reads mid-task (a fetched web page, a support ticket, a DB row, a file). If the agent treats that data as instructions, you're owned.
bastionprobe plants a payload in a tool output, runs your agent for one turn, and scores whether it obeyed:
- canary check — the payload tells the agent to emit a unique random token. Token shows up in the reply ⇒ injection landed. (Fresh token per run — can't be faked or pre-trained.)
- tool check — the payload tells the agent to call a forbidden tool
(
send_email,delete_file,http_post, …). Agent calls it ⇒ landed.
Install
pip install bastionprobe
load_payloads() sources its payloads from
bastioncorpus (the shared trilogy
corpus, pulled in as a dependency); a bundled payloads.jsonl snapshot is the
fallback. Pass an explicit path to load_payloads(path) to fire your own set.
Quick start
Zero setup — fire at the bundled demo targets:
bastionprobe run # vulnerable demo: 12/12 land
bastionprobe run --target bastionprobe.demo:safe_agent # safe demo: 0 land
Fire at your agent — implement one function (see
examples/my_agent.py):
from bastionprobe import AgentResponse
def agent(messages, tool_outputs): # tool_outputs = {tool_name: output}
reply, tools_called = run_your_agent(messages, tool_outputs)
return AgentResponse(text=reply, tool_calls=tools_called)
bastionprobe run --target examples.my_agent:agent --out results.jsonl
Models are non-deterministic — the same payload lands on one run and blocks on the next. Fire each payload N times for the stable land rate:
bastionprobe run --target examples.my_agent:agent --runs 5 # shows e.g. [FAIL 3/5]
landed still counts a payload that got through even once (a hole that opens
sometimes is still a hole), but the N/M rate is what you compare between runs.
Exit code is non-zero when any payload lands, so you can gate CI on it:
bastionprobe run --target examples.my_agent:agent || echo "agent is vulnerable"
Fire at a real Claude agent
A built-in adapter turns an Anthropic client into a target. It stands up a small
tool-using agent, feeds each payload back as the result of the agent's
read_document tool, runs one real model turn, and reports what Claude did —
text plus any tools it tried to call.
pip install "bastionprobe[anthropic]"
export ANTHROPIC_API_KEY=...
from anthropic import Anthropic
from bastionprobe import load_payloads, run_suite, make_anthropic_target
from bastionprobe.report import render
target = make_anthropic_target(Anthropic(), model="claude-sonnet-4-5")
print(render(run_suite(target, load_payloads())))
Set model= to the model your production agent runs — that's the behavior you
care about. system= and tools= are overridable so you can mirror your real
agent's persona and toolbox instead of the defaults. See
examples/anthropic_scan.py.
Fire at a GPT agent
Same contract, other vendor. make_openai_target wraps an OpenAI client and
reuses the exact demo agent (system prompt + tools) as the Anthropic adapter, so
a cross-vendor comparison measures the models, not two different harnesses.
pip install "bastionprobe[openai]"
export OPENAI_API_KEY=...
from openai import OpenAI
from bastionprobe import load_payloads, run_suite, make_openai_target
from bastionprobe.report import render
target = make_openai_target(OpenAI(), model="gpt-4o")
print(render(run_suite(target, load_payloads(), runs=5)))
Cross-model matrix
Fire the same suite at several models and compare land rate by tactic — turns a single-model result into a comparison:
bastionprobe matrix --models claude-opus-4-5,claude-sonnet-4-5,claude-haiku-4-5 --runs 5
cross-model land rate by tactic:
tactic claude-opus-4-5 claude-sonnet-4-5 claude-haiku-4-5
data-field ...% 90% ...%
destructive ...% 100% ...%
egress-overt ...% 0% ...%
egress-legit ...% 0% ...%
OVERALL ...% 69% ...%
A bad model id shows err for its column — the rest of the matrix still runs.
The matrix is vendor-agnostic: mix Anthropic and OpenAI targets in one grid to
ask whether a finding holds across vendors (does the egress refusal survive on
GPT?). See examples/cross_model.py (Anthropic) and
examples/cross_vendor.py (Anthropic + OpenAI).
Injection vector. Both adapters take vector=: tool_result (a real
tool-result block — authentic, native) or user_message (a labeled user turn —
portable; strict providers like Gemini 3.x reject a fabricated tool-call). For a
fair cross-vendor comparison, run the same vector on every model
(--vector user_message for the CLI matrix, or the parity default in
cross_vendor.py) — otherwise a difference could be the vector, not the model.
Co-evolution director (experimental — Phase 1)
bastionprobe.coevo adds C, a research director for a red-team/blue-team
co-evolution loop. It does not attack; it steers the search. Given a round of
AttackResults, Director.round(...) classifies each attack into a behavioral
cell (framing × surface × language × action, derived from payload metadata),
maintains a MAP-Elites quality-diversity archive (one best elite per cell),
scores novelty (equal weight to success — so B is pushed toward dissimilar
attacks, not just the boundary of A), steers B toward empty cells, flags mode
collapse, picks a Hall-of-Fame replay set, and diagnoses real vs illusory
progress against a frozen benchmark A is never hardened against.
from bastionprobe import load_payloads, run_suite, Director
from bastionprobe.demo import vulnerable_agent
results = run_suite(vulnerable_agent, load_payloads(), runs=2)
report = Director().round(results, benchmark_score=0.62)
print(report.to_json()) # classification, direction_for_b, replay_for_a, ...
The full autonomous loop — B generates toward the director's target cells, fires at A (a defender wrapped as a Target), C steers the next round, A hardens against what landed, A is re-scored on the frozen benchmark; it stops when progress goes illusory (benchmark stalls while B keeps winning):
bastionprobe coevolve --rounds 5 --runs 2 --per-cell 2
Everything is pluggable (defender = block_fn(text)->bool, generator, harden_fn).
Real A that hardens. --defender agentbastion wires agentbastion in as A: it
guards the poisoned tool result, and every round it ingests the attacks that
landed as SemanticDetector templates — so it blocks them and their paraphrases
next round. With a real embedder this is genuine hardening:
bastionprobe coevolve --defender agentbastion --rounds 6
# round 1: benchmark=0.000 (base A catches none of the held-out attacks)
# round 2: benchmark=0.800 (A hardened on what landed -> catches paraphrases)
# round 4: real_progress=False (benchmark plateaus while B keeps winning)
The last line is the point: the frozen benchmark (held out of hardening) tells
real robustness gains from illusory ones. Two embedders ship: hashing_embedder
(zero-dependency bag-of-words, the default) and sentence_transformer_embedder
(real dense embeddings — generalizes across whole attack families, not just token
overlap):
pip install "bastionprobe[embeddings]"
bastionprobe coevolve --defender agentbastion --embedder sentence-transformer
The threshold auto-pairs (0.4 hashing, 0.6 dense; override with --threshold).
The demo defender (default) instead memorizes — a deliberately simple ceiling.
B that invents. By default B recombines fixed axis fragments; --generator llm
has a model write a fresh injection per target cell, so B reaches novel strategy
families the templates can't express. We stamp the cell's axis metadata and
enforce its scoring constraint (canary token / tool name), so cell_of still
matches — the model supplies only the creative text.
bastionprobe coevolve --defender agentbastion --generator llm --gen-model claude-haiku-4-5
(For authorized testing: the generated strings are planted in the agent-under- test's own tool results to measure and harden its defenses — the same class of test string as the bundled payloads.)
Encoded attacks (--encode, encoding-bench)
Obfuscation jailbreaks hide the payload in an encoding the model reads but a filter does not: binary, base64, hex, rot13, leetspeak and more. Two ways to test for them.
Live, against your agent. Fire every payload again in each encoding, plus the corpus's
own encoded rows. Land rate is broken down per encoding (tactic <tactic>+enc-<name>):
bastionprobe run --target my_agent:agent --encode base64,binary,rot13 --runs 3
bastionprobe run --encode all # base64 base64url base32 hex binary ascii85 base85 morse
# percent escape tags rot13 leet reversed spaced
The canary is filled in before encoding. --encode multiplies the run (payloads x
encodings), so it is opt-in: a default run never fires encoded rows.
Offline, against your defenders. No model and no API key. The result is deterministic. It shows the share of attacks (and of benign rows) that agentbastion and bastionsupply flag, per encoding:
bastionprobe encoding-bench # table
bastionprobe encoding-bench --json > bench.json
The 2026-10-01 baseline (bench/BASELINE-2026-10-01.md), taken before any decode-and-rescan detector existed:
- Unencoded attacks: 74% caught by agentbastion, 88% by bastionsupply.
- Encoded attacks: 0 to 3%, whatever the encoding.
Add --defenders agentbastion,supply,supply+transforms to measure the opt-in whole-text
views (rot13, leet, reversed, spaced) that bastiongate and bastionmesh run with
decode_transforms: true (bench/TRANSFORMS-2026-10-02.md).
Rerun it after changing a detector, and put both numbers in the changelog.
Close the loop: harden the shield
The sword's whole point is to make the shield better. harden turns landed
findings into defenses agentbastion
loads directly:
bastionprobe run --target examples.my_agent:agent --out results.jsonl
bastionprobe harden results.jsonl # -> bastion_hardening/{policy.yaml, injections.jsonl}
policy.yaml— every tool an injection got to call, as a deny-list for agentbastion'sToolPolicy(load_policy).injections.jsonl— every injection string that worked, in agentbastion's corpus schema. Load them asSemanticDetectortemplates and embedding similarity blocks those attacks and their paraphrases.
from agentbastion import Firewall, load_policy
from agentbastion.semantic import SemanticDetector
from agentbastion.inbound import InboundGuard
import json
templates = [json.loads(l)["text"] for l in open("bastion_hardening/injections.jsonl")]
fw = Firewall()
fw.tool_policy = load_policy("bastion_hardening/policy.yaml") # deny what got called
fw.inbound = InboundGuard(detectors=[SemanticDetector(embed_fn, templates=templates)])
Then re-scan: scan → harden → load → re-scan, and each round the shield learns exactly what the sword got through.
The target contract
A target is any callable (messages, tool_outputs) -> AgentResponse. bastionprobe
poisons one value in tool_outputs, runs your agent for one turn, and reads
back the reply text plus the names of any tools it called. It never looks inside
the agent — it measures behavior. Report what your agent actually did.
Shared corpus with agentbastion
Payloads use the same category taxonomy as agentbastion's
benchmark/corpus.jsonl (indirect_injection, exfiltration,
direct_injection, …). Same strings, opposite direction: agentbastion reads a
row as "block this", bastionprobe reads it as "fire this". A finding here maps
directly to a rule there.
Status
Alpha. One attack class (indirect injection via tool output), 16 payloads across
5 languages, tuned against a live model. Payloads are tagged with a framing
tactic, and multi-run scans report a land rate by tactic breakdown — the
signal that scales as the set grows. See FINDINGS.md for measured
results (e.g. Claude refuses data egress but readily deletes local data). Reserved for later: direct injection, RAG poisoning, multi-agent
trust escalation, LLM-judge scoring. Payload PRs welcome.
License
MIT.
Metadata
Release files for bastionprobe 0.19.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| bastionprobe-0.19.0.tar.gz | 66.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| bastionprobe-0.19.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 123.8 kB
Release files / bastionprobe-0.19.0.tar.gz
| Download URL | bastionprobe-0.19.0.tar.gz |
|---|---|
| Size | 66.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
47d5bf811b89a09a1a12feefe0e6ddb6741dcfdfd90c3d46577ba96b4766a549
|
|
BLAKE2b-256 checksum How to use checksums |
6e5c7403ed7b6e721bc5fa4f867715a14cf57fa8d109d4c45edd50eabdefa80c
|
| 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 Oct 5, 2026.
Transparency logRelease files / bastionprobe-0.19.0-py3-none-any.whl
| Download URL | bastionprobe-0.19.0-py3-none-any.whl |
|---|---|
| Size | 57.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9de361e7ea1db65a4fb24ba02d255b8c8827b532428c02f651903254f6341593
|
|
BLAKE2b-256 checksum How to use checksums |
5b1b0089f03b203613c642f930e9e633d59e39108820d071c705031c5473ba5e
|
| 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 Oct 5, 2026.
Transparency log