announcement · release · security · json · tool calls · python · llm
A quote in a name could add a bcc to an AI tool call. Here is the fix in Sether for Python 0.4.0.
Sether Python 0.4.0 adds JSON-safe restore and strict token handling. Review the fix, runnable examples and structured-stream upgrade limits.
Published by Raeven Company LTD. Sether is our product. How we check and correct stories.
In this article
Sether™ / Field notes
release
Safe restoration. In Python too.
Restore parsed values before an authorised action.
sether 0.4.0 on PyPI fixes a bug where restoring tokens into serialized JSON as plain text put original values back unescaped, so a value containing a quote could add keys to LLM tool-call arguments. New restore_json and restore_json_value helpers restore each string value on its own, strict mode raises on tokens it cannot resolve, and the SSE, httpx, ASGI, WSGI and SDK-wrapper paths now restore JSON value by value. It also brings the 0.3.0 features to PyPI for the first time.
- Restoring tokens into serialized JSON as plain text put original values back unescaped. An original holding a
"could add keys, for example abccin an email tool call. sether0.4.0 addsrestore_json,restore_json_valueandSether.restore_json, which restore each string value on its own and letjson.dumpsescape it. Object keys are never restored.- Strict mode (
strict=True) raisesUnresolvedTokenErrorinstead of silently leaving invented, expired or altered tokens in place. - The SSE, httpx, ASGI, WSGI and SDK-wrapper restore paths now restore JSON value by value, and streamed JSON-mode deltas stay tokenized.
- It is also the first PyPI release of the 0.3.0 features: the alias (decoy) engine, the multi-region phone detector and label-anchored secrets.
On 14 October I was building an n8n node for Sether. Its job is to check a model's proposed tool call before any real value goes back into it.
So I was looking hard at the moment the real values go back in. That is where I found the bug. Sether put originals back into JSON as plain text. If an original value held a quote, it could rewrite the JSON around it.
Nobody reported this to me. I found it in my own library. It was in both packages, the TypeScript one and this Python port, and in Sether's own middleware too. It is fixed in sether 0.4.0 on PyPI and in @raeven-co/sether 0.8.0 on npm. The advisory is here: the Sether security advisories on GitHub.
This post covers what Sether is, what went wrong, what changed, and how to upgrade, in Python.
Sether in one picture
Sether is an open-source library that sits between your app and any LLM. Before a prompt leaves your process, it swaps personal data for tokens shaped like <EMAIL_uuid>. By default it catches emails, phone numbers written with a country code, card numbers, US Social Security numbers, IBANs and IP addresses. Opt-in packs add secrets such as API keys, and labelled identity fields such as Name:.
For detected values, the edited request contains tokens instead of those originals; unmatched text and identifying context can remain. Recognised reply tokens restore only while their mapping is available. The default vault stays in process, and the core package does not phone home. Keep the full data flow in view, including your own logs and stores.
It runs on Python 3.9 and up, with sync and async streaming, and integrations for httpx, ASGI (FastAPI, Starlette), WSGI (Flask), and the OpenAI and Anthropic SDKs.
pip install sether
The bug: restoring into JSON as text
Restoring sounds like the easy half. Find each token in the reply, look it up in the vault, and put the original in its place. For a chat reply in plain prose, that is exactly right.
But models don't only write prose. They write tool calls, and tool-call arguments are serialized JSON:
{"to": "<EMAIL_…>", "subject": "Hello <NAME_…>"}
Now say the original behind <NAME_…> is a name someone typed into your app:
Alice", "bcc": "attacker@example.com
Plain text substitution drops that straight into the document. The first quote ends the subject string early. The rest of the name becomes JSON structure.
Here it is end to end. This runs as-is against 0.4.0:
import json
from sether import MemoryVault, restore_json, restore_sync
EMAIL = "<EMAIL_11111111-2222-4333-8444-555555555555>"
NAME = "<NAME_0f0e0d0c-0b0a-4908-8706-050403020100>"
# The vault maps tokens back to originals. This name was typed by someone
# who controls their own name field.
vault = MemoryVault()
vault.set(EMAIL, "bob@example.com")
vault.set(NAME, 'Alice", "bcc": "attacker@example.com')
# Tool-call arguments as the model wrote them: valid JSON, tokens only.
args = json.dumps({"to": EMAIL, "subject": f"Hello {NAME}"})
# Text substitution: the original lands in the document unescaped.
as_text = restore_sync(args, vault)
print(as_text)
print(list(json.loads(as_text)))
# Value by value (new in 0.4.0): the original stays inside its string.
as_json = restore_json(args, vault)
print(as_json)
print(list(json.loads(as_json)))
Output:
{"to": "bob@example.com", "subject": "Hello Alice", "bcc": "attacker@example.com"}
['to', 'subject', 'bcc']
{"to":"bob@example.com","subject":"Hello Alice\", \"bcc\": \"attacker@example.com"}
['to', 'subject']
The model wrote two keys. Text restore produced three. A send_email tool reading those arguments now has a bcc recipient nobody asked for.
restore_json is new in 0.4.0. It parses the document, restores each string value on its own, and lets json.dumps escape it. The same name stays inside subject, as data.
Who could actually trigger it
I want to be precise here, because "PII library had an injection bug" can sound bigger or smaller than it is.
- The original has to carry a
"to add keys, or end in a\to break the document. And an attacker has to be able to influence it, for example their own name. - The built-in detector paths checked in the affected releases did not capture a
". The demonstrated key-addition case therefore uses a custom or externally populated mapping. - Two built-in detectors can capture a trailing backslash: the labeled-password detector (in the opt-in
secrets_detectorspack) and the address detector (in the opt-inidentity_detectorspack). That breaks the document. - Custom detectors, vaults filled from other systems, and direct
vault.setcalls can carry any character. There, full key injection is possible.
The backslash case, with a detector that ships in the package and no custom code:
import json
from sether import TOKEN_RE, MemoryVault, redact_sync, restore_json, restore_sync, secrets_detectors
# A built-in detector, no custom code: the labeled-password detector
# captures the trailing backslash as part of the password.
vault = MemoryVault()
prompt = redact_sync("password: hunter2\\", secrets_detectors, vault)
token = TOKEN_RE.search(prompt).group(0)
args = json.dumps({"note": token, "to": "bob@example.com"})
as_text = restore_sync(args, vault)
print(as_text)
try:
json.loads(as_text)
except json.JSONDecodeError as err:
print(type(err).__name__)
print(restore_json(args, vault))
Output:
{"note": "hunter2\", "to": "bob@example.com"}
JSONDecodeError
{"note":"hunter2\\","to":"bob@example.com"}
It wasn't only in code that called Sether
Sether's own restore paths did the same thing. I ran the same tool-call arguments through the code at the v0.2.0 tag (the last release on PyPI), the v0.3.0 tag, and 0.4.0, and parsed what came out:
| Restore path | 0.2.0 and 0.3.0: keys after parsing | 0.4.0: keys after parsing |
|---|---|---|
sse_restore_iter, JSON data: payload |
to, subject, bcc |
to, subject |
SetherWSGIMiddleware, application/json body |
to, subject, bcc |
to, subject |
wrap_openai, JSON-mode reply |
to, subject, bcc |
to, subject |
wrap_openai, streamed JSON-mode deltas, joined |
to, subject, bcc |
to, subject (left tokenized) |
That last row is a deliberate change. Streamed JSON-mode deltas are fragments of one document, so 0.4.0 no longer restores them one by one. Join them and call restore_json.
What changed in 0.4.0
New: restore_json, restore_json_value and Sether.restore_json
restore_json(text, vault)takes serialized JSON and returns serialized JSON (compact). Each string value is restored and re-escaped.restore_json_value(value, vault)does the same for a value you already parsed, like an Anthropictool_useblock'sinput. It returns a new value and doesn't touch the input.sether.restore_json(text)is the same thing on theSetherfacade, using its shared vault.- Object keys are never restored.
- Invalid JSON raises
json.JSONDecodeError, and a non-string raisesTypeError. There is no fallback to text replacement.
The facade, as a round trip:
import json
from sether import TOKEN_RE, Sether
sether = Sether()
# Outgoing: the prompt the model sees.
prompt = sether.redact_sync("Email alice@example.com about the refund")
print(prompt)
# Incoming: the model answers in JSON mode and quotes the token back.
token = TOKEN_RE.search(prompt).group(0)
reply = json.dumps({"action": "send_email", "to": token})
# Same vault, JSON-safe restore (new in 0.4.0).
print(sether.restore_json(reply, strict=True))
Output (the UUID in the token is random on every run):
Email <EMAIL_cb05029d-d615-403d-b9b5-87197b06b495> about the refund
{"action":"send_email","to":"alice@example.com"}
New: strict mode
By default, a token Sether can't resolve passes through untouched. That's quiet in the wrong way. A model can invent a token, copy one with the wrong case, or put one in a key.
strict=True raises UnresolvedTokenError (a ValueError) instead. Its tokens attribute lists every token that would survive. It never includes restored originals. restore_sync, Sether.restore_sync, restore_json and restore_json_value all accept it.
import json
from sether import MemoryVault, UnresolvedTokenError, restore_json
EMAIL = "<EMAIL_11111111-2222-4333-8444-555555555555>"
vault = MemoryVault()
vault.set(EMAIL, "bob@example.com")
# The model copied one real token, invented one, and changed the case of one.
args = json.dumps({
"to": EMAIL,
"cc": "<EMAIL_99999999-8888-4777-8666-555555555555>",
"bcc": EMAIL.lower(),
})
# Default: anything that can't be restored passes through silently.
print(restore_json(args, vault))
# Strict: raise instead, and name every token that would survive.
try:
restore_json(args, vault, strict=True)
except UnresolvedTokenError as err:
print(err.tokens)
Output:
{"to":"bob@example.com","cc":"<EMAIL_99999999-8888-4777-8666-555555555555>","bcc":"<email_11111111-2222-4333-8444-555555555555>"}
('<EMAIL_99999999-8888-4777-8666-555555555555>', '<email_11111111-2222-4333-8444-555555555555>')
Strict mode is not authorization. A model can still put a real token in the wrong field, like the customer's own address in bcc. Validate tool arguments before acting on them.
Also new at the package root: TOKEN_RE (it used to live only in sether.stream) and the JsonValue type.
Fixed: Sether's own restore paths restore JSON value by value
These paths now change only the string values that hold a token, and re-encode just those. Every other byte stays as it was: formatting, key order, escapes and numbers. Keys are never restored.
sse_restore_iter/create_sse_restore_stream: adata:payload that parses as JSON is restored value by value.[DONE]and plain-text payloads are still restored as text. In a text payload, a line break inside an original used to end the line, so the rest could injectevent:orid:fields. Now the original continues on a newdata:line.wrap_httpx,SetherASGIMiddleware,SetherWSGIMiddleware: anevent-streambody is handled perdata:payload, as above. A body that parses as JSON is restored value by value. A JSON-typed body that doesn't parse as a whole (NDJSON, JSON Lines) is restored line by line, and lines that aren't valid JSON stay tokenized. Other text is restored as text.wrap_openai/wrap_anthropic: when the request asks for JSON output (response_formatjson_objectorjson_schema; Anthropicoutput_config.format, or the deprecatedoutput_format, withjson_schema), the reply is restored value by value. If it isn't valid JSON, it stays tokenized. Streamed JSON-mode deltas stay tokenized.
New, opt-in: restore tool-call arguments in the SDK wrappers
Tool-call arguments still stay tokenized by default. The tests pin that. If you want real values in them, pass restore_tool_arguments=True. Non-streaming tool_calls[].function.arguments (OpenAI) and tool_use input (Anthropic) are then restored value by value.
import json
from openai import OpenAI
from sether import Sether, wrap_openai
sether = Sether()
client = wrap_openai(
OpenAI(),
detectors=sether.detectors,
vault=sether.vault,
restore_tool_arguments=True, # new in 0.4.0, default False
)
reply = client.chat.completions.create(
model="gpt-4.1-mini",
messages=[{"role": "user", "content": "Send the invoice to alice@example.com"}],
tools=[{
"type": "function",
"function": {
"name": "send_email",
"parameters": {
"type": "object",
"properties": {"to": {"type": "string"}, "subject": {"type": "string"}},
"required": ["to", "subject"],
},
},
}],
)
for call in reply.choices[0].message.tool_calls or []:
# The model only saw <EMAIL_...>. These arguments now carry real data:
# validate them before your tool acts on them.
print(call.function.name, json.loads(call.function.arguments))
When I ran this, a stand-in for the model received Send the invoice to <EMAIL_…> and answered with a send_email call holding that token. The script printed:
send_email {'to': 'alice@example.com', 'subject': 'Your invoice'}
Restored arguments carry real PII into your tool code. Validate them before you act.
Also in 0.4.0: the 0.3.0 features, on PyPI for the first time
0.3.0 was tagged on GitHub but never reached PyPI. 0.4.0 carries it. If you are coming from 0.2.0, you also get:
- The alias engine (
alias_value,suggest_aliases,shape_alias,AliasVault): swap a value for a realistic decoy instead of a token, and reverse it later. create_multi_region_phone_detector(countries): one phone detector for several regions' national formats.- Label-anchored secrets (
labeled_api_key_detector,labeled_password_detector), both now part ofsecrets_detectors. - Detection fixes for dates of birth and addresses.
Those change what gets detected, so read the 0.3.0 changelog before you upgrade.
Behaviour changes to check before you upgrade
restore_sync,Sether.restore_syncand the restore streams behave exactly as before. Upgrading does not fix your own code if it restores JSON with them. You have to switch torestore_jsonorrestore_json_value.- On Sether's own paths, a token sitting in an object key is no longer restored.
- JSON-mode content that isn't valid JSON, for example cut off by a token limit, now stays tokenized.
- Streamed JSON-mode deltas from
wrap_openai/wrap_anthropicnow stay tokenized. Streamed text deltas outside JSON mode are still restored. - NDJSON lines that aren't valid JSON now stay tokenized.
- Tokens escaped as
<...>are now restored.
Rehearse the upgrade with a fictional original containing a quote and backslash, an unknown token, a truncated JSON reply and an otherwise valid token in an unauthorized destination field. Assert both syntax and allowed fields after restoration. Use a stubbed tool: a successful parse or strict restore must never be treated as permission to send an email or change a record.
These restore APIs and the alias engine remain part of the full MIT libraries. Optional Pro workflows in the official tools are separate; an SDK upgrade does not add a Sether usage quota or require a Pro account. See pricing for that product distinction.
How to upgrade
- Install 0.4.0:
pip install --upgrade "sether>=0.4.0"
- Find the places where you restore text yourself:
grep -rnE "restore_sync|restore_stream|restore_iter|AliasVault" --include="*.py" .
- For each one that can receive JSON (tool-call arguments, JSON-mode replies, JSON response bodies), switch to
restore_jsonfor a string orrestore_json_valuefor a parsed value. Addstrict=Trueif a surviving token should be an error. - If you stream a JSON-mode response, collect the tokenized fragments first, then call
restore_jsonon the joined text. See the limits below. - If you want real values in tool-call arguments from
wrap_openai/wrap_anthropic, passrestore_tool_arguments=True, and validate the arguments before acting.
What 0.4.0 does not fix
- JSON-mode output streamed over SSE (
sse_restore_iter, or a middleware on an event-stream body). Each SSE chunk stays valid JSON. But thedelta.contentfragments join into a second JSON document that the stream never sees whole. An original can land in that document unescaped. Collect the fragments and callrestore_json. - Text restore is still text restore, by design.
restore_syncand the restore streams are plain substitution. So isAliasVault.restore(): on JSON, apply it to each parsed string value, never to the serialized document. restore_jsonre-serializes compactly, so input formatting is not kept. Sether's middleware paths keep exact bytes.- One level at a time. A string value that itself holds serialized JSON is restored as plain text. Parse and restore it separately.
- A JSON payload split across several
data:lines is restored as text. Eachdata:line is handled on its own. - Strict mode only catches token-shaped text in the
<TYPE_...>form. It is not authorization.
Disclosure
- Found by me on 14 October 2026, while building
n8n-nodes-sether. It was not reported by anyone else. - Affected:
sether0.2.0 and earlier on PyPI (and the 0.3.0 tag, which never reached PyPI). Fixed in 0.4.0. - TypeScript:
@raeven-co/sether0.7.0 and earlier, fixed in 0.8.0. That post also covers a separate Express middleware issue that only affects the TypeScript package: Sether 0.8.0: JSON-safe restore. - Advisory: the Sether security advisories on GitHub
- To report a security issue, email the address in the README. Please don't open a public issue.
0.4.0 passes 183 tests on Python 3.12 with httpx installed.
Links
- PyPI: pypi.org/project/sether/
- GitHub: github.com/emorilebo/sether-py
- Changelog: github.com/emorilebo/sether-py/blob/main/CHANGELOG.md
- Python docs: sether.raevenmarket.com.ng/docs/python
Frequently asked questions
Which versions are affected?
sether 0.2.0 and earlier on PyPI, fixed in 0.4.0 (the 0.3.0 tag never reached PyPI), and @raeven-co/sether 0.7.0 and earlier on npm, fixed in 0.8.0. The original value has to contain a quote to add keys, or end in a backslash to break the document, and an attacker has to be able to influence it.
Does upgrading fix my code automatically?
Only where you rely on Sether's own restore paths: the SSE helpers, the httpx, ASGI and WSGI middleware and the SDK wrappers. restore_sync is unchanged, so code that restores serialized JSON with it must switch to restore_json or restore_json_value.
What does strict mode catch?
Token-shaped text that would survive restore: well-formed tokens the vault cannot resolve (invented, expired or from another vault), case-altered copies and tokens in object keys. The error lists tokens only, never originals. It is not authorization, so validate a tool call before you act on it.
How do I upgrade?
Run pip install --upgrade "sether>=0.4.0". TypeScript users run npm install @raeven-co/sether@^0.8.0.
Written by Godfrey Lebo
Founder of Sether. Ships production software for a living and reads every comment below. More about the team.
Comments
Emails and phone numbers in comments are hidden automatically. We take our own medicine.
No comments yet. Ask a question or share how you handle this. First comment gets our undivided attention.
Join the conversation
Questions, corrections and war stories are all welcome. Be kind, stay on topic.