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.

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: Restore the value. Keep the shape. Sether™ / Field notes release Restore the value. Keep the shape. JSON-safe restoration for TypeScript tool calls.
Conceptual illustration with fictional data.
Direct answer

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.

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.
  • @raeven-co/sether 0.8.0 adds restoreJson and restoreJsonValue, which restore each string value on its own and let the serializer escape it. Object keys are never restored.
  • Strict mode (strict: true) throws UnresolvedTokenError instead 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.
  • restoreSync behaves as before. If you restore JSON yourself, switch to restoreJson after 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

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.

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

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.8.0:

ts/01-text-vs-json.ts
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:

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.

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: labeledPasswordDetector (in the opt-in secrets pack) and addressDetector (in the opt-in identity 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:

ts/02-trailing-backslash.ts
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:

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:

JSON
{"__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:

ts/05-restore-json-value.ts
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:

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.

ts/03-strict-mode.ts
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:

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

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 integers beyond Number.MAX_SAFE_INTEGER. Keys are never restored.

  • createSSERestoreStream: 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.
  • wrapFetch: 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.
  • createExpressMiddleware: a string passed to res.send() that parses as JSON is restored value by value. That includes the string Express's own res.json() hands to res.send().
  • wrapOpenAI / wrapAnthropic: 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.

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.

ts/04-wrap-openai-tool-arguments.ts
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:

Output
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, createRestoreStream and sether.restore() behave exactly as before. Upgrading does not fix your own code if it restores JSON with them. You have to switch to restoreJson or restoreJsonValue.
  • 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

  1. Install 0.8.0:
Terminal
npm install @raeven-co/sether@^0.8.0
  1. Find the places where you restore text yourself:
Terminal
grep -rnE "restoreSync|createRestoreStream|\.restore\(\)|AliasVault" src/
  1. For each one that can receive JSON (tool-call arguments, JSON-mode replies, JSON response bodies), switch to restoreJson for a string or restoreJsonValue for a parsed value. Add strict: true if a surviving token should be an error.
  2. If you restore a JSON-mode response streamed over SSE, collect the tokenized delta.content fragments first, then call restoreJson on the joined text. See the limits below.
  3. If you want real values in tool-call arguments from wrapOpenAI / wrapAnthropic, set restoreToolArguments: true, and validate the arguments before acting.
  4. 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.content fragments 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 call restoreJson.
  • Text restore is still text restore, by design. restoreSync, createRestoreStream and sether.restore() are plain substitution. So is AliasVault.restore(): on JSON, apply it to each parsed string value, never to the serialized document.
  • Big integers in restoreJson. It re-serializes through JSON.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 call restoreJsonValue.
  • 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: @raeven-co/sether 0.7.0 and earlier. Fixed in 0.8.0.
  • The Express "__proto__" issue affects every release that ships createExpressMiddleware. Fixed in 0.8.0.
  • Python: sether 0.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.

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

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.