announcement · release · security · json · tool calls · typescript · llm
A quote in a name could add a bcc to an AI tool call. Here is the fix in Sether 0.8.0.
Sether 0.8.0 fixes JSON restore injection paths. See the affected scope, JSON-aware APIs, strict mode, runnable examples and upgrade limits.
Published by Raeven Company LTD. Sether is our product. How we check and correct stories.
In this article
Sether™ / Field notes
release
Restore the value. Keep the shape.
JSON-safe restoration for TypeScript tool calls.
Sether 0.8.0 on npm 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. The new restoreJson and restoreJsonValue helpers restore each string value on its own and re-escape it, strict mode refuses tokens it cannot resolve, and Sether's own SSE, fetch, Express and SDK-wrapper paths now restore JSON value by value.
- 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. @raeven-co/sether0.8.0 addsrestoreJsonandrestoreJsonValue, which restore each string value on its own and let the serializer escape it. Object keys are never restored.- Strict mode (
strict: true) throwsUnresolvedTokenErrorinstead of silently leaving invented, expired or altered tokens in place. - Sether's own SSE, fetch, Express and SDK-wrapper restore paths now restore JSON value by value, and the Express middleware no longer lets a client-sent
"__proto__"key become the body's prototype. restoreSyncbehaves as before. If you restore JSON yourself, switch torestoreJsonafter upgrading.
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, and it was in Sether's own middleware too, not only in code that called it. It is fixed in @raeven-co/sether 0.8.0 and in the Python package, sether 0.4.0. 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.
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.
npm install @raeven-co/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.8.0:
import { MemoryVault, restoreJson, restoreSync } from '@raeven-co/sether';
const EMAIL = '<EMAIL_11111111-2222-4333-8444-555555555555>';
const 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.
const vault = new 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.
const args = JSON.stringify({ to: EMAIL, subject: `Hello ${NAME}` });
// Text substitution: the original lands in the document unescaped.
const asText = restoreSync(args, { vault });
console.log(asText);
console.log(Object.keys(JSON.parse(asText)));
// Value by value (new in 0.8.0): the original stays inside its string.
const asJson = restoreJson(args, { vault });
console.log(asJson);
console.log(Object.keys(JSON.parse(asJson)));
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.
restoreJson is new in 0.8.0. It parses the document, restores each string value on its own, and lets JSON.stringify 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:
labeledPasswordDetector(in the opt-in secrets pack) andaddressDetector(in the opt-in identity pack). 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 {
MemoryVault,
TOKEN_RE,
redactSync,
restoreJson,
restoreSync,
secretsDetectors,
} from '@raeven-co/sether';
// A built-in detector, no custom code: the labeled-password detector
// captures the trailing backslash as part of the password.
const vault = new MemoryVault();
const prompt = redactSync('password: hunter2\\', { detectors: secretsDetectors, vault });
const token = prompt.match(TOKEN_RE)?.[0] ?? '';
const args = JSON.stringify({ note: token, to: 'bob@example.com' });
const asText = restoreSync(args, { vault });
console.log(asText);
try {
JSON.parse(asText);
} catch (err) {
console.log((err as Error).name);
}
console.log(restoreJson(args, { vault }));
Output:
{"note":"hunter2\","to":"bob@example.com"}
SyntaxError
{"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 0.7.0 package tarball and through 0.8.0, and parsed what came out:
| Restore path | 0.7.0: keys after parsing | 0.8.0: keys after parsing |
|---|---|---|
createSSERestoreStream, JSON data: payload |
to, subject, bcc |
to, subject |
wrapFetch, application/json body |
to, subject, bcc |
to, subject |
createExpressMiddleware, res.send(string) |
to, subject, bcc |
to, subject |
wrapOpenAI, JSON-mode reply |
to, subject, bcc |
to, subject |
The same review turned up a second, separate problem in the Express middleware. It rebuilt req.body with plain assignment. A client could send this:
{"__proto__": {"isAdmin": true}}
In 0.7.0, after the middleware ran, req.body.isAdmin was true, and Object.keys(req.body) listed only the other keys. Validation that walks own keys could miss it. This has been there since the middleware shipped. In 0.8.0 req.body.isAdmin is undefined, and "__proto__" stays an ordinary key, exactly as JSON.parse produced it.
What changed in 0.8.0
New: restoreJson and restoreJsonValue
restoreJson(json, { vault })takes serialized JSON and returns serialized JSON. Each string value is restored and re-escaped.restoreJsonValue(value, { vault })does the same for a value you already parsed. It returns a new value and keeps a"__proto__"key as data.- Object keys are never restored.
- Invalid JSON throws
SyntaxError. There is no fallback to text replacement. - Tokens whose angle brackets a serializer escaped as
</>are still restored.
An Anthropic tool_use block arrives with its input already parsed, so that's restoreJsonValue:
import { MemoryVault, restoreJsonValue } from '@raeven-co/sether';
const EMAIL = '<EMAIL_11111111-2222-4333-8444-555555555555>';
const NAME = '<NAME_0f0e0d0c-0b0a-4908-8706-050403020100>';
const vault = new MemoryVault();
vault.set(EMAIL, 'bob@example.com');
vault.set(NAME, 'Alice", "bcc": "attacker@example.com');
// An Anthropic tool_use block: its `input` arrives already parsed.
const block = {
type: 'tool_use',
id: 'toolu_01',
name: 'send_email',
input: { to: EMAIL, subject: `Hello ${NAME}` },
};
const input = restoreJsonValue(block.input, { vault, strict: true });
console.log(input);
Output:
{
to: 'bob@example.com',
subject: 'Hello Alice", "bcc": "attacker@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 throws UnresolvedTokenError instead. Its tokens property lists every token that would survive. It never includes restored originals. restoreSync, restoreJson and restoreJsonValue all accept it.
import { MemoryVault, UnresolvedTokenError, restoreJson } from '@raeven-co/sether';
const EMAIL = '<EMAIL_11111111-2222-4333-8444-555555555555>';
const vault = new MemoryVault();
vault.set(EMAIL, 'bob@example.com');
// The model copied one real token, invented one, and changed the case of one.
const args = JSON.stringify({
to: EMAIL,
cc: '<EMAIL_99999999-8888-4777-8666-555555555555>',
bcc: EMAIL.toLowerCase(),
});
// Default: anything that can't be restored passes through silently.
console.log(restoreJson(args, { vault }));
// Strict: throw instead, and name every token that would survive.
try {
restoreJson(args, { vault, strict: true });
} catch (err) {
if (err instanceof UnresolvedTokenError) console.log(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: TOKEN_RE (the token pattern) and the JsonValue type are exported, so integrations don't need to copy the pattern.
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 integers beyond Number.MAX_SAFE_INTEGER. Keys are never restored.
createSSERestoreStream: 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.wrapFetch: 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.createExpressMiddleware: a string passed tores.send()that parses as JSON is restored value by value. That includes the string Express's ownres.json()hands tores.send().wrapOpenAI/wrapAnthropic: 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.
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, set restoreToolArguments: true. Non-streaming tool_calls[].function.arguments (OpenAI) and tool_use.input (Anthropic) are then restored value by value.
import OpenAI from 'openai';
import { Sether, wrapOpenAI } from '@raeven-co/sether';
const sether = new Sether();
const openai = wrapOpenAI(new OpenAI(), {
detectors: sether.detectors,
vault: sether.vault,
restoreToolArguments: true, // new in 0.8.0, default false
});
const reply = await openai.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 (const call of reply.choices[0]?.message.tool_calls ?? []) {
if (call.type !== 'function') continue;
// The model only saw <EMAIL_...>. These arguments now carry real data:
// validate them before your tool acts on them.
console.log(call.function.name, JSON.parse(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.
Fixed: the Express middleware no longer rewrites binary bodies
res.send(buffer) used to send {"0":..,"1":..} instead of the bytes, and a Date inside a JSON body became {}. Only arrays and plain objects are walked now. Buffers, typed arrays, Dates and other non-plain objects pass through unchanged. The "__proto__" fix above is in the same middleware.
Behaviour changes to check before you upgrade
restoreSync,createRestoreStreamandsether.restore()behave exactly as before. Upgrading does not fix your own code if it restores JSON with them. You have to switch torestoreJsonorrestoreJsonValue.- 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.
- 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.8.0:
npm install @raeven-co/sether@^0.8.0
- Find the places where you restore text yourself:
grep -rnE "restoreSync|createRestoreStream|\.restore\(\)|AliasVault" src/
- For each one that can receive JSON (tool-call arguments, JSON-mode replies, JSON response bodies), switch to
restoreJsonfor a string orrestoreJsonValuefor a parsed value. Addstrict: trueif a surviving token should be an error. - If you restore a JSON-mode response streamed over SSE, collect the tokenized
delta.contentfragments first, then callrestoreJsonon the joined text. See the limits below. - If you want real values in tool-call arguments from
wrapOpenAI/wrapAnthropic, setrestoreToolArguments: true, and validate the arguments before acting. - If you use
createExpressMiddleware, upgrading is the fix for"__proto__".
0.8.0 also carries 0.7.1, a docs-only patch that was tagged but never reached npm.
What 0.8.0 does not fix
- JSON-mode output streamed over SSE. Each SSE chunk stays valid JSON. But the
delta.contentfragments join into a second JSON document that the stream never sees whole. An original can land in that document unescaped. Don't SSE-restore JSON-mode streams: collect the fragments and callrestoreJson. - Text restore is still text restore, by design.
restoreSync,createRestoreStreamandsether.restore()are plain substitution. So isAliasVault.restore(): on JSON, apply it to each parsed string value, never to the serialized document. - Big integers in
restoreJson. It re-serializes throughJSON.parse, so integers above 2^53 lose precision. Sether's middleware paths keep exact bytes. If it matters in your own code, parse with your own reviver and callrestoreJsonValue. - 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:
@raeven-co/sether0.7.0 and earlier. Fixed in 0.8.0. - The Express
"__proto__"issue affects every release that shipscreateExpressMiddleware. Fixed in 0.8.0. - Python:
sether0.2.0 and earlier on PyPI, fixed in 0.4.0. The Python post is here: Sether for Python 0.4.0: JSON-safe restore. - Advisory: the Sether security advisories on GitHub
- To report a security issue, follow SECURITY.md. Please don't open a public issue.
0.8.0 ships with 252 passing tests (201 before this release), a ReDoS scan of 224 regex patterns with 0 flagged unsafe, and one runtime dependency, libphonenumber-js.
Links
- npm: www.npmjs.com/package/@raeven-co/sether
- GitHub: github.com/raeven-co/sether
- Changelog: github.com/raeven-co/sether/blob/main/CHANGELOG.md
- Try it in the browser: the live sandbox
Frequently asked questions
Which versions are affected?
@raeven-co/sether 0.7.0 and earlier on npm, fixed in 0.8.0, and sether 0.2.0 and earlier on PyPI, fixed in 0.4.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. The affected built-in paths checked here did not capture a quote; custom detectors, externally populated vaults and direct vault.set calls can.
Does upgrading fix my code automatically?
Only where you rely on Sether's own restore paths: the SSE stream, wrapFetch, the Express middleware and the SDK wrappers. restoreSync and sether.restore() are unchanged, so code that restores serialized JSON with them must switch to restoreJson or restoreJsonValue.
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 npm install @raeven-co/sether@^0.8.0. Python users run pip install --upgrade "sether>=0.4.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.