AI Cordon Picket for Haystack
Check what an LLM is given for prompt injection — in both places it can arrive:
| component | reads | with |
|---|---|---|
PromptInjectionFilter |
material: documents at ingest, before they are chunked and embedded | Picket's ipi rules |
PromptInjectionGuard |
the request: the turn the model is about to answer | Picket's dpi rules |
The two rule sets are disjoint, and neither is a stricter version of the other — this is not a sensitivity knob. Pick by role: material is what the model works on, the request is what it answers. Your code knows which is which; it puts them in different places when it assembles the call.
The check is a rule, not a model: no GPU, no network, no key, a few hundred kilobytes of base, and a fraction of a millisecond per turn on one core — see what it costs.
Installation
pip install aicordon-haystack
Material at ingest
from haystack import Pipeline
from haystack.components.preprocessors import DocumentSplitter
from haystack.components.writers import DocumentWriter
from haystack_integrations.components.preprocessors.aicordon import PromptInjectionFilter
pipe = Pipeline()
pipe.add_component("ipi_filter", PromptInjectionFilter(mode="redact"))
pipe.add_component("splitter", DocumentSplitter(split_by="word", split_length=200))
pipe.add_component("writer", DocumentWriter(document_store=store))
pipe.connect("converter.documents", "ipi_filter.documents")
pipe.connect("ipi_filter.documents", "splitter.documents")
pipe.connect("splitter.documents", "writer.documents")
pipe.connect("ipi_filter.rejected", "quarantine.documents") # optional; nothing disappears quietly
The component sits before the splitter: a cut here takes the injection out of the chunks, the embeddings and the store at once, with no offsets to reconcile across chunk boundaries.
What it does with a finding
mode |
the document | the length |
|---|---|---|
annotate |
indexed unchanged, the finding recorded in metadata | unchanged |
blank |
every character of the block becomes blank_char (default *) |
preserved |
mask |
the block is replaced by mask_with |
changes |
redact (default) |
the block is cut out | changes |
drop |
not indexed; it comes out of the rejected socket |
— |
fail |
the run stops on the first finding | — |
blank is for pipelines that carry offsets, page maps or diffs downstream and cannot have a
document change length under them.
The cut takes the line holding the span, or the sentence when that line runs past 1500 characters: the span points at the injection, but what must leave the index is the whole utterance. Measured on 1200 documents: the payload is gone entirely in 91% of catches, at a median 11.6% of the document removed.
The turn the model answers
from haystack_integrations.components.validators.aicordon import PromptInjectionGuard
pipe.add_component("guard", PromptInjectionGuard()) # mode="drop" is the default
pipe.connect("prompt.messages", "guard.messages")
pipe.connect("guard.messages", "llm.messages") # the model is called on this path
pipe.connect("guard.blocked", "refusal.messages") # and not on this one
Two sockets, one value. On a flagged exchange run returns blocked and no messages key, so
the generator is not called at all. Connect blocked to whatever answers the user instead.
The decision is for the exchange, not for one message: drop the offending turn and the model answers the one before it.
What it reads, and what it does with a finding
roles maps a role to a rule set, default {"user": "dpi"}. assistant is the model's own text,
system the operator's. tool carries material and switches on with
roles={"user": "dpi", "tool": "ipi"} — measure your own tool output first: over live chat text the
ipi rules raise eight times as many alarms as over documents, and they fire on command lists and
code, which is what a tool result looks like.
mode |
the exchange |
|---|---|
drop (default) |
routed to the blocked socket; the model is not called |
annotate |
passed through, with the finding in each read message's metadata |
fail |
the run stops with InjectionFound |
No mode edits a turn, and asking for one raises. The cut above is fitted to an instruction spliced into a document; a typed jailbreak is not spliced into anything — it is the turn, and cutting it leaves the rest of the attack in place.
What lands in the metadata
Written on every document and every message whose role is read, so "checked and clean" is distinguishable from "never checked". A role outside the map gets no fields — a third, distinct fact.
{"ipi_flagged": False, "ipi_action": "none", "ipi_base": "20260817"}
{"ipi_flagged": True, "ipi_action": "redact", "ipi_base": "20260817",
"ipi_threats": ["IPI/Secret.Reveal.B"], "ipi_spans": [[812, 947]], "ipi_removed_chars": 163}
{"picket_flagged": True, "picket_action": "drop", "picket_base": "20260817",
"picket_threats": ["DPI/Policy.Cancel.M"], "picket_spans": [[0, 41]]}
Findings are also logged through Haystack's own logger at warning, so the level is yours to set.
Measured
Not the detector's recall — that ships with the detector — but what the pipeline delivers with the component and without.
Material. Quadrat-IPI v1.0.1,
1000 injected and 1000 clean documents, mode="redact"; how much of a planted payload still reaches
the store:
| whole corpus | injections that ask the model to reveal something | |
|---|---|---|
| payload reaches the store intact, without the filter | 100% | 100% |
| payload reaches the store intact, with it | 85.4% | 42.8% |
| payload gone without a trace | 13.1% | 52.3% |
| clean documents dropped or trimmed | 0 of 1000 | 0 of 1000 |
Both columns matter: the first is an arbitrary stream, the second is where the rule is strong. A document cost 8.3 ms in that run — documents are long, and cost follows length.
The request. Held-out forum jailbreaks from
TrustAIRLab in-the-wild
(537, near-duplicates of the fitting half removed) against 20 000 real user turns from
WildChat, mode="drop":
| attacks reaching the model, without the guard | 100% (537 of 537) |
| attacks reaching the model, with it | 65.2% |
| turns not answered, out of 20 000 real ones | 0.070% (14) |
| verdicts differing from the bare detector | 0 |
WildChat carries no attack labels and real jailbreaks sit inside it, so "turns not answered" is an upper bound on the cost to a real user, not a false-alarm rate. The detector's working point, on a labelled pool, is in its report.
Reproduce both with eval/measure.py and eval/measure_dialog.py.
What it costs
Adding the component to a pipeline costs 0.39 ms for a turn of median length, and 1.65 ± 0.03 ms
averaged over ordinary traffic — 3000 real WildChat turns, each timed five times
(eval/costturn.py). Loading the base costs 15 ms, once per process.
The average is four times the median: cost follows turn length, and a chat pool has a long tail. Find your row:
| turn length | turns in the pool | cost |
|---|---|---|
| under 200 characters | 1943 | 0.30 ms |
| 200–500 | 411 | 0.74 ms |
| 500–1500 | 321 | 1.62 ms |
| 1500–4000 | 182 | 4.09 ms |
| over 4000 | 143 | 13.20 ms |
Cost drifts with machine load. These were taken in one run by one procedure — the only way two figures compare.
No findings does not mean no injection.
License
Apache-2.0, the same as the detector it wraps.
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 aicordon_haystack-0.1.0.tar.gz.
File metadata
- Download URL: aicordon_haystack-0.1.0.tar.gz
- Upload date:
- Size: 58.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.8.17
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6b3f3ef4aef75f2732af2029cfb1b08594027ff32a417f1323b05a31ed29cce3
|
|
| MD5 |
136b5d8f72688d749a0d3fa9ed2b0856
|
|
| BLAKE2b-256 |
366e5601f38f19a03c833c6198ce8dafd3a3b8db9f13fea5f1f5a18572a04de2
|
File details
Details for the file aicordon_haystack-0.1.0-py3-none-any.whl.
File metadata
- Download URL: aicordon_haystack-0.1.0-py3-none-any.whl
- Upload date:
- Size: 14.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.8.17
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8c8f448b92e191d9057f1813c92a6afb763eccadf6b37e2ca999fae83069c5c5
|
|
| MD5 |
bf186487af093c231b1b42e7c55008a6
|
|
| BLAKE2b-256 |
753480551f03fcbb643ff4a6d868a383266bc507d1736779d495aadcd2874b55
|