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, 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.

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.

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.0.tar.gz (40.1 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.0-py3-none-any.whl (22.2 kB view details)

Uploaded Python 3

File details

Details for the file aicordon_langchain-0.1.0.tar.gz.

File metadata

  • Download URL: aicordon_langchain-0.1.0.tar.gz
  • Upload date:
  • Size: 40.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.8.17

File hashes

Hashes for aicordon_langchain-0.1.0.tar.gz
Algorithm Hash digest
SHA256 d271bf527598c8a8610d5f6c1c4b210022fa4551290e35ac3748dc4f87255bd9
MD5 445f5424dfe3f4d5d927f6c892fe158f
BLAKE2b-256 d014fd2c5c8d7d128e5b2ed5beecf263462147a373139f5a4d62b7fb303c0f92

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for aicordon_langchain-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 00aa277981d0a111348593599d86c64a33b40500ce26d71f25322233b0485179
MD5 8546363ec478f223190d02aa61b9b4fc
BLAKE2b-256 698cb631e4ef664b20dc6d0942a4e0cd1c695ab62f529d49f8dadc8216c1d52b

See more details on using hashes here.

Release history Release notifications | RSS feed

0.1.1

2 files

This release

0.1.0 This release

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