prefix-safe-json 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +27 -4
  2. package/README.md +273 -398
  3. package/dist/ajv.d.ts +2 -0
  4. package/dist/ajv.d.ts.map +1 -0
  5. package/dist/ajv.js +19 -0
  6. package/dist/ajv.js.map +1 -0
  7. package/dist/conformance/runner.d.ts +19 -0
  8. package/dist/conformance/runner.d.ts.map +1 -0
  9. package/dist/conformance/runner.js +78 -0
  10. package/dist/conformance/runner.js.map +1 -0
  11. package/dist/conformance/types.d.ts +115 -0
  12. package/dist/conformance/types.d.ts.map +1 -0
  13. package/dist/conformance/types.js +2 -0
  14. package/dist/conformance/types.js.map +1 -0
  15. package/dist/conformance.d.ts +3 -0
  16. package/dist/conformance.d.ts.map +1 -0
  17. package/dist/conformance.js +11 -0
  18. package/dist/conformance.js.map +1 -0
  19. package/dist/coordinator/coordinator.d.ts +25 -11
  20. package/dist/coordinator/coordinator.d.ts.map +1 -1
  21. package/dist/coordinator/coordinator.js +140 -25
  22. package/dist/coordinator/coordinator.js.map +1 -1
  23. package/dist/coordinator/types.d.ts +3 -2
  24. package/dist/coordinator/types.d.ts.map +1 -1
  25. package/dist/gate/gate.d.ts +2 -2
  26. package/dist/gate/gate.d.ts.map +1 -1
  27. package/dist/gate/gate.js +4 -4
  28. package/dist/gate/gate.js.map +1 -1
  29. package/dist/guard/provider-guard.js +1 -1
  30. package/dist/guard/provider-guard.js.map +1 -1
  31. package/dist/guard/types.d.ts +11 -4
  32. package/dist/guard/types.d.ts.map +1 -1
  33. package/dist/index.d.ts +11 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/standard-schema.d.ts +2 -0
  37. package/dist/standard-schema.d.ts.map +1 -0
  38. package/dist/standard-schema.js +11 -0
  39. package/dist/standard-schema.js.map +1 -0
  40. package/dist/validation/ajv-validator.d.ts +43 -0
  41. package/dist/validation/ajv-validator.d.ts.map +1 -0
  42. package/dist/validation/ajv-validator.js +63 -0
  43. package/dist/validation/ajv-validator.js.map +1 -0
  44. package/dist/validation/standard-schema-validator.d.ts +41 -0
  45. package/dist/validation/standard-schema-validator.d.ts.map +1 -0
  46. package/dist/validation/standard-schema-validator.js +131 -0
  47. package/dist/validation/standard-schema-validator.js.map +1 -0
  48. package/dist/validation/types.d.ts +60 -0
  49. package/dist/validation/types.d.ts.map +1 -0
  50. package/dist/validation/types.js +77 -0
  51. package/dist/validation/types.js.map +1 -0
  52. package/package.json +13 -1
package/README.md CHANGED
@@ -1,316 +1,122 @@
1
1
  # prefix-safe-json
2
2
 
3
- > **v0.4.3** — Core APIs are stable; raw provider adapters remain
4
- > experimental. See [Current Status](#current-status) and
5
- > [CHANGELOG.md](CHANGELOG.md).
3
+ [![npm version](https://img.shields.io/npm/v/prefix-safe-json.svg)](https://www.npmjs.com/package/prefix-safe-json)
4
+ [![CI](https://github.com/canblmz1/prefix-safe-json/actions/workflows/ci.yml/badge.svg)](https://github.com/canblmz1/prefix-safe-json/actions/workflows/ci.yml)
5
+ [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license)
6
6
 
7
- **Execution integrity for streamed LLM tool calls: prove raw arguments and
8
- terminal state before caller-owned side effects.**
7
+ **Fail-closed execution integrity for streamed LLM tool calls.**
9
8
 
10
- Use `prefix-safe-json` when an LLM streams arguments for a tool that can write
11
- files, run commands, send requests, or perform another irreversible operation.
12
- It distinguishes complete, unfabricated raw arguments from truncated or
13
- unconfirmed input, including SDK-projected or repaired values that merely look
14
- complete. JSON validity is not execution authority.
15
-
16
- ## Install
9
+ Prevent truncated, conflicting, or unconfirmed streamed tool arguments
10
+ from reaching side effects. `prefix-safe-json` distinguishes complete,
11
+ unfabricated raw arguments from truncated or unconfirmed input — including
12
+ SDK-projected or repaired values that merely *look* complete. **JSON
13
+ validity is not execution authority.**
17
14
 
18
15
  ```bash
19
16
  pnpm add prefix-safe-json ai
20
17
  # or: npm install prefix-safe-json ai
21
18
  ```
22
19
 
23
- The published package is ESM-only and supports Node `>=18.0.0`. Repository
24
- development and release jobs remain on Node 22/24 because the development
25
- toolchain and the exact `ai@7.0.77` lifecycle proof require newer Node; those
26
- requirements do not apply to the package's runtime dependency graph.
20
+ ## 30-line safe execution example
27
21
 
28
- ## Supply-chain verification
22
+ ```javascript
23
+ import { streamText } from "ai";
24
+ import { createAiSdkExecutionGuard, createAiSdkExecutionLock } from "prefix-safe-json";
29
25
 
30
- Verify releases independently rather than relying on publisher claims. The
31
- `0.4.2` npm artifact has npm provenance from this repository's GitHub Actions
32
- publish workflow, and its tarball can be rebuilt and compared with the release
33
- tag by running `npm run verify:published-release -- 0.4.2` from a clone with
34
- tags fetched. `0.4.2` has no npm `gitHead` (see
35
- [`RELEASE_INTEGRITY.md`](docs/RELEASE_INTEGRITY.md) for why); the verifier
36
- establishes its release commit through verified provenance instead, failing
37
- closed if that provenance is missing or disagrees. See the command-driven
38
- [maintainer audit](docs/MAINTAINER_AUDIT.md),
39
- the exact [release/hash mapping](docs/RELEASE_INTEGRITY.md), the
40
- [runtime dependency graph](docs/RUNTIME_DEPENDENCIES.md), and the
41
- [execution-critical source map](docs/EXECUTION_AUDIT_SURFACE.md).
26
+ // 1. Lock tool definitions before the AI SDK sees them - removes every
27
+ // SDK-invoked callback that could run your code before this guard does.
28
+ const lockedTools = createAiSdkExecutionLock({
29
+ write_file: { description: "Write a UTF-8 text file", inputSchema: writeFileSchema },
30
+ });
42
31
 
43
- ## Recommended AI SDK boundary
32
+ // 2. Stream, feeding the real fullStream into the guard.
33
+ const result = streamText({ model, prompt, tools: lockedTools });
34
+ const guard = createAiSdkExecutionGuard({ schemas: { write_file: writeFileSchema } });
35
+ for await (const part of result.fullStream) {
36
+ guard.push(part);
37
+ }
44
38
 
45
- Lock the local tool definitions before the AI SDK sees them, feed the real
46
- `fullStream` into the guard, and keep the irreversible operation in caller-owned
47
- manual dispatch:
39
+ // 3. finish() signals the stream ended. Every decision is replayable
40
+ // diagnostic state - it does not perform anything by itself.
41
+ const final = guard.finish();
48
42
 
49
- ```javascript
50
- import { jsonSchema, streamText } from "ai";
51
- import {
52
- createAiSdkExecutionGuard,
53
- createAiSdkExecutionLock,
54
- } from "prefix-safe-json";
55
-
56
- const writeFileSchema = {
57
- type: "object",
58
- properties: {
59
- path: { type: "string" },
60
- content: { type: "string" },
61
- },
62
- required: ["path", "content"],
63
- additionalProperties: false,
64
- };
65
-
66
- export async function runToolCall(model, prompt, callerOwnedSideEffect) {
67
- const lockedTools = createAiSdkExecutionLock({
68
- write_file: {
69
- description: "Write a UTF-8 text file",
70
- inputSchema: jsonSchema(writeFileSchema),
71
- },
72
- });
73
-
74
- const result = streamText({ model, prompt, tools: lockedTools });
75
- const guard = createAiSdkExecutionGuard({
76
- schemas: { write_file: writeFileSchema },
77
- });
78
-
79
- for await (const part of result.fullStream) {
80
- guard.push(part);
81
- }
82
-
83
- const final = guard.finish(); // replayable diagnostic state
84
- for (const observed of final.decisions) {
85
- const authority = guard.takeDecision(observed.internalId);
86
- if (authority) await callerOwnedSideEffect(authority.value);
87
- }
43
+ // 4. The caller owns dispatch: take each call's authority exactly once,
44
+ // then run the real, irreversible side effect yourself.
45
+ for (const observed of final.decisions) {
46
+ const authority = guard.takeDecision(observed.internalId);
47
+ if (authority) await writeFile(authority.value.path, authority.value.content);
88
48
  }
89
49
  ```
90
50
 
91
- In this pattern:
92
-
93
- - Never execute `chunk.input` or an SDK-projected/repaired value. Dispatch only
94
- the `value` returned once by `takeDecision(internalId)`.
95
- - `finish()` remains replayable diagnostic state. `takeDecision()` returns a
96
- call's executable authority at most once and never consumes another call.
97
- - Provider-executed tools are outside the local guarantee.
98
- - Mutating or reconstructing a locked definition after locking voids the
99
- guarantee; pass `lockedTools` through unchanged.
100
- - Application-level authorization and idempotency remain caller-owned. The
101
- one-shot method prevents accidental replay through this guard instance; it
102
- is not a distributed transaction or idempotency system.
103
- - `prefix-safe-json` returns decisions; it never hides or performs execution.
104
-
105
- [`examples/ai-sdk-lifecycle-proof.mjs`](examples/ai-sdk-lifecycle-proof.mjs)
106
- runs deterministic execution-ownership proofs through the real `streamText()`
107
- lifecycle of the exact pins `ai@5.0.244`, `ai@6.0.264`, and `ai@7.0.77`, with
108
- no API key, network request, or paid model call. Run it with
109
- `pnpm run example:ai-sdk-lifecycle-proof`. This is not a claim that every
110
- version in those majors is tested. See [Compatibility](docs/COMPATIBILITY.md).
111
-
112
- **What the decisions mean**
51
+ `prefix-safe-json` never calls `writeFile` (or anything like it) itself.
52
+ The last line above is the only place a side effect happens, and it's
53
+ entirely yours. See [`examples/`](examples/) for full, runnable
54
+ demonstrations against real provider/SDK shapes, no mocked internals.
55
+
56
+ ## Why this exists
57
+
58
+ LLM providers stream tool-call arguments as small JSON chunks. During
59
+ streaming, the JSON is frequently incomplete: strings split mid-word,
60
+ UTF-8 characters split mid-byte, containers left unclosed, numbers
61
+ terminated at chunk boundaries — and even once the JSON *looks* complete,
62
+ nothing about its shape says whether the provider actually confirmed the
63
+ call is done, or just stopped for an unrelated reason (a token limit, an
64
+ error, a content filter) partway through.
65
+
66
+ > **The core invariant: parse completion is not execution authority.**
67
+
68
+ Structural validity, confirmed completeness, and execution authority are
69
+ three separate questions. Most streaming JSON helpers — including some
70
+ already used for LLM tool-call arguments in the wild — are built to show
71
+ *something* as early as possible, even if that means guessing. Given a
72
+ stream that has so far delivered `{"city":"Tok` (the model is still
73
+ typing "Tokyo"):
74
+
75
+ | | `city` field after this chunk |
76
+ |---|---|
77
+ | [vercel/ai's `fixJson`](https://github.com/vercel/ai/blob/main/packages/ai/src/util/fix-json.ts) | `"Tok"` — closes the open string as-is, reports a successful parse |
78
+ | [langchain's `parsePartialJson`](https://github.com/langchain-ai/langchainjs/blob/main/libs/langchain-core/src/utils/json.ts) | `"Tok"` — returns whatever string content it collected |
79
+ | **`prefix-safe-json`** (`snapshot().stableValue`) | *(absent — `city` is not committed yet)* |
80
+
81
+ Verified by cloning both projects and running their actual code
82
+ side-by-side with this library. Neither is a bug — they're built for
83
+ progressively rendering a value in a UI, where showing `"Tok"` then
84
+ `"Tokyo"` a moment later is good UX. `prefix-safe-json` answers a
85
+ different question: *is it safe to act on this value yet* — where
86
+ treating `"Tok"` as the city would be wrong. If you need live
87
+ "filling in..." UI text, a partial-JSON renderer is the right tool. If
88
+ you need to know precisely when it's safe to execute a tool call with the
89
+ parsed arguments, that's what this library is for.
90
+
91
+ ## What `execute` / `retry` / `reject` mean
113
92
 
114
93
  Every tool call gets exactly one verdict:
115
94
 
116
- - **`execute`** — complete, unfabricated, and (if a schema is registered)
117
- schema-valid. `decision.value` is safe to pass to your tool.
95
+ - **`execute`** — complete, unfabricated, and (if a schema/validator is
96
+ registered) valid. `decision.value` is safe to pass to your tool.
118
97
  - **`retry`** — nothing is wrong with what arrived; there just isn't a
119
98
  trustworthy complete value yet. Continue generation.
120
99
  - **`reject`** — the data itself is the problem (malformed JSON, a schema
121
100
  mismatch, a resource limit, a provider error, a content-policy
122
101
  termination). Retrying the same input won't help.
123
102
 
124
- **Why the model's finish reason matters as much as the JSON's shape**
125
-
126
- A tool call cut short by a token limit, a provider error, or a content
127
- filter can still contain syntactically complete, schema-valid JSON — the
128
- model was simply stopped before finishing the *rest* of its response, not
129
- necessarily mid-argument. Executing on JSON shape alone means trusting data
130
- nobody, including the model, confirmed as final. This is exactly the failure
131
- mode independently reported and reproduced in
132
- [vercel/ai#19063](https://github.com/vercel/ai/issues/19063): tool calls
133
- executing regardless of an unsafe finish reason, across AI SDK v5, v6, and
134
- v7, with fixes landing across all three release lines.
135
- `prefix-safe-json` does not patch or depend on that fix — it's a
136
- provider-independent guard that requires positive proof a stream ended
137
- safely before treating any tool call as final, regardless of which SDK,
138
- provider, or framework hands it the data. See
139
- `test/guard/ai-sdk-compatibility.test.ts` for the cross-version test
140
- evidence.
141
-
142
- **Execution ownership: what must never also happen**
143
-
144
- The guard's decisions only mean something if they're the sole thing deciding
145
- whether your tool function runs. The recommended pattern above prevents the
146
- AI SDK's own tool-calling loop from running caller callbacks first —
147
- in order from strongest guarantee to weakest:
148
-
149
- - **Strongest — `createAiSdkExecutionLock()`** — wrap your tool definitions
150
- with it before passing them to `streamText()`/`generateText()`. It removes
151
- every AI SDK-invoked callback capable of running your code before this
152
- library's decision — not just `execute`, but also `onInputStart`,
153
- `onInputDelta`, and `onInputAvailable` (verified directly against
154
- `ai@5`/`ai@6`/`ai@7`'s own real source: all three fire unconditionally,
155
- independent of `needsApproval`/approval status — `needsApproval: true`
156
- alone does **not** stop them, which is exactly the gap this function
157
- closes). It also forces `needsApproval: true`, which still closes the
158
- `execute` gap specifically on `ai@6`+ via the SDK's own approval
159
- mechanism. It also rejects a **function-valued `description`**
160
- (`ai@7`+ invokes it during tool preparation, before the model call even
161
- begins — arbitrary caller code on the same pre-decision timeline as the
162
- callback trio; a string `description` is unaffected on every major), and
163
- any **provider tool shape whose real execution location it cannot
164
- verify**: `isProviderExecuted: true` (real operation runs entirely on the
165
- model provider's remote infrastructure), `ai@6`'s discriminator-less
166
- `{ type: "provider" }`, and `ai@5`'s discriminator-less
167
- `{ type: "provider-defined" }` are all rejected rather than silently
168
- accepted; `ai@7`'s `{ type: "provider", isProviderExecuted: false }` is
169
- accepted (verified to have no `execute` field at all on that shape, so the
170
- SDK can never auto-run it). The guarantee is precisely about **this
171
- function's own output, unchanged, on a supported local tool definition**:
172
- it says nothing about a tool that bypasses this function, one
173
- mutated/reconstructed after this function returns it, or a rejected
174
- shape. Still dispatch manually from
175
- `guard.takeDecision(observed.internalId)`, exactly as shown above; this only closes the
176
- door on the SDK (or your own callbacks) doing it *for* you. See
177
- [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md#execution-ownership-tool-resulttool-error-as-evidence).
178
- - **Safe, supported pattern (all majors, including `ai@5`)** — define your
179
- tools *without* an AI SDK-native `execute` callback at all. Not even a
180
- no-op one: the guard treats any `tool-result`/`tool-error` it observes as
181
- proof the SDK's tool loop already invoked that call, and a no-op callback
182
- still produces one — omitting `execute` entirely is what keeps the SDK
183
- from running the tool itself in the first place. Consume `fullStream`
184
- yourself, call `guard.finish()` for diagnostic state, and dispatch manually
185
- only from `guard.takeDecision(observed.internalId)`, exactly as shown above. `createAiSdkExecutionLock()`
186
- above gets you this same shape automatically (it drops `execute` too) plus
187
- the SDK-enforced backstop where that backstop exists.
188
- - **Unprotected / misuse pattern** — attach the real, irreversible operation
189
- (or any side-effecting code at all) directly as `execute`, `onInputStart`,
190
- `onInputDelta`, or `onInputAvailable` on a tool definition that bypasses
191
- `createAiSdkExecutionLock()` entirely, *and* separately run the guard on
192
- the same stream. All four are callbacks the SDK invokes on its own
193
- schedule, independent of and often before this guard ever reaches a
194
- decision — `onInputStart`/`onInputDelta`/`onInputAvailable` in particular
195
- run regardless of finish reason, schema validity, or approval status, on
196
- every major including `ai@5`. If that happens, the side effect has
197
- already run — `prefix-safe-json` cannot retroactively undo it, and on
198
- `ai@5` specifically nothing in this library can prevent any of these four
199
- callbacks from firing at all (verified directly, not assumed — see
200
- `test/integration/ai-sdk-lifecycle/ai-v5.real.test.ts`).
201
-
202
- The guard does defend against the second pattern in one specific way, and
203
- only for `execute` specifically: a `tool-result` or `tool-error` part on
204
- `fullStream` is direct evidence the SDK's own loop already invoked *some*
205
- call's `execute`. When it names a specific call (a real `toolCallId`), only
206
- that call is disqualified; when it doesn't, the guard cannot tell which
207
- in-flight call was affected and fails closed for **every** call in that
208
- stream rather than guess. Either way the guard never again reports
209
- `action: "execute"` for the affected call(s)
210
- (`reason: "sdk_execution_observed"`, which takes priority over every other
211
- rejection reason), so a caller who also runs the documented manual-dispatch
212
- loop will not invoke the tool function a *second* time. There is no
213
- equivalent detection for a bypassed `onInputStart`/`onInputDelta`/
214
- `onInputAvailable` — those callbacks produce no observable `fullStream`
215
- evidence of having run at all, so a misuse of any of the three is invisible
216
- to the guard entirely; the only defense is calling
217
- `createAiSdkExecutionLock()` in the first place. It does not undo
218
- whatever the SDK's own callback already did. See
219
- [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md#execution-ownership-tool-resulttool-error-as-evidence)
220
- for the full behavior and test matrix.
221
-
222
- **Supported providers**: OpenAI (legacy `function_call` and Responses API),
223
- Anthropic, Gemini, OpenRouter, generic OpenAI-compatible endpoints, and the
224
- Vercel AI SDK. High-level guards currently ship for the AI SDK; every
225
- provider has a public low-level adapter (see below) that composes with
226
- `createToolCallExecutionGate()` the same way.
227
-
228
- Gemini's adapter exposes its structured argument projection for inspection
229
- and validation, but it never grants strict execute authority because Gemini
230
- does not provide raw argument text at this seam. OpenAI-compatible and
231
- OpenRouter events require a non-negative integer `choice.index`; missing,
232
- invalid, or duplicate choice identity fails closed instead of guessing zero.
103
+ See [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md) for the full
104
+ decision table and every `reason` value.
233
105
 
234
- `AiSdkStreamAdapter`/`createAiSdkExecutionGuard()` target the public
235
- `streamText()`/`generateText()` `fullStream` surface, whose `finishReason`
236
- is a plain unified string (`"stop"`, `"length"`, ...). They are not
237
- currently an adapter for the lower-level `@ai-sdk/provider` `doStream()`
238
- boundary (`LanguageModelV3`/`V4`), where finish reason is
239
- `{ unified, raw }`-object-shaped — building your own provider against that
240
- interface directly needs different handling of that field.
241
-
242
- Full semantics, the decision table, provider finish-reason mapping, and the
243
- high-level guard API: [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md).
244
-
245
- ## The lower-level problem this is built on
246
-
247
- LLM providers (OpenAI, Anthropic, Gemini, OpenRouter) stream tool-call arguments as small JSON chunks. During streaming, the JSON is frequently incomplete: strings split mid-word, UTF-8 characters split mid-byte, containers left unclosed, numbers terminated at chunk boundaries.
248
-
249
- This library parses each chunk incrementally and provides:
250
-
251
- - **Committed values**: Fields whose values are definitively complete
252
- - **Pending tokens**: Partially received values awaiting more data
253
- - **Monotonic semantic events**: JSON Pointer-based events that are never retracted
254
- - **Parse diagnostics**: Structured error reporting with severity and recoverability
255
- - **Deterministic repair**: When the stream ends, a clear accounting of what was repaired and why
256
- - **Execution safety**: Whether the parsed result is safe to use for tool invocation
257
-
258
- ## Core Principle
259
-
260
- > The parser never fabricates missing data. It clearly distinguishes what is definite, what is pending, what was deterministically repaired, and what is lost.
261
-
262
- > **Valid JSON is not safe generation completion. Safe generation completion is not permission to execute a side effect.** Structural validity, confirmed completeness, and execution authority are three separate questions — the gate layer (below) answers all three before ever returning `execute`, not just the first. See [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) for the full guarantee/non-guarantee list.
263
-
264
- ## Why not a general "partial JSON" parser?
265
-
266
- Most streaming JSON helpers (including ones already used in the wild for
267
- LLM tool-call arguments) are designed to show *something* as early as
268
- possible, even if that means guessing. Concretely, given a stream that has
269
- so far delivered `{"city":"Tok` (the model is still typing "Tokyo"):
270
-
271
- | | `city` field after this chunk |
272
- |---|---|
273
- | [vercel/ai's `fixJson`](https://github.com/vercel/ai/blob/main/packages/ai/src/util/fix-json.ts) | `"Tok"` — closes the open string as-is and reports a successful parse |
274
- | [langchain's `parsePartialJson`](https://github.com/langchain-ai/langchainjs/blob/main/libs/langchain-core/src/utils/json.ts) | `"Tok"` — its recursive-descent parser returns whatever string content it collected before running out of input |
275
- | **this library** (`snapshot().stableValue`) | *(absent — `city` is not committed yet)* |
276
-
277
- Both of those are real, current, well-engineered implementations — verified
278
- by cloning `vercel/ai` (commit `fbb154a0`) and `langchain-ai/langchainjs`
279
- (commit `555d6f14`) and running their actual code side-by-side with this
280
- library. Neither is a bug: they're built for progressively rendering a
281
- value in a UI, where showing `"Tok"` and then `"Tokyo"` a moment later is
282
- good UX. This library is built for the different question — *is it safe to
283
- act on this value yet* — where treating `"Tok"` as the city would be
284
- wrong. It's why `city` only appears in `stableValue` once its closing
285
- quote genuinely arrives, and why `executable` only becomes `true` once the
286
- whole document is unambiguously, definitively complete.
287
-
288
- If you need live "filling in..." UI text, a partial-JSON renderer is the
289
- right tool. If you need to know precisely when it's safe to execute a tool
290
- call with the parsed arguments, that's what this library is for.
291
-
292
- ### A concrete case where this matters: silent execution on truncated data
106
+ ## Failure cases
293
107
 
294
108
  The risk above isn't hypothetical. [Cline](https://github.com/cline/cline)
295
109
  (a coding agent that writes files and runs terminal commands from LLM tool
296
110
  calls — verified by cloning it, commit `81cce3d70e1`) wires a JSON-repair
297
- step directly into tool execution:
298
- [`sdk/packages/llms/src/providers/ai-sdk.ts:1332`](https://github.com/cline/cline/blob/81cce3d70e10244cdde40dbd0eb0bb711c93006d/sdk/packages/llms/src/providers/ai-sdk.ts#L1332)
299
- registers `repairMalformedToolCall` as the Vercel AI SDK's
300
- `experimental_repairToolCall` hook — whatever it returns *is* the tool call
301
- that runs. That function's own docstring names its target: "truncated
302
- payloads ... common with weaker models." Its repair path
303
- ([`sdk/packages/shared/src/parse/json.ts:42`](https://github.com/cline/cline/blob/81cce3d70e10244cdde40dbd0eb0bb711c93006d/sdk/packages/shared/src/parse/json.ts#L42))
304
- runs the raw argument text through
305
- [`jsonrepair`](https://github.com/josdejong/jsonrepair), which — like the
306
- two implementations above — closes an unterminated string as-is rather
307
- than rejecting it.
308
-
309
- Feeding `parseJsonStream` (copied verbatim from that file) and this
310
- library the same truncated `write_file` tool call — a `content` argument
311
- cut off mid-value, the exact scenario the docstring describes:
111
+ step directly into tool execution: whatever
112
+ [`repairMalformedToolCall`](https://github.com/cline/cline/blob/81cce3d70e10244cdde40dbd0eb0bb711c93006d/sdk/packages/llms/src/providers/ai-sdk.ts#L1332)
113
+ returns *is* the tool call that runs, and its repair path closes an
114
+ unterminated string as-is, same as the table above.
312
115
 
313
- ```
116
+ Feeding both implementations the same truncated `write_file` call — a
117
+ `content` argument cut off mid-value:
118
+
119
+ ```text
314
120
  input: {"path":"config/database.yml","content":"production:\n host: db.prod.internal\n password: correct-horse-battery-sta
315
121
 
316
122
  Cline's parseJsonStream() -> jsonrepair:
@@ -318,166 +124,235 @@ Cline's parseJsonStream() -> jsonrepair:
318
124
  "content": "production:\n host: db.prod.internal\n password: correct-horse-battery-sta" }
319
125
  No error, no warning. This is what gets written to disk.
320
126
 
321
- This library:
127
+ prefix-safe-json:
322
128
  outcome: "truncated", executable: false
323
129
  stableValue: { "path": "config/database.yml" } // content is absent, not guessed
324
130
  ```
325
131
 
326
- To be clear about scope: `jsonrepair` also fixes things this library
327
- intentionally doesn't (single quotes, trailing commas, unquoted keys) — it
328
- isn't a strict RFC 8259 parser and isn't trying to be, so this library
329
- can't simply replace it there. The gap is narrower and specific: nothing
330
- in that repair path currently distinguishes *"the model's JSON syntax is
331
- wrong"* from *"the model's JSON was cut off mid-value and the rest is
332
- unknown"* — `jsonrepair` handles both the same way, silently. This library
333
- draws exactly that distinction (`outcome: "truncated"`) and could gate
334
- execution on it without touching the syntax-repair behavior for the other
335
- case.
336
-
337
- ## Scope: what this does and doesn't protect against
338
-
339
- This library is honestly scoped as **execution integrity** — complete,
340
- unfabricated, schema-valid arguments — not a general "AI security platform."
341
- It does **not**:
132
+ This is one instance of a class of defect independently found across
133
+ several unrelated agent runtimes — see
134
+ [`docs/REAL_WORLD_FAILURES.md`](docs/REAL_WORLD_FAILURES.md) for the full,
135
+ strictly-classified matrix (which reports are upstream-fixed, which are
136
+ still open, which are internal reproductions only) and
137
+ [`docs/CASE_STUDY_SANDBASE.md`](docs/CASE_STUDY_SANDBASE.md) for how one
138
+ independently maintained runtime closed this exact gap in production.
139
+
140
+ ## Supported boundaries
141
+
142
+ `prefix-safe-json` owns **tool-call execution integrity** — raw argument
143
+ evidence, identity correlation, lifecycle completeness, truncation
144
+ detection, conflicting evidence, and validator-verdict composition, up
145
+ through a one-shot execution-authority decision. It does **not** own tool
146
+ permission, application authorization, human approval, sandboxing,
147
+ application idempotency, distributed exactly-once semantics, or prompt
148
+ injection defense — see
149
+ [`docs/PRODUCT_POSITIONING.md`](docs/PRODUCT_POSITIONING.md) for the full
150
+ owns/does-not-own boundary and
151
+ [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) for the complete guarantee/
152
+ non-guarantee list, including known internal limitations.
153
+
154
+ Concretely, it does **not**:
342
155
 
343
156
  - defend against prompt injection (it says nothing about whether the
344
157
  arguments a model *chose* to send are the arguments it *should* have
345
158
  sent — only whether they're complete and unfabricated)
346
- - resolve authorization (it doesn't know which caller may invoke which tool)
159
+ - resolve authorization (it doesn't know which caller may invoke which
160
+ tool)
347
161
  - sandbox execution (`action: "execute"` means "this is genuinely the
348
162
  complete value the model produced," not "safe to run without your own
349
163
  validation and permissions")
350
164
  - detect a malicious or wrong tool choice (a model calling the wrong tool
351
- with complete, schema-valid arguments is still reported `execute`)
352
-
353
- See [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md#limitations---what-this-does-not-protect-against)
354
- for the full list, including known internal limitations, and
355
- [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) for the complete security
356
- objective, trust boundaries, and guarantee/non-guarantee list.
165
+ with complete, valid arguments is still reported `execute`)
166
+
167
+ ## AI SDK integration
168
+
169
+ The 30-line example above covers the common case. Three integration
170
+ patterns exist, strongest first:
171
+
172
+ - **Strongest — `createAiSdkExecutionLock()`.** Wrap your tool
173
+ definitions with it before passing them to `streamText()`/
174
+ `generateText()`. It removes every AI SDK-invoked callback capable of
175
+ running your code before this library's decision — not just `execute`,
176
+ but also `onInputStart`, `onInputDelta`, and `onInputAvailable`
177
+ (verified directly against `ai@5`/`ai@6`/`ai@7`'s own source: all three
178
+ fire unconditionally, independent of `needsApproval`). It also forces
179
+ `needsApproval: true` and rejects any provider-tool shape whose real
180
+ execution location it cannot verify. Still dispatch manually from
181
+ `guard.takeDecision(observed.internalId)` — this only closes the door
182
+ on the SDK (or your own callbacks) doing it *for* you.
183
+ - **Safe, supported pattern (all majors, including `ai@5`)** — define
184
+ your tools *without* an AI SDK-native `execute` callback at all. Not
185
+ even a no-op one. Consume `fullStream` yourself and dispatch manually.
186
+ `createAiSdkExecutionLock()` gets you this automatically.
187
+ - **Unprotected / misuse pattern** — attach the real side effect directly
188
+ as `execute`/`onInputStart`/`onInputDelta`/`onInputAvailable` on a tool
189
+ definition that bypasses the lock. If that happens, the side effect has
190
+ already run before this library ever reaches a decision; nothing here
191
+ can retroactively undo it.
192
+
193
+ The guard does defend against the misuse pattern in one specific way: a
194
+ `tool-result`/`tool-error` part on `fullStream` is direct evidence the
195
+ SDK's own loop already invoked *some* call's `execute`, and once observed
196
+ this library never again reports `action: "execute"` for the affected
197
+ call(s) (`reason: "sdk_execution_observed"` — the highest-priority
198
+ rejection reason of any). There is no equivalent detection for a bypassed
199
+ `onInputStart`/`onInputDelta`/`onInputAvailable` — those callbacks
200
+ produce no observable evidence of having run at all. Full behavior, the
201
+ exact test matrix, and every verified `ai@5`/`ai@6`/`ai@7` behavioral
202
+ difference:
203
+ [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md#execution-ownership-tool-resulttool-error-as-evidence).
357
204
 
358
- ## Current Status
205
+ `AiSdkStreamAdapter`/`createAiSdkExecutionGuard()` target the public
206
+ `streamText()`/`generateText()` `fullStream` surface (`finishReason` as a
207
+ plain unified string), not the lower-level `@ai-sdk/provider`
208
+ `doStream()` boundary.
359
209
 
360
- ### Implemented
210
+ ## Provider adapters
361
211
 
362
- - Incremental UTF-8 decoder with split-byte handling
363
- - Lexical JSON state machine (17 states)
364
- - Grammar stack with object/array tracking
365
- - Semantic event emission for committed scalar values
366
- - Duplicate key detection and rejection
367
- - `push()` / `snapshot()` / `drainEvents()` / `finish()` API
368
- - Configurable resource limits (depth, input size, string length, queued events)
369
- - Machine-readable test corpus with 25+ canonical fixtures
370
- - Chunk invariance verification
371
- - Provider stream adapters for OpenAI (legacy `function_call` and Responses API), Anthropic, Gemini, OpenRouter, generic OpenAI-compatible endpoints, and the Vercel AI SDK, plus a coordinator for tracking multiple concurrent tool-call streams
372
- - Optional per-tool JSON Schema validation on the coordinator (see below) — a value can be structurally complete and still not match what a tool declared it needs
373
- - `createToolCallExecutionGate()` — a fail-closed `execute` / `retry` / `reject` decision per tool call, built on top of the coordinator (see [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md))
374
- - `createAiSdkExecutionGuard()` — a drop-in high-level guard for the Vercel AI SDK's `fullStream`, composing a provider adapter with the execution gate; every `ExecutionDecision` also carries an `evidence` object explaining why (see [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md#high-level-guards))
375
- - Call-scoped one-shot authority via `takeDecision(internalId)`; replayable `finish()` results remain available for diagnostics, while the recommended dispatch path cannot take the same execute authority twice
376
- - SDK execution-ownership detection — direct evidence that the AI SDK's own tool loop already invoked a call (attributed or, when it can't be, stream-wide) permanently blocks this library from also authorizing it, with the highest rejection-reason priority of any disqualifier (see [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md#execution-ownership-tool-resulttool-error-as-evidence))
212
+ Every provider below has a public low-level adapter that composes with
213
+ `createToolCallExecutionGate()` the same way the high-level AI SDK guard
214
+ does internally:
377
215
 
378
- ### Not Yet Implemented
216
+ **OpenAI** (legacy `function_call` and Responses API), **Anthropic**,
217
+ **Gemini**, **OpenRouter**, generic **OpenAI-compatible** endpoints, and
218
+ the **Vercel AI SDK**.
379
219
 
380
- - CLI tool
381
- - Network/SSE client integration
220
+ ```typescript
221
+ import { createToolCallExecutionGate, OpenAIStreamAdapter } from "prefix-safe-json";
382
222
 
383
- `Advanced repair engine (structural/lossy repairs)` was previously listed here and has been removed — "lossy repair" (fabricating/guessing a value to fill a gap) is the opposite of this library's core principle, not a missing feature.
223
+ const adapter = new OpenAIStreamAdapter();
224
+ const gate = createToolCallExecutionGate();
225
+ for (const rawChunk of stream) {
226
+ for (const event of adapter.push(rawChunk)) gate.push(event);
227
+ }
228
+ for (const event of adapter.finish({ reason: "complete" })) gate.push(event);
229
+ const { decisions } = gate.finish();
230
+ ```
384
231
 
385
- ### JSON Schema validation
232
+ Notes: Gemini's adapter exposes its structured argument projection for
233
+ inspection and validation, but never grants strict execute authority —
234
+ Gemini does not provide raw argument text at this seam. OpenAI-compatible
235
+ and OpenRouter events require a non-negative integer `choice.index`;
236
+ missing, invalid, or duplicate choice identity fails closed instead of
237
+ guessing zero. Full semantics, the decision table, and provider
238
+ finish-reason mapping: [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md).
386
239
 
387
- `createToolCallStreamCoordinator()` optionally accepts a third argument: a map of tool name → JSON Schema (draft-07). When a call for a registered tool reaches a structurally complete outcome, its `stableValue` is validated against that schema.
240
+ ### Bring your own validator
388
241
 
389
- This is a genuinely different check from prefix-safety. `executable` from the core parser only means *"this value is not truncated or fabricated."* It says nothing about whether the value matches what the tool actually needs — a model can finish generating a syntactically complete object that's still missing a required field, or has the wrong type for one. Schema validation catches that separate class of problem. The coordinator's own `executable` (on `tool_call_finished` and `ToolCallState.parser`) is only `true` when *both* hold.
242
+ Schema/validator checking is optional and validator-agnostic — plug in
243
+ Zod, TypeBox, Valibot, a Standard Schema-compliant validator, or a
244
+ hand-written check via the separate `validators` option, alongside or
245
+ instead of a raw JSON Schema object via the pre-existing `toolSchemas`/
246
+ `schemas` option (statically compiled through Ajv):
390
247
 
391
248
  ```typescript
392
- import { createToolCallStreamCoordinator } from "prefix-safe-json";
393
-
394
- const coordinator = createToolCallStreamCoordinator(undefined, undefined, {
395
- write_file: {
396
- type: "object",
397
- properties: {
398
- path: { type: "string" },
399
- content: { type: "string" },
400
- },
401
- required: ["path", "content"],
402
- },
249
+ const gate = createToolCallExecutionGate(undefined, undefined, undefined, {
250
+ write_file: { validate: (v) => WriteFileSchema.safeParse(v).success ? { valid: true } : { valid: false } },
403
251
  });
252
+ ```
404
253
 
405
- // ...push provider events...
254
+ A tool name may be registered in `toolSchemas`/`schemas` or in `validators`,
255
+ never both — registering both throws at construction time. No forced
256
+ dependency on any specific validation ecosystem. See
257
+ [`docs/VALIDATION.md`](docs/VALIDATION.md).
406
258
 
407
- const call = coordinator.snapshot().calls[0];
408
- call.schemaValid; // true | false | undefined (undefined = no schema registered for this tool)
409
- ```
259
+ ## Conformance
410
260
 
411
- A schema mismatch also surfaces as a coordinator diagnostic (`E_SCHEMA_VALIDATION_FAILED`) with ajv's own error detail. Malformed schemas are compiled eagerly at construction time, so a bad schema fails fast rather than mid-stream.
261
+ [`conformance/`](conformance/) packages this project's failure corpus as
262
+ a portable, provider-neutral fixture format and a small deterministic
263
+ runner (`prefix-safe-json/conformance`) — useful even to a project that
264
+ never installs this package at runtime, since the fixture format and
265
+ expected outcomes are meaningful against any implementation of this
266
+ problem class. `prefix-safe-json` is the reference implementation and
267
+ dogfoods this exact runner in its own test suite. See
268
+ [`docs/CONFORMANCE.md`](docs/CONFORMANCE.md).
412
269
 
413
- ## Package requirements
270
+ ## Threat model / non-goals
414
271
 
415
- ESM only — `import`, not `require`. There is currently no CommonJS build.
416
- Node `>=18.0.0`. Node 18/20 are end-of-life and no longer receive security
417
- patches; compatibility here describes the package runtime, not a recommendation
418
- to operate an unpatched Node release. See
419
- [`docs/COMPATIBILITY.md`](docs/COMPATIBILITY.md) for the full
420
- runtime-versus-toolchain distinction, exact AI SDK pin requirements, and the
421
- per-provider compatibility matrix.
272
+ `prefix-safe-json` is honestly scoped as **execution integrity**, not a
273
+ general "AI security platform." The complete guarantee/non-guarantee
274
+ list, trust boundaries, and known internal limitations live in
275
+ [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md); the product-level
276
+ owns/does-not-own boundary this is drawn from is
277
+ [`docs/PRODUCT_POSITIONING.md`](docs/PRODUCT_POSITIONING.md).
278
+
279
+ ## Supply-chain verification
280
+
281
+ Verify releases independently rather than relying on publisher claims.
282
+ Published artifacts have npm provenance from this repository's GitHub
283
+ Actions publish workflow, and a tarball can be rebuilt and compared
284
+ against its release tag with `npm run verify:published-release -- <version>`
285
+ from a clone with tags fetched. See the command-driven
286
+ [maintainer audit](docs/MAINTAINER_AUDIT.md), the exact
287
+ [release/hash mapping](docs/RELEASE_INTEGRITY.md), the
288
+ [runtime dependency graph](docs/RUNTIME_DEPENDENCIES.md), and the
289
+ [execution-critical source map](docs/EXECUTION_AUDIT_SURFACE.md).
422
290
 
423
- ## Low-level API: Quick Start
291
+ ## Compatibility
424
292
 
425
- The AI SDK guard (above) covers the common case. For other providers, or
426
- when you need the adapter/gate instance directly, `createToolCallExecutionGate()`
427
- and the provider adapters (`AnthropicStreamAdapter`, `OpenAIStreamAdapter`,
428
- etc.) are equally public — see [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md#example).
429
- The `createParser()` API both are built on is also fully public, for anyone who
430
- needs direct access to parser state:
293
+ ESM only (`import`, not `require`), Node `>=18.0.0`. Node 18/20 are
294
+ end-of-life and no longer receive security patches; this describes the
295
+ package's runtime requirement, not a recommendation to operate an
296
+ unpatched Node release. Repository development and release jobs run on
297
+ newer Node because the toolchain needs it — that requirement does not
298
+ apply to the published package's own runtime dependency graph.
299
+
300
+ **Vercel AI SDK**, verified directly against each exact pinned major via
301
+ a real `streamText()` lifecycle proof (no API key, no network request, no
302
+ paid model call — `pnpm run example:ai-sdk-lifecycle-proof`, run in CI on
303
+ every push):
304
+
305
+ | Major | Status |
306
+ | --- | --- |
307
+ | v5 | verified |
308
+ | v6 | verified |
309
+ | v7 | verified |
310
+
311
+ Not a claim that every version within a major is tested. Full
312
+ per-provider compatibility matrix, exact AI SDK pin requirements, and the
313
+ runtime-vs-toolchain distinction:
314
+ [`docs/COMPATIBILITY.md`](docs/COMPATIBILITY.md).
315
+
316
+ ## Development / audit
317
+
318
+ ### Current status
319
+
320
+ **Implemented:** incremental UTF-8 decoder, 17-state lexical JSON state
321
+ machine, grammar stack, semantic event emission, duplicate-key rejection,
322
+ `push()`/`snapshot()`/`drainEvents()`/`finish()`, configurable resource
323
+ limits, a machine-readable test corpus plus the public
324
+ [Tool Call Integrity conformance corpus](conformance/), provider stream
325
+ adapters (OpenAI legacy + Responses, Anthropic, Gemini, OpenRouter,
326
+ OpenAI-compatible, Vercel AI SDK) with a coordinator for concurrent
327
+ tool-call streams, validator-agnostic per-tool validation (see
328
+ [`docs/VALIDATION.md`](docs/VALIDATION.md)), `createToolCallExecutionGate()`,
329
+ `createAiSdkExecutionGuard()`, call-scoped one-shot authority via
330
+ `takeDecision(internalId)`, and SDK execution-ownership detection.
331
+
332
+ **Not yet implemented:** a CLI tool, network/SSE client integration.
333
+
334
+ ### Low-level parser API
431
335
 
432
336
  ```typescript
433
337
  import { createParser } from "prefix-safe-json";
434
338
 
435
339
  const parser = createParser();
436
-
437
- // Feed chunks as they arrive from the LLM stream
438
340
  parser.push('{"tool":"calc",');
439
341
  parser.push('"args":{"x":42}}');
440
-
441
- // Get committed values at any point
442
- const snap = parser.snapshot();
443
- console.log(snap.stableValue);
444
-
445
- // Drain semantic events
446
- const events = parser.drainEvents();
447
-
448
- // Signal stream completion
342
+ console.log(parser.snapshot().stableValue);
449
343
  const result = parser.finish({ reason: "complete" });
450
344
  console.log(result.executable); // true if safe to execute
451
345
  ```
452
346
 
453
- ### End-to-end example (real API, no mocked internals)
454
-
455
- The snippet above is illustrative; for a runnable, verified demonstration
456
- against the actual provider-adapter + coordinator public API — including
457
- the exact truncation scenario described above — see
458
- [`examples/anthropic-truncation-safety.mjs`](examples/anthropic-truncation-safety.mjs)
459
- (`node examples/anthropic-truncation-safety.mjs` after `pnpm run build`).
460
- It feeds `AnthropicStreamAdapter` a realistic Anthropic Messages API SSE
461
- sequence — including one cut off mid-argument by `max_tokens`, matching
462
- Anthropic's real streaming shape — through `createToolCallStreamCoordinator()`,
463
- and asserts the truncated call is never reported executable while a
464
- genuinely complete one is. This example runs in CI on every push, so it
465
- can't silently rot into a stale claim.
466
-
467
- For the exact-major AI SDK ownership proof, run
468
- `pnpm run example:ai-sdk-lifecycle-proof` after `pnpm run build`. It asserts
469
- for each exact pin that an unlocked control invokes caller code before guard
470
- authority, locked unsafe calls perform no operation, and locked safe calls are
471
- manually dispatched exactly once. CI and the release workflow run this same
472
- command.
473
-
474
- For the same raw-evidence demonstration through the low-level execution gate and the
475
- [Vercel AI SDK](https://ai-sdk.dev)'s `fullStream` shape instead, see
476
- [`examples/ai-sdk-execution-gate.mjs`](examples/ai-sdk-execution-gate.mjs)
477
- (`node examples/ai-sdk-execution-gate.mjs` after `pnpm run build`) — also
478
- run in CI on every push. [`examples/ai-sdk-guard.mjs`](examples/ai-sdk-guard.mjs)
479
- is a lower-level wire-shape guard demonstration; it is not the canonical
480
- execution-ownership example. Both remain CI-checked.
347
+ Runnable, CI-checked, real-API examples (no mocked internals):
348
+ [`examples/anthropic-truncation-safety.mjs`](examples/anthropic-truncation-safety.mjs),
349
+ [`examples/ai-sdk-execution-gate.mjs`](examples/ai-sdk-execution-gate.mjs),
350
+ [`examples/ai-sdk-lifecycle-proof.mjs`](examples/ai-sdk-lifecycle-proof.mjs).
351
+
352
+ ### Contributing
353
+
354
+ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for how to run tests, add a
355
+ provider fixture or conformance case, and propose adapter support.
481
356
 
482
357
  ## License
483
358