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.

Godfrey Lebo Founder, Sether · 12 min read

Published by Raeven Company LTD. Sether is our product. How we check and correct stories.

In this article
Conceptual Sether illustration: Safe restoration. In Python too. Sether™ / Field notes release Safe restoration. In Python too. Restore parsed values before an authorised action.
Conceptual illustration with fictional data.
Direct answer

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.

TL;DR
  • Restoring tokens into serialized JSON as plain text put original values back unescaped. An original holding a " could add keys, for example a bcc in an email tool call.
  • sether 0.4.0 adds restore_json, restore_json_value and Sether.restore_json, which restore each string value on its own and let json.dumps escape it. Object keys are never restored.
  • Strict mode (strict=True) raises UnresolvedTokenError instead 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

Your app and Sether sit inside your process with the vault. The prompt "Email alice@example.com" leaves as "Email <EMAIL_…>". The LLM provider only sees tokens. The reply "Sent to <EMAIL_…>" comes back and Sether restores it to "Sent to alice@example.com".

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.

Terminal
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:

JSON
{"to": "<EMAIL_…>", "subject": "Hello <NAME_…>"}

Now say the original behind <NAME_…> is a name someone typed into your app:

Output
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:

py/01_text_vs_json.py
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:

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.

The bug versus the fix: the same tool-call JSON restored as text gains an extra bcc key; restored value by value it keeps two keys and the quotes are escaped.

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_detectors pack) and the address detector (in the opt-in identity_detectors pack). That breaks the document.
  • Custom detectors, vaults filled from other systems, and direct vault.set calls 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:

py/02_trailing_backslash.py
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:

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 Anthropic tool_use block's input. It returns a new value and doesn't touch the input.
  • sether.restore_json(text) is the same thing on the Sether facade, using its shared vault.
  • Object keys are never restored.
  • Invalid JSON raises json.JSONDecodeError, and a non-string raises TypeError. There is no fallback to text replacement.

The facade, as a round trip:

py/05_facade_restore_json.py
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):

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

py/03_strict_mode.py
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:

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

Which restore paths are JSON-safe in @raeven-co/sether 0.8.0 and sether 0.4.0.

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: a data: 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 inject event: or id: fields. Now the original continues on a new data: line.
  • wrap_httpx, SetherASGIMiddleware, SetherWSGIMiddleware: an event-stream body is handled per data: 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_format json_object or json_schema; Anthropic output_config.format, or the deprecated output_format, with json_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.

py/04_wrap_openai_tool_arguments.py
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:

Output
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 of secrets_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_sync and the restore streams behave exactly as before. Upgrading does not fix your own code if it restores JSON with them. You have to switch to restore_json or restore_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_anthropic now 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

  1. Install 0.4.0:
Terminal
pip install --upgrade "sether>=0.4.0"
  1. Find the places where you restore text yourself:
Terminal
grep -rnE "restore_sync|restore_stream|restore_iter|AliasVault" --include="*.py" .
  1. For each one that can receive JSON (tool-call arguments, JSON-mode replies, JSON response bodies), switch to restore_json for a string or restore_json_value for a parsed value. Add strict=True if a surviving token should be an error.
  2. If you stream a JSON-mode response, collect the tokenized fragments first, then call restore_json on the joined text. See the limits below.
  3. If you want real values in tool-call arguments from wrap_openai / wrap_anthropic, pass restore_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 the delta.content fragments join into a second JSON document that the stream never sees whole. An original can land in that document unescaped. Collect the fragments and call restore_json.
  • Text restore is still text restore, by design. restore_sync and the restore streams are plain substitution. So is AliasVault.restore(): on JSON, apply it to each parsed string value, never to the serialized document.
  • restore_json re-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. Each data: 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: sether 0.2.0 and earlier on PyPI (and the 0.3.0 tag, which never reached PyPI). Fixed in 0.4.0.
  • TypeScript: @raeven-co/sether 0.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.

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.

Godfrey Lebo

Written by Godfrey Lebo

Founder of Sether. Ships production software for a living and reads every comment below. More about the team.

Share: X LinkedIn Markdown

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.

By posting you agree to our terms. We store a one-way hash of your IP to fight spam, never the IP itself.