Skip to main content

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

aicordon_langchain-0.1.1.tar.gz (44.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

aicordon_langchain-0.1.1-py3-none-any.whl (23.0 kB view details)

Uploaded Python 3

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

Hashes for aicordon_langchain-0.1.1.tar.gz
Algorithm Hash digest
SHA256 bdeb9cdc084ca1c475ef2ed860de4582c449c3d46ac0111b2e7921f0fe8c7acf
MD5 356da8434ba4b520b6ca05b9effda875
BLAKE2b-256 64847662a1968caf291d47b32441a2c3292a1d42d23d356f006a66c65d5372bb

See more details on using hashes here.

File details

Details for the file aicordon_langchain-0.1.1-py3-none-any.whl.

File metadata

File hashes

Hashes for aicordon_langchain-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 cd525024763ac697bcd6e66779a1e93e309c6df389846ab5467f78a948113d91
MD5 71bdf38778d987d22855ad3ce4aa7046
BLAKE2b-256 1192b2d1269d18b518db0ca1a4e280eacee01ce1b84017837e2bcf67b64c1919

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page