AI Cordon Picket for LangChain
Check what an LLM is given for prompt injection, in each place it can arrive:
| entry point | reads | with |
|---|---|---|
PromptInjectionFilter |
material: documents at ingest, before they are chunked and embedded | Picket's ipi rules |
ToolOutputFilter |
material: what a tool handed back, before the model reads it | ipi |
PromptInjectionGuard |
the request: the turn an agent is about to answer | Picket's dpi rules |
PromptInjectionValidator |
the request: the same, in a chain that is not an agent | dpi |
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-langchain
Material at ingest
from aicordon_langchain import PromptInjectionFilter
from langchain_community.document_loaders import DirectoryLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
docs = DirectoryLoader("kb/").load()
docs = PromptInjectionFilter(mode="redact").transform_documents(docs) # <- here
chunks = RecursiveCharacterTextSplitter().split_documents(docs)
store.add_documents(chunks)
The filter 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 | — |
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 whole utterance the span sits in — the sentence, across the lines a wrapper broke it over: the span points at the injection, but what must leave the index is everything it was saying. A short line that ends without punctuation is a bullet or a table row and is left alone, so a list is not eaten item by item.
In drop mode the host's contract lets transform_documents return the survivors and nothing else,
so use split when the removed pile should stay visible:
kept, rejected = PromptInjectionFilter(mode="drop").split(docs)
Tool output in an agent
from aicordon_langchain import ToolOutputFilter
from langchain.agents import create_agent
agent = create_agent(
model="openai:gpt-5.5",
tools=[fetch_page, read_ticket],
middleware=[ToolOutputFilter(mode="redact")], # every tool, or tools=["fetch_page"]
)
A page a tool brings back is material by any reading — the model is to work on it, not answer it — and it enters the conversation with nothing between it and the model. The filter reads it at the point the tool returns, which is the same ingest point a document has and the only place where cutting is what the policy was measured on.
drop here withholds the text, not the message. Every tool call must be answered by a result
carrying its id, so the message stays and the model is told the output was withheld — which is also
the honest thing to tell it.
The request in an agent
from aicordon_langchain import PromptInjectionGuard
agent = create_agent(
model="openai:gpt-5.5",
tools=[fetch_page],
middleware=[PromptInjectionGuard()], # mode="drop" by default
)
On a flagged turn the model is not called, the refused turn is taken out of the conversation so
that the next turn is not assembled with it, and the agent answers with the guard's own message
instead — mode="annotate" calls the model and records the finding on the answer, mode="fail"
raises InjectionFound. What is read is the request: by default the user's turns, and nothing else.
The system message is the operator's own text, and an operator who wants to steer their own model
does not need an injection to do it.
The refusal stays in the thread and carries the finding; only the flagged turn goes. Pass
forget=False to keep it, knowing that the next call to the model then carries the attack in its
history.
Both middlewares in one agent, each on its own side:
middleware=[PromptInjectionGuard(), ToolOutputFilter(mode="redact")]
The request in a chain
from aicordon_langchain import PromptInjectionValidator
chain = prompt | PromptInjectionValidator() | model # raises InjectionFound on a finding
A link in a chain returns a value and the next link is the model; there is no arrangement in which it declines the call and answers instead. So it raises, or it marks and lets the chain decide:
guard = PromptInjectionValidator(mode="annotate")
chain = prompt | RunnableBranch((guard.flagged, refusal), model)
Inside an agent the same decision has a proper home — prefer PromptInjectionGuard when there is
an agent to put it in.
Turns are read as they arrive — what came in since the model last spoke. A transcript you assemble yourself and hand over whole is read from its newest turn on, not re-read end to end, so keep the guard in the loop that appends to it rather than passing it an unchecked history.
Nothing is rewritten on the request side
PromptInjectionGuard and PromptInjectionValidator accept annotate, drop and fail only; ask
either for redact and it raises. Material can lose a paragraph and stay usable. Take a clause out
of what somebody asked for and the model answers a question nobody put, with the user seeing an
answer rather than a notice — and the cut itself is fitted to the wrong shape, because a typed
attack is not spliced into a turn, it is the turn.
What is written where
Metadata is written on every text that was read, including the clean ones: "read, clean" and "not read" are different facts, and a field that appeared only on a finding could not be filtered on.
| surface | where it lands | keys |
|---|---|---|
| documents | Document.metadata |
ipi_flagged, ipi_action, ipi_base, and on a finding ipi_threats, ipi_spans, ipi_removed_chars |
| tool output | ToolMessage.response_metadata |
ipi_flagged, ipi_action, ipi_base, ipi_threats |
| the agent's request | the answer's response_metadata |
picket_blocked or picket_request_flagged, picket_request_threats, picket_messages (keyed by message id) |
| the chain's request | each read message's additional_kwargs |
picket_flagged, picket_action, picket_base, picket_threats, picket_spans |
Findings are also logged through the standard library logger under aicordon_langchain.* at
warning level, in every mode. Writing it down is not a policy choice.
Measured
Not the detector's recall — that ships with the detector — but what your line delivers with this package in it and without.
Material at ingest. Quadrat-IPI v1.0.1,
2000 injected and 2000 clean documents, mode="redact"; how much of a planted payload still reaches
the splitter:
| whole corpus | injections that ask the model to reveal something | |
|---|---|---|
| payload gets through intact, without the filter | 100% | 100% |
| payload gets through intact, with it | 85.2% | 43.4% |
| payload gone without a trace | 13.5% | 51.3% |
| clean documents dropped or trimmed | 1 of 2000 | 1 of 2000 |
Both columns matter: the first is an arbitrary stream, the second is where the rule is strong.
Material through a tool. The same corpus, 1000 injected and 1000 clean pages, fetched by a tool inside a real agent loop — and read off the message list the MODEL was handed, not off the filter's own return value:
| payload reaching the model intact, without the filter | 100% (1000 of 1000) |
| payload reaching the model intact, with it | 85.4% |
| payload gone without a trace | 13.1% |
| clean pages withheld or trimmed | 0 of 1000 |
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, an agent in 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.
The last row outranks the other two: a wrapper may neither lose text nor add its own, and a figure taken while it does would be describing a different string than the one the user sent.
Reproduce all three with eval/measure_ingest.py, eval/measure_tools.py and
eval/measure_agent.py.
What it costs
Checking a turn costs 0.32 ms at the median length, and 1.78 ± 0.06 ms averaged over ordinary
traffic — 3000 real WildChat turns, each timed five times (eval/costturn.py). Loading the base
costs 21 ms, once per process. Of that 1.78 ms, 1.75 is the rule engine itself and 0.03 is
everything this package adds.
The average is five times the median: cost follows length, and a chat pool has a long tail. Find your row:
| turn length | turns in the pool | cost |
|---|---|---|
| under 200 characters | 1943 | 0.21 ms |
| 200–500 | 411 | 0.71 ms |
| 500–1500 | 321 | 1.72 ms |
| 1500–4000 | 182 | 4.62 ms |
| over 4000 | 143 | 15.63 ms |
For scale, an agent step through LangGraph costs 0.57 ms before any middleware is installed. A document at ingest costs 11 ms — documents are long, and cost follows length there too.
Cost drifts with machine load. These were taken in one run by one procedure — the only way two figures compare.
No GPU, no network call, no key. The rule base is a few hundred kilobytes and loads once.
Not a prefilter
Silence from a rule is not a verdict. Picket reports what it recognises; what it does not recognise it says nothing about, and no finding does not mean no injection. It belongs where a cheap, local, deterministic check is worth having on everything you ingest — not as the only thing between a model and the web.
Licence
Apache-2.0. The detector itself is aicordon; the policy the
wrappers share lives there as aicordon.guard.
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_langchain-0.1.1.tar.gz.
File metadata
- Download URL: aicordon_langchain-0.1.1.tar.gz
- Upload date:
- Size: 44.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.8.17
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bdeb9cdc084ca1c475ef2ed860de4582c449c3d46ac0111b2e7921f0fe8c7acf
|
|
| MD5 |
356da8434ba4b520b6ca05b9effda875
|
|
| BLAKE2b-256 |
64847662a1968caf291d47b32441a2c3292a1d42d23d356f006a66c65d5372bb
|
File details
Details for the file aicordon_langchain-0.1.1-py3-none-any.whl.
File metadata
- Download URL: aicordon_langchain-0.1.1-py3-none-any.whl
- Upload date:
- Size: 23.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.8.17
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cd525024763ac697bcd6e66779a1e93e309c6df389846ab5467f78a948113d91
|
|
| MD5 |
71bdf38778d987d22855ad3ce4aa7046
|
|
| BLAKE2b-256 |
1192b2d1269d18b518db0ca1a4e280eacee01ce1b84017837e2bcf67b64c1919
|