prefix-safe-json 0.4.5 → 0.5.1

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 +195 -428
  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 +63 -0
  49. package/dist/validation/types.d.ts.map +1 -0
  50. package/dist/validation/types.js +131 -0
  51. package/dist/validation/types.js.map +1 -0
  52. package/package.json +21 -4
package/README.md CHANGED
@@ -1,483 +1,250 @@
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
+ An incremental JSON parser and execution gate 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.
9
+ `prefix-safe-json` keeps incomplete, conflicting, or unconfirmed streamed arguments from being treated as final tool input. It is designed for callers that need to distinguish:
15
10
 
16
- ## Install
11
+ 1. JSON that can be parsed right now,
12
+ 2. values that are stable enough to expose, and
13
+ 3. arguments that the provider actually completed and confirmed.
14
+
15
+ It does not execute tools itself.
17
16
 
18
17
  ```bash
19
- pnpm add prefix-safe-json ai
20
- # or: npm install prefix-safe-json ai
18
+ pnpm add prefix-safe-json ai zod
19
+ # or: npm install prefix-safe-json ai zod
21
20
  ```
22
21
 
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.
27
-
28
- ## Supply-chain verification
29
-
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).
42
-
43
- ## Recommended AI SDK boundary
44
-
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:
22
+ ## Safe execution example
48
23
 
49
24
  ```javascript
50
- import { jsonSchema, streamText } from "ai";
25
+ import { writeFile } from "node:fs/promises";
26
+ import { streamText } from "ai";
27
+ import { z } from "zod";
51
28
  import {
52
29
  createAiSdkExecutionGuard,
53
30
  createAiSdkExecutionLock,
54
31
  } from "prefix-safe-json";
32
+ import { fromStandardSchema } from "prefix-safe-json/standard-schema";
55
33
 
56
- const writeFileSchema = {
57
- type: "object",
58
- properties: {
59
- path: { type: "string" },
60
- content: { type: "string" },
34
+ const writeFileSchema = z.object({ path: z.string(), content: z.string() });
35
+
36
+ const tools = createAiSdkExecutionLock({
37
+ write_file: {
38
+ description: "Write a UTF-8 text file",
39
+ inputSchema: writeFileSchema,
61
40
  },
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
- }
41
+ });
82
42
 
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);
43
+ const guard = createAiSdkExecutionGuard({
44
+ validators: { write_file: fromStandardSchema(writeFileSchema) },
45
+ });
46
+
47
+ const result = streamText({ model, prompt, tools });
48
+ for await (const part of result.fullStream) {
49
+ guard.push(part);
50
+ }
51
+
52
+ const { decisions, diagnostics } = guard.finish();
53
+ for (const decision of decisions) {
54
+ const authority = guard.takeDecision(decision.internalId);
55
+ if (authority) {
56
+ await writeFile(authority.value.path, authority.value.content);
57
+ } else {
58
+ console.warn(`skipped ${decision.name}: ${decision.action} (${decision.reason})`);
87
59
  }
88
60
  }
61
+ if (decisions.length === 0 && diagnostics.length > 0) {
62
+ console.warn("no tool call was executed:", diagnostics);
63
+ }
89
64
  ```
90
65
 
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**
113
-
114
- Every tool call gets exactly one verdict:
115
-
116
- - **`execute`** — complete, unfabricated, and (if a schema is registered)
117
- schema-valid. `decision.value` is safe to pass to your tool.
118
- - **`retry`** — nothing is wrong with what arrived; there just isn't a
119
- trustworthy complete value yet. Continue generation.
120
- - **`reject`** — the data itself is the problem (malformed JSON, a schema
121
- mismatch, a resource limit, a provider error, a content-policy
122
- termination). Retrying the same input won't help.
123
-
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.
233
-
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
293
-
294
- The risk above isn't hypothetical. [Cline](https://github.com/cline/cline)
295
- (a coding agent that writes files and runs terminal commands from LLM tool
296
- 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:
66
+ `model` and `prompt` are yours. The lock stops the SDK from running `write_file` itself; the guard checks each streamed call against the same schema and hands out a one-shot decision. The caller owns dispatch, and a call the guard doesn't hand out never runs.
312
67
 
313
- ```
314
- input: {"path":"config/database.yml","content":"production:\n host: db.prod.internal\n password: correct-horse-battery-sta
68
+ Using `jsonSchema()` from `ai` instead of Zod? Give the guard the raw schema inside it: `schemas: { write_file: writeFileSchema.jsonSchema }`. The wrapper itself is refused at construction, because it would otherwise validate nothing.
69
+
70
+ ## Why parse success is not enough
315
71
 
316
- Cline's parseJsonStream() -> jsonrepair:
317
- { "path": "config/database.yml",
318
- "content": "production:\n host: db.prod.internal\n password: correct-horse-battery-sta" }
319
- No error, no warning. This is what gets written to disk.
72
+ Tool-call arguments often arrive as partial JSON. A renderer may reasonably repair or close an unfinished prefix so it can show progressive UI. That is useful for display, but it answers a different question from whether a side effect should run.
320
73
 
321
- This library:
322
- outcome: "truncated", executable: false
323
- stableValue: { "path": "config/database.yml" } // content is absent, not guessed
74
+ Given the prefix:
75
+
76
+ ```text
77
+ {"city":"Tok
324
78
  ```
325
79
 
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**:
342
-
343
- - defend against prompt injection (it says nothing about whether the
344
- arguments a model *chose* to send are the arguments it *should* have
345
- sent — only whether they're complete and unfabricated)
346
- - resolve authorization (it doesn't know which caller may invoke which tool)
347
- - sandbox execution (`action: "execute"` means "this is genuinely the
348
- complete value the model produced," not "safe to run without your own
349
- validation and permissions")
350
- - 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.
357
-
358
- ## Current Status
359
-
360
- ### Implemented
361
-
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))
377
-
378
- ### Not Yet Implemented
379
-
380
- - CLI tool
381
- - Network/SSE client integration
382
-
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.
384
-
385
- ### JSON Schema validation
386
-
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.
388
-
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.
80
+ some partial-JSON helpers can expose `"Tok"` immediately. `prefix-safe-json` keeps that field uncommitted until the stream provides enough evidence that the value is complete.
81
+
82
+ The core distinction is simple:
83
+
84
+ > **Parse completion is not execution authority.**
85
+
86
+ See [`docs/REAL_WORLD_FAILURES.md`](docs/REAL_WORLD_FAILURES.md) for independently reproduced examples and [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md) for the decision model.
87
+
88
+ ## Decisions
89
+
90
+ Every observed tool call ends in one of three actions:
91
+
92
+ - **`execute`** — arguments are complete and, when configured, validator/schema checks pass.
93
+ - **`retry`** — no trustworthy complete value exists yet.
94
+ - **`reject`** — the stream contains malformed, conflicting, invalid, or terminally unsafe evidence.
95
+
96
+ The full reason matrix lives in [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md).
97
+
98
+ ## Scope and non-goals
99
+
100
+ The library covers streamed argument evidence, call identity, lifecycle completion, truncation detection, conflicting evidence, validation composition, and one-shot execution decisions.
101
+
102
+ It does **not** provide:
103
+
104
+ - application authorization,
105
+ - human approval workflows,
106
+ - sandboxing,
107
+ - prompt-injection defense,
108
+ - distributed exactly-once semantics,
109
+ - a guarantee that the model selected the correct tool.
110
+
111
+ An `execute` decision means the tool arguments were observed as complete under the configured boundary. Your application still owns permissions, policy, validation, and the side effect itself.
112
+
113
+ See [`docs/PRODUCT_POSITIONING.md`](docs/PRODUCT_POSITIONING.md) and [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) for the detailed boundary.
114
+
115
+ ## Vercel AI SDK integration
116
+
117
+ For `streamText()` / `generateText()`, the recommended pattern is:
118
+
119
+ 1. wrap tool definitions with `createAiSdkExecutionLock()`,
120
+ 2. consume `fullStream`,
121
+ 3. feed stream parts into `createAiSdkExecutionGuard()`, and
122
+ 4. dispatch manually only from `takeDecision()`.
123
+
124
+ `createAiSdkExecutionLock()` removes SDK-invoked callbacks that could execute application code before the guard reaches a final decision. A tool definition that bypasses that lock and performs side effects directly is outside the protection boundary.
125
+
126
+ The adapter targets the public AI SDK `fullStream` surface, not the lower-level `@ai-sdk/provider` `doStream()` boundary.
127
+
128
+ Detailed lifecycle behavior and version-specific tests are documented in [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md).
129
+
130
+ ## Provider adapters
131
+
132
+ Low-level adapters are included for:
133
+
134
+ - OpenAI legacy `function_call`,
135
+ - OpenAI Responses API,
136
+ - Anthropic,
137
+ - Gemini,
138
+ - OpenRouter,
139
+ - generic OpenAI-compatible endpoints,
140
+ - Vercel AI SDK.
390
141
 
391
142
  ```typescript
392
- import { createToolCallStreamCoordinator } from "prefix-safe-json";
143
+ import {
144
+ createToolCallExecutionGate,
145
+ OpenAIStreamAdapter,
146
+ } from "prefix-safe-json";
147
+
148
+ const adapter = new OpenAIStreamAdapter();
149
+ const gate = createToolCallExecutionGate();
150
+
151
+ for (const rawChunk of stream) {
152
+ for (const event of adapter.push(rawChunk)) {
153
+ gate.push(event);
154
+ }
155
+ }
393
156
 
394
- const coordinator = createToolCallStreamCoordinator(undefined, undefined, {
157
+ for (const event of adapter.finish({ reason: "complete" })) {
158
+ gate.push(event);
159
+ }
160
+
161
+ const { decisions } = gate.finish();
162
+ ```
163
+
164
+ Gemini exposes structured argument projections at this seam rather than raw argument text, so its adapter does not grant the same strict raw-text execution authority. Provider-specific semantics are documented in [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md).
165
+
166
+ ## Validation
167
+
168
+ Validation is optional and validator-agnostic. You can use JSON Schema/Ajv, Standard Schema-compatible validators, Zod, TypeBox, Valibot, or a custom validator.
169
+
170
+ `schemas` takes plain JSON Schema documents (TypeBox schemas count). Zod and other Standard Schema libraries go through `validators` with `fromStandardSchema()` from `prefix-safe-json/standard-schema`. Handing `schemas` a Zod schema or an AI SDK `jsonSchema()` wrapper throws at construction instead of silently validating nothing.
171
+
172
+ ```typescript
173
+ const gate = createToolCallExecutionGate(undefined, undefined, undefined, {
395
174
  write_file: {
396
- type: "object",
397
- properties: {
398
- path: { type: "string" },
399
- content: { type: "string" },
400
- },
401
- required: ["path", "content"],
175
+ validate: (value) =>
176
+ WriteFileSchema.safeParse(value).success
177
+ ? { valid: true }
178
+ : { valid: false },
402
179
  },
403
180
  });
181
+ ```
404
182
 
405
- // ...push provider events...
183
+ See [`docs/VALIDATION.md`](docs/VALIDATION.md).
406
184
 
407
- const call = coordinator.snapshot().calls[0];
408
- call.schemaValid; // true | false | undefined (undefined = no schema registered for this tool)
409
- ```
185
+ ## Conformance corpus
186
+
187
+ [`conformance/`](conformance/) contains a provider-neutral fixture format and deterministic runner for this problem class. Projects can use the fixtures without adopting this package as their runtime implementation.
410
188
 
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.
189
+ See [`docs/CONFORMANCE.md`](docs/CONFORMANCE.md).
412
190
 
413
- ## Package requirements
191
+ ## Compatibility
414
192
 
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.
193
+ - ESM only. CommonJS code can `require()` it on Node 20.19+ / 22.12+ (Node's `require(esm)`); older runtimes need `import()`.
194
+ - Runtime: Node `>=18.0.0`.
195
+ - Repository development/release tooling uses newer Node versions.
196
+ - CI exercises pinned Vercel AI SDK v5, v6, and v7 integration paths.
422
197
 
423
- ## Low-level API: Quick Start
198
+ | AI SDK major | CI integration check |
199
+ | --- | --- |
200
+ | v5 | yes |
201
+ | v6 | yes |
202
+ | v7 | yes |
424
203
 
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:
204
+ This is not a claim that every patch release inside each major is tested. Exact pins and provider notes live in [`docs/COMPATIBILITY.md`](docs/COMPATIBILITY.md).
205
+
206
+ ## Low-level parser API
431
207
 
432
208
  ```typescript
433
209
  import { createParser } from "prefix-safe-json";
434
210
 
435
211
  const parser = createParser();
436
-
437
- // Feed chunks as they arrive from the LLM stream
438
212
  parser.push('{"tool":"calc",');
439
213
  parser.push('"args":{"x":42}}');
440
214
 
441
- // Get committed values at any point
442
- const snap = parser.snapshot();
443
- console.log(snap.stableValue);
215
+ console.log(parser.snapshot().stableValue);
444
216
 
445
- // Drain semantic events
446
- const events = parser.drainEvents();
447
-
448
- // Signal stream completion
449
217
  const result = parser.finish({ reason: "complete" });
450
- console.log(result.executable); // true if safe to execute
218
+ console.log(result.executable);
219
+ ```
220
+
221
+ ## Development
222
+
223
+ Implemented components include the incremental UTF-8 decoder, lexical/grammar state tracking, semantic events, duplicate-key rejection, configurable resource limits, provider adapters, concurrent call coordination, validation hooks, and execution-gate APIs.
224
+
225
+ Not currently provided: a standalone CLI or network/SSE client.
226
+
227
+ Runnable examples:
228
+
229
+ - [`examples/anthropic-truncation-safety.mjs`](examples/anthropic-truncation-safety.mjs)
230
+ - [`examples/ai-sdk-execution-gate.mjs`](examples/ai-sdk-execution-gate.mjs)
231
+ - [`examples/ai-sdk-lifecycle-proof.mjs`](examples/ai-sdk-lifecycle-proof.mjs)
232
+
233
+ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development workflow.
234
+
235
+ ## Release verification
236
+
237
+ Published packages use npm provenance from this repository's GitHub Actions release workflow. A release tarball can also be rebuilt and compared with its tag using:
238
+
239
+ ```bash
240
+ npm run verify:published-release -- <version>
451
241
  ```
452
242
 
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.
243
+ Additional release and dependency documentation is available in:
244
+
245
+ - [`docs/MAINTAINER_AUDIT.md`](docs/MAINTAINER_AUDIT.md)
246
+ - [`docs/RELEASE_INTEGRITY.md`](docs/RELEASE_INTEGRITY.md)
247
+ - [`docs/RUNTIME_DEPENDENCIES.md`](docs/RUNTIME_DEPENDENCIES.md)
481
248
 
482
249
  ## License
483
250