Changelog¶
Unreleased¶
Security¶
- Placeholders inside a link or image address in the model's answer are no
longer restored (gap G1). An injected
used to become an image request carrying the real SSN once the answer was rendered. They stay placeholders, are recorded astaintward.placeholders.blocked, and the check also follows links across stream chunks (Google ADK, LlamaIndex, TypeScriptStreamRestorer). Python and TypeScript. Turn off withplaceholder_restore_in_urls=True,restoreInUrls: trueorTAINTWARD_RESTORE_IN_URLS=1. - Report fingerprints are an HMAC-SHA256 with a secret key, 12 hex
characters, instead of an unsalted SHA-256 that could be brute forced for
SSNs (gap G6).
TAINTWARD_REPORT_KEYkeeps them stable across reports; without it each report has its own random key. TAINTWARD_DETECT_MAX_CHARS(default 500000): a longer value is not scanned and is masked whole, so a huge payload cannot hold a call for seconds (gap G10).Redactor.reverseandtw.restoreare recorded: ataintward.reversespan event and ataintward.auditlog line with the key id and the calling code, never the value (gap G11).- Tool outputs are masked in the SDK also with
TAINTWARD_MASK_IN_SDK=0, because the collector only masks regex types (gap G5). - The collector's
taint_policyprocessor has adrop_attributesoption. By default it drops the Google ADK attributes that hold model requests, responses and tool arguments with real values. - Gateway: tools are fingerprinted (description, title, schemas). A tool
that differs from its pin in
tool_pinsor from the version first seen gets ataintward.gateway tool_changedspan;on_tool_change: hidestops exposing it.--print-tool-pinswrites the pins (gap G2). - Gateway: two upstreams offering the same tool name stops the gateway at
startup, and hides both tools if it happens later; it used to let the
first upstream win. Rules that grant
internalorpassthrough_argswithoutupstream:warn when there are several upstreams, and an unknownupstream:is a config error (gap G3). - Gateway:
TAINTWARD_GATEWAY_TOKENS=name:token,...gives each client its own token, and taint is kept per client as well as per session, so a new session does not start clean (gap G4).client:does the same over stdio. - Collector:
trusted_servicesintaint_policy. Spans from other services are treated as sensitive and theirtaintward.args_redactedmarker is ignored (gap G7). The image includes thebearertokenauthextension;collector/config.secure.yamland Helmcollector.authrequire a producer token, and the Helm routing tier and gateway send it. - The sidecar config listens on
127.0.0.1:4319, anddocker-compose.ymlpublishes the collector and Jaeger ports on127.0.0.1only (gap G8). - Anthropic's
messages.stream()helper is patched on a protected client. It builds its own request instead of callingmessages.create, so its request used to reach Anthropic unredacted. The request is now redacted when thewithblock starts, the events,text_streamandget_final_message()are restored, and the SDK's own span (content mode) gets no answer text. A JSON answer (output_format,output_config) is restored JSON-escaped, also formessages.create(stream=True). - LiteLLM: the caller gets a restored copy of a non-streamed answer, and
the
TaintwardGuardrailpost-call hook returns one too. Restoring LiteLLM's own response object in place let its logging callbacks, which read it after the call returns, see the real values.
Added¶
- Streamed answers are restored in Python: OpenAI and Anthropic
stream=True(and the OpenAIchat.completions.stream()/responses.stream()helpers), LangChainstream()/astream(), Pydantic AIrun_stream, LiteLLMstream=Trueand the LiteLLM proxy guardrail. A placeholder split across chunks is restored whole: each text holds back at most 64 characters while they may be the start of a placeholder. Tool call arguments are restored as JSON, and so is the text of a JSON answer (response_format). The SDK's own stream object is returned and its chunks are restored copies; the provider's chunk objects keep the placeholders (LiteLLM's logging callbacks read them). The span stays open until the stream ends, then records the answer (placeholder version) and checks it; strict mode raises after the last chunk. PlaceholderMap.restore_json_fragment()andplaceholders.StreamRestorer(the Python version of the TypeScript one).- Detection outside US formats (gap G9). Text in another language goes to
spaCy's multilingual model
xx_ent_wiki_smwhen it is installed (python -m spacy download xx_ent_wiki_sm; pick another one withTAINTWARD_NER_MULTILINGUAL_MODEL, oroff). New rules: phone numbers with a+country code, or after a phone word in English, German, French, Spanish, Italian, Dutch or Swedish; street addresses in the usual European forms, joined with the postal code and city after them; national ids with a check digit (Indian Aadhaar and PAN, Italian codice fiscale, Spanish DNI / NIE, French NIR, Swiss AHV, UK National Insurance number); any id right after an id word ("Passnummer", "numéro de passeport", "Codice fiscale") or under an id key (passport_number,tax_id), never the plain word "id"; passwords and API keys written inline ("Passwort: ...",<password>...</password>). On the held-out outside data strict recall went from 0.36 to 0.51 and from 0.50 to 0.60 (docs/benchmark.md). detect.health()has amultilingual_modelfield.- CI job
detection-benchruns the outside-data benchmark and fails when a score drops under its floor.bench/external/run_external.py --split devgives rows that are never in the reported sample, for tuning rules.
Changed¶
- Fewer false name hits: greeting and form label words ("Bitte",
"Numéro"), template slots (
[City]), words that are also written in lower case in the same text ("Policy" ... "policy"), spaCy stop words ("Unsere", "Estoy") and hits that start with a pronoun ("Meine Gedichte") are dropped. False alarms on short outside texts went from 26% to 15%. - A UK NHS number is only flagged with its context word nearby: on outside data the ten-digit check fired mostly on phone numbers.
- More US street types from the USPS list (Junction, Creek, Crossing, ...).
- An id found after an id word wins over a phone number guess on the same characters, and gives way to a recognizer that knows the exact format.
- The LiteLLM proxy guardrail sends placeholders for streaming requests too (it used to send masks, because it could not restore a stream).
- LlamaIndex and Google ADK streams go through the same restorer, so a placeholder split across two deltas is restored in the deltas too, not only in the final text. Their chunks are restored copies, and the span records the placeholder version of the streamed answer.
- Pydantic AI
request_streamkeeps its span in a private context, so it no longer logs "Failed to detach context" when Pydantic AI ends the stream in another task.
Fixed¶
- TypeScript
StreamRestorer: a double bracket form ({{PERSON_1}},[[PERSON_1]]) cut between its two closing brackets was restored too early and left a stray bracket. In streamed tool call arguments, the check for link addresses (gap G1) now looks only at the JSON string the placeholder is in, so a link in another argument no longer keeps a placeholder unrestored.
0.3.0¶
Added¶
- Adapters for CrewAI (
wrap_crewai_tools,protect_crewai_llm), LlamaIndex (wrap_llamaindex_tools,protect_llamaindex_llm), Pydantic AI (wrap_pydantic_ai_tools,wrap_pydantic_ai_toolset,protect_pydantic_ai_model), Google ADK (wrap_adk_tools,protect_adk_model) and LiteLLM (protect_litellmfor the SDK,TaintwardGuardrailfor the proxy). Same gate as the other adapters: tools get what their destination allows, the model gets placeholders and its answer is restored. Extrascrewai,llamaindex,pydantic-ai,google-adk,litellm; tests run each framework's own agent loop with a scripted model, in a newadapter-testsCI job pinned byconstraints-adapters.txt. - Key rings and rotation (
taintward/sdk/keys.py,docs/key-management.md):TAINTWARD_KEYSloads FPE and vault keys from a JSON file or HashiCorp Vault KV, with keys optionally wrapped by AWS KMS, GCP KMS or Vault Transit. Vault rows now name the key that sealed them; old rows still open. Newtaintward-keyscommand:rotate,reseal(re-encrypt vault rows under the active key, SQLite, Postgres and Redis),retire,generate,show. Erasure finds rows under every key in the ring. New extrasawsandgcp. Tested against real Vault and LocalStack. -
TypeScript SDK in
packages/taintward-js(npm nametaintward):taintwardMiddleware()for the Vercel AI SDK sends placeholders to the model and restores real values in text, reasoning and tool call arguments, also when streamed. Same detector rules as Python (checked case by case against it), tolerant restore of rewritten placeholders, the placeholder instruction line and strict mode. No name detection. -
docs/threat-model.md: trust boundaries, assets, what is prevented, detected or not handled for each attacker, known detection bypasses, STRIDE per component, 11 open gaps (G1 to G11) and a deployment hardening checklist. Every claim points to the code and test behind it. bench/external/: detection measured on two public labeled PII datasets (ai4privacy, gretel finance multilingual). Results inbench/results/external_*show value shapes only, never dataset text. Summary in "Results on outside data" indocs/benchmark.md.- Documentation site (mkdocs-material,
mkdocs.yml): concepts, guides, recipes, configuration, span attribute, command line and Python API reference, operations and security pages.mkdocs build --strictruns in CI, and.github/workflows/docs.ymlpublishes it to GitHub Pages from main.
Changed¶
-
The README is shorter, with the key numbers at the top.
-
README and SECURITY.md say that the 0% false alarm and 0.94 recall numbers are from the synthetic benchmark, and link the outside-data results and the threat model.
- Links on the site, quickstart, SECURITY.md and CONTRIBUTING.md point to the GitHub repo. Security reports go to GitHub private advisories, with the GitLab confidential issue as a fallback.
0.2.0¶
First release under the name Taintward. The project was called AgentWard
before; the PyPI name agentward belongs to another company, so everything
was renamed.
Renamed¶
- One Python package,
taintward, holdssdk,core,evidence,fix_suggesterandgateway.import taintward as twgives the integrations API:tw.init,tw.run,tw.wrap_langchain_tools,tw.protect_langchain_model,tw.wrap_function_tools,tw.protect_openai,tw.protect_anthropic,tw.restore,tw.health. - PyPI name
taintward. Commands:taintward-report,taintward-gateway,taintward-evidence,taintward-fix. - Environment variables
AGENTWARD_*are nowTAINTWARD_*. - Span attributes
agentward.*are nowtaintward.*in the SDK, the gateway, the collector processor, the Rego package (package taintward), the report, the dashboards and the Helm chart. - Go module
github.com/DCchoudhury15/taintward/..., collector binary and imagestaintward-collector,taintward-gateway,taintward-demo. - Helm chart in
deploy/helm/taintward. The gateway Dockerfile moved todeploy/gateway.Dockerfile. - There are no compatibility aliases for the old names.
Added¶
- Packaging: every runtime package ships; extras
mcp,langchain,openai-agents,llm,postgres,redis. CI for GitLab and GitHub. - FF1 format-preserving encryption, checked against the NIST sample vectors.
- Token vault with AES-256-GCM encryption at rest: memory, SQLite, Postgres and Redis backends, with per-subject erasure for GDPR.
- MCP gateway over stdio and HTTP (
--listen) with redact, block and audit modes per tool, and Redis session storage for several replicas. - Framework adapters for LangChain, LangGraph, the OpenAI Agents SDK and the OpenAI and Anthropic clients.
- Placeholders for the LLM: the model gets
<PERSON_1>,<SSN_1>and the caller gets the real values back. - Compliance report (
taintward-report), a sharded evidence log with Ed25519 signed checkpoints, RFC 9162 inclusion and consistency proofs, and repair after a crash. - Docker compose quickstart with Jaeger, a Helm chart (tested on kind) and load tests for the collector, the SDK and the gateway.
- Detection health:
tw.health()reportsfull,regex_onlyorbroken. TAINTWARD_FAIL_CLOSED=1: refuse calls to non-internal sinks while detection is degraded (SDK and gateway).taintward.args_redactedspan attribute: the stored tool arguments are already redacted, so the collector keeps them (and their reversible tokens) as they are.taintward.taint.source_spannow names the span that first found the data, and the source span rides in baggage.
Changed¶
- Detection is much faster: a wrapped 1 KB tool call went from 518 ms to
8.4 ms (p50), and false alarms on clean payloads went from 48% to 0% on
the benchmark. See
docs/benchmark.md. - Collector processor: fails closed, scans all span data, has more secret patterns and caches policy decisions.
- If spaCy or Presidio fail to load, detection logs one ERROR and falls back to regex-only detection instead of finding nothing.
- Run state (
begin_run/end_run,tw.run) lives in context variables: runs in concurrent asyncio tasks no longer mix, and nested runs work.
Fixed¶
- Numbers and other non-string values (an SSN stored as an int, an object
whose
str()holds an SSN) reached tools and spans raw. They are now checked in their string form and redacted. - Dict keys were never scanned. They are now scanned and redacted, and redacted keys are kept unique.
- namedtuples became plain tuples, and masking one raised an error. They keep their type now; sets, frozensets and dict subclasses too.
AGENTWARD_DRY_RUN=0turned dry run on. All on/off settings now use one parser: on for1,true,yes,on, off for0,false,no,off.- Taint from a wrapped tool called inside another wrapped tool could be lost, so a later external call recorded no violation.
- A tool that raised recorded no violation, and the exception message reached the span raw. The violation is recorded now and the exception message and stack trace are masked.
- The Helm chart shipped an older copy of the Rego policy.