Monitoring prompt injection with Bastion Lens See every catch across your apps — tagged by exactly where it came from

You've blocked the attacks in-process — now see them. Every catch, across every app, tagged by exactly where it came from: the user's message, a document your RAG retrieved, or a tool result your agent trusted.

Once you run more than one app — a RAG assistant here, a support agent there — you want a single place to answer:

  • What's being caught, right now and over time?
  • Where is it coming from — a poisoned résumé in the vector store, a hijacked support ticket, or the user's own query?
  • Is my false-positive rate creeping up as I tune thresholds?

That's Bastion Lens: a self-hosted collector + console. Wiring it in is purely additive — your application code doesn't change at all. You run one container and set two environment variables, and every detection your apps already make starts streaming into a live, filterable audit trail.

Why this matters for indirect injection specifically. Indirect attacks don't come from the user, so they never show up in the one place most teams look — the user-input log. They ride in on content your app trusts. Lens tags every detection with its origin, so you can filter to exactly the attacks that arrived through your knowledge base and tools — the ones a user-input filter would never have seen.

The core idea: provenance

Bastion Lens records more than "an attack happened." Every detection carries:

  • directioninput (screening what goes into the model) or output.
  • vectordirect (typed by the user) or indirect (arrived via retrieved content or a tool result).
  • originuser_prompt, rag_document, or tool_result.

That provenance is the entire payoff of routing detections through Lens. The defence already blocked the attack; Lens tells you it arrived through the knowledge base, or through a tool the agent called — the distinction that turns a pile of alerts into something you can actually reason about.

   app A (RAG)  ─┐
   app B (agent) ┤── detections (async, non-blocking) ──►  Bastion Lens
   app C (...)   ─┘                                         ├─ Live feed
                                                            ├─ Detections (filter by origin)
                                                            ├─ Overview (fleet stats)
                                                            └─ Policies / retention

Nothing here changes how your apps decide — they already screen content in-process with the SDK. Lens is the lens you look through.

Prerequisites

Bastion Lens is CPU-only, air-gapped, and makes no outbound calls — the detection model and the console are baked into the image, and your data stays in a local volume.

Step 1 — Run the collector

No clone, no build — pull the published image:

docker run -d --name bastion -p 8080:8080 -v bastion-data:/data \
  -e BASTION_ADMIN_KEY=admin-secret-change-me \
  bastionsoft/bastion-lens:cpu

That's the whole install. It gives you:

  • Console → http://localhost:8080/console
  • Ingest + admin API/v1/* (OpenAPI at /docs)
  • Health/health

Events persist to a local SQLite store in the bastion-data volume. BASTION_ADMIN_KEY is your bootstrap admin credential — set a real secret in anything but a local demo.

Also on GHCR: ghcr.io/bastion-soft/bastion-lens:cpu (identical image).
Bastion Lens — the Live feed empty state on a fresh install, waiting for the first detection
Bastion Lens — the Live feed empty state on a fresh install, waiting for the first detection

Step 2 — Create an ingest key

Bastion Lens has two key roles, and you want the least-privileged one for apps:

  • admin — full console, config, and events access (what you log in with).
  • ingest — can only report detections. This is what your apps use, so a leaked app key can't read or change anything.

In the console (recommended): open http://localhost:8080/console, sign in with admin-secret-change-me, then go to Settings → API keys → Create key, choose role ingest, and copy the token — it's shown exactly once and never stored in clear.

Creating an ingest API key in Bastion Lens — the one-time token reveal
Creating an ingest API key in Bastion Lens — the one-time token reveal

Or via the API:

curl -s -X POST http://localhost:8080/v1/keys \
  -H "X-API-Key: admin-secret-change-me" \
  -H "Content-Type: application/json" \
  -d '{"name":"tutorial-sdk","role":"ingest"}'
# → {"key_id":"...","role":"ingest","token":"<KEY_ID>.<SECRET>"}   ← copy the token

The token is already the full KEY_ID.SECRET value — use it verbatim, don't re-assemble it.

Step 3 — Point your app at Lens (two env vars, no code change)

In the same shell that runs your app, set:

export BASTION_TELEMETRY_ENDPOINT=http://localhost:8080
export BASTION_TELEMETRY_KEY='<KEY_ID>.<SECRET>'   # the ingest token from Step 2

Now re-run either app from the defence tutorial:

python llamaindex/app.py     # the poisoned-résumé RAG attack
# or
python langchain/app.py      # the hijacked support-ticket agent

That's all. The SDK's reporter batches detections in the background and flushes them to POST /v1/events:batch about once a second — it's non-blocking and off your app's hot path, so it never adds latency to a screen. Without these two env vars set, the SDK reports nothing and runs exactly as before: monitoring is pure opt-in.

What Lens shows vs. what decides. Your app screens content in-process with the SDK; Lens records what the SDK already decided. It's an observability layer, not a second judge. (To change what gets flagged in-process, tune the SDK's Guard threshold — see the LlamaIndex or LangChain defence tutorial. The console's Policies page, below, governs a different deployment: traffic you screen through the gateway's /v1/protect API.)

Step 4 — Watch it land live

Open Live feed. With the apps running, detections appear in real time (the page holds an open stream — you'll see the green Live pill, and rows animate in as they arrive; no refresh needed). Each row shows the time, a prompt snippet, the reporting app, the detection stage, the risk score, and the action taken.

Bastion Lens Live feed streaming detections in real time
Bastion Lens Live feed streaming detections in real time

Step 5 — Isolate the indirect attacks (the payoff)

Go to Detections — the filterable table over your whole event history. Set the filter to vector = indirect, and you're now looking at only the attacks that arrived through retrieved content and tool results: the poisoned résumé from the LlamaIndex app (origin: rag_document) and the hijacked ticket from the LangChain agent (origin: tool_result). These are precisely the attacks a user-input filter would never have surfaced.

Click any row to open the detail drawer:

  • a risk gauge and the verdict (attack / safe, blocked / flagged / allowed),
  • the stored prompt snippet (or a "logging disabled" notice — see Step 7),
  • the per-scanner breakdown (which scanner fired, its score, matched rules),
  • and full provenance: direction, vector, origin, the reporting SDK, model version.

Need it elsewhere? Export the filtered set as CSV or JSON for a report or a SIEM.

Detections filtered to indirect prompt-injection attacks, tagged by origin (rag_document, tool_result)
Detections filtered to indirect prompt-injection attacks, tagged by origin (rag_document, tool_result)
Detection detail drawer — risk gauge, per-scanner breakdown, and attack provenance
Detection detail drawer — risk gauge, per-scanner breakdown, and attack provenance

Step 6 — The fleet view

Overview is the at-a-glance health of everything reporting in: total screened, blocked count, block rate, the score histogram, and volume over time. It updates live as detections stream, and it respects the time-range selector (last 24h / 7d / 30d / all) in the header. Run several apps and each reports its own source, so the picture is your whole fleet, not one service.

Bastion Lens Overview — fleet-wide detection stats and charts
Bastion Lens Overview — fleet-wide detection stats and charts

Step 7 — Privacy, retention, and policy

A few operator controls worth knowing, all in Settings and Policies:

  • Prompt-logging policy (Settings) — controls what content Lens stores alongside the always-present SHA-256 hash: off (hash only, no content), snippet (a truncated excerpt — the default), or full (the entire prompt). Prompts can carry PII or secrets, so this is a real privacy lever; a stricter setting affects only new events.
  • Data retention — events auto-purge after a configurable window; hashes can be retained for audit correlation.
  • API keys (Settings) — create, revoke, and permanently delete keys; keep apps on ingest, humans on admin.
  • Policies / scanners — the injection threshold, the secrets / PII / output scanners, and their on/off toggles. Note: this governs traffic screened through the gateway's own /v1/protect endpoint. Detections your apps report via the SDK arrive pre-decided and aren't re-judged here — the console says as much at the top of the page.
Prompt-logging policy — store no content, a truncated snippet, or the full prompt
Prompt-logging policy — store no content, a truncated snippet, or the full prompt
Policies — injection threshold and scanner enable/disable toggles
Policies — injection threshold and scanner enable/disable toggles

Taking it to production

  • Tag your apps. Set BASTION_SOURCE (and optionally BASTION_CLIENT_ID, BASTION_ENVIRONMENT) per app so Detections and Overview can be filtered by service and environment.
  • High volume? BASTION_TELEMETRY_SAMPLE_RATE samples reporting (attacks are always kept); the reporter is async and drops rather than blocks under back-pressure, so it never stalls your app.
  • Air-gap. Lens makes no outbound calls — the model and console are baked into the image, data lives in your local volume. The only network traffic is your apps → the telemetry endpoint you configured.
  • Scaling the store. The default is local SQLite, ideal for a single node. Postgres, SIEM/S3 export, multi-tenant, and RBAC are enterprise features.
  • Licensing. Bastion Lens (this image) is source-available under the Business Source License 1.1 — free to self-host for your own use; you just can't resell it as a hosted service. (The in-process SDK is AGPL). A commercial license unlocks the enterprise tier — contact sales@bastionsoft.com.

What you've got

You started with a deterministic, infrastructure-free defence and added the operational half: a live, filterable, provenance-tagged audit trail across every app — with the attacks that matter most, the indirect ones, one filter away. You can see not just that an injection was caught, but that it arrived through your knowledge base or a tool your agent trusted — the visibility a user-input filter can't give you. It runs on infrastructure you own, fully offline, and your application code never changed.

Start here if you haven't yet: Defending your LlamaIndex app from prompt injection and Defending your LangChain agent from prompt injection.