Quickstart¶
Two parts, about 5 minutes each:
- Run the demo with Docker: see the collector flag and redact a leak, with nothing installed but Docker.
- Protect your own agent:
pip installand about 10 lines of code for a LangGraph agent or an OpenAI client.
All data in the demo is fake.
Part 1: the Docker demo¶
A small HR bot reads an employee record that holds a fake SSN and a fake AWS key, then sends that data to an LLM, an external API and a log. The Taintward collector checks every span against the Rego policy, marks the leaks as violations and removes the raw values before the spans are stored.
You need Docker with the Compose plugin. Nothing else.
Run it¶
From the repo root:
The first build takes about 4 minutes (the collector is compiled from source
and the demo installs Presidio and spaCy). After that, docker compose up
starts in a few seconds.
What starts:
| service | what it does | host port |
|---|---|---|
jaeger |
trace viewer | 16686 (UI) |
taintward-collector |
OTLP receiver + taint_policy processor |
14319 (OTLP/HTTP) |
demo |
runs the breach simulator once, then exits | none |
Both host ports are published on 127.0.0.1 only: the collector receiver
has no auth, and the UI shows span data.
The demo prints external API ack: ... and exits with code 0. That is
expected. The collector and Jaeger keep running.
What you should see¶
Open http://localhost:16686, pick the service taintward-hr-bot and click
Find Traces. Open the hr_bot.run trace. It has these spans:
| span | destination | taintward.policy.violation |
|---|---|---|
query_db |
internal | false |
internal_store |
internal | false |
ask_llm |
llm | true (pii_egress + transfer) |
rag_retrieve |
rag | false |
call_external_api |
external | true (pii_egress + transfer) |
write_log |
log | true (pii_egress) |
To list only the leaks, put taintward.policy.violation=true in the Tags
box and search again. You get the three spans above.
Click any span and look at gen_ai.tool.call.arguments and
gen_ai.tool.call.result:
- On the egress spans (
ask_llm,call_external_api,write_log) the SSN shows as[PII]and the AWS key as[SECRET]. - On
internal_storethe SSN shows as a token like103-48-0677. It has the same shape as an SSN and can be turned back into the real value by the key holder, but it is not the real value. - The fake SSN
234-12-1234and the fake keyAKIAIOSFODNN7EXAMPLEappear nowhere.
You can check that last point from the command line too:
curl -s -G http://localhost:16686/api/v3/traces \
--data-urlencode query.service_name=taintward-hr-bot \
--data-urlencode query.start_time_min=$(date -u -d '-1 hour' +%FT%TZ) \
--data-urlencode query.start_time_max=$(date -u -d '+1 min' +%FT%TZ) \
| grep -c -E '234-12-1234|AKIAIOSFODNN7EXAMPLE'
It prints 0.
Run the demo again¶
Each run adds one new trace.
Change the policy¶
The policy file collector/policy/pii_egress.rego is mounted into the
collector. Edit it, then:
No rebuild needed. What the policy says is explained in Policy.
Send your own agent's spans¶
Point your OTLP/HTTP exporter at http://localhost:14319/v1/traces. The
collector runs the same policy on your spans. Part 2 below does exactly
that.
Use SigNoz (or any OTLP backend) instead of Jaeger¶
The collector sends spans to whatever OTLP_EXPORT_ENDPOINT says, as an
OTLP gRPC host:port. The default is jaeger:4317.
If SigNoz runs on the same machine and listens on port 4317:
OTLP_EXPORT_ENDPOINT=host.docker.internal:4317 \
docker compose up --build --no-deps taintward-collector demo
--no-deps skips Jaeger. Then open SigNoz, go to Traces and filter on
service.name = taintward-hr-bot and taintward.policy.violation = true.
For a remote SigNoz, use its address, for example
OTLP_EXPORT_ENDPOINT=signoz.example.com:4317. The exporter uses plain gRPC
without TLS. If your backend needs TLS, edit collector/config.docker.yaml.
More backends (Grafana Tempo, Jaeger, SigNoz Cloud) are in
Send traces to SigNoz, Tempo or Jaeger.
Also write spans to a file¶
The collector can also write every checked span as OTLP JSON, one object per line:
The file is /var/lib/taintward/spans.jsonl in the taintward-spans
volume. To copy it out:
This file is one of the inputs of the compliance report.
Ports¶
The quickstart does not use host ports 4317, 4318 or 8080, so it can run next to a local SigNoz.
Part 2: protect your own agent¶
Keep the Docker stack from part 1 running: your agent will send its spans to the same collector and you will see them in the same Jaeger.
Install¶
Python 3.11 or newer.
python -m venv .venv && . .venv/bin/activate
pip install "taintward[langchain]" # or [openai-agents], [llm], [mcp]
python -m spacy download en_core_web_sm
python -m spacy download xx_ent_wiki_sm # optional: names in other languages
The spaCy model is needed for name detection. Without it Taintward still runs, logs one ERROR and falls back to regex-only detection (see Detection health). The second model is spaCy's small multilingual one: text in German, French, Spanish, Italian, Dutch or Swedish goes to it, and without it that text uses the English model, which misses more names there and calls more capitalized words a name.
Set a key so reversible tokens are stable across restarts (any 64 hex characters; this one is for the quickstart only):
LangGraph or LangChain¶
import taintward as tw
from langchain.agents import create_agent # or langgraph.prebuilt.create_react_agent
from langchain_openai import ChatOpenAI # pip install langchain-openai
tw.init("support-bot", endpoint="http://localhost:14319/v1/traces",
destinations={"lookup_customer": "internal",
"send_email": {"destination": "external", "allow": ["to"]}})
tools = tw.wrap_langchain_tools([lookup_customer, send_email]) # your @tool functions
model = tw.protect_langchain_model(ChatOpenAI(model="gpt-4.1-mini"))
agent = create_agent(model, tools)
with tw.run("support-bot.run"):
agent.invoke({"messages": [("user", "Email Alice Lee about her refund")]})
What happens:
- the model sees
<PERSON_1>instead ofAlice Lee, and the reply your code gets has the real name again; lookup_customerisinternal, so it gets reversible tokens;send_emailisexternal, so its body gets[PII]masks, but thetoargument is on itsallowlist and reaches the tool as is;- any tool you forgot to list is treated as
external.
No API key at hand? The repo has an offline version of this agent with a scripted model:
Plain OpenAI (or Anthropic) client¶
import taintward as tw
from openai import OpenAI
tw.init("support-bot", endpoint="http://localhost:14319/v1/traces")
client = tw.protect_openai(OpenAI()) # or tw.protect_anthropic(Anthropic())
messages = [{"role": "user", "content": "Summarize: Alice Lee, SSN 234-12-1234, wants a refund."}]
with tw.run("summarize"):
reply = client.chat.completions.create(model="gpt-4.1-mini", messages=messages)
print(reply.choices[0].message.content) # real values restored
The request that reaches OpenAI says <PERSON_1>, SSN <SSN_1>.
See it¶
In Jaeger, pick the service support-bot. The chat span holds the
redacted request in gen_ai.input.messages, and spans that sent sensitive
data to an llm, external or log destination have
taintward.violation=true (set by the SDK) and
taintward.policy.violation=true (set by the collector).
Next steps¶
- Concepts: destinations, taint, placeholders, policy and evidence in a few pages.
- Audit mode first, then enforce: how to roll this out on a real agent without breaking it.
- Kubernetes and Helm and Scaling.