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

Published 2026-10-17, updated 2026-10-17 by Godfrey Lebo.

## 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](https://github.com/raeven-co/sether/security/advisories).

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".](https://sether.ai/blog/img/sether-release-one-picture.png)

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.

```bash
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:

```text
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
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:

```text
{"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.](https://sether.ai/blog/img/sether-0-8-0-bug-vs-fix.png)

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

```text
{"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
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:

```text
{
  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
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:

```text
{"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.](https://sether.ai/blog/img/sether-release-json-safe-paths.png)

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

```text
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](/pricing) for that product distinction.

## How to upgrade

1. Install 0.8.0:

```bash
npm install @raeven-co/sether@^0.8.0
```

1. Find the places where you restore text yourself:

```bash
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](/blog/sether-python-0-4-0-json-safe-restore).
- Advisory: [the Sether security advisories on GitHub](https://github.com/raeven-co/sether/security/advisories)
- To report a security issue, follow [SECURITY.md](https://github.com/raeven-co/sether/blob/main/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](https://www.npmjs.com/package/@raeven-co/sether)
- GitHub: [github.com/raeven-co/sether](https://github.com/raeven-co/sether)
- Changelog: [github.com/raeven-co/sether/blob/main/CHANGELOG.md](https://github.com/raeven-co/sether/blob/main/CHANGELOG.md)
- Try it in the browser: [the live sandbox](/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"`.


Canonical: https://sether.ai/blog/sether-0-8-0-json-safe-restore
