Skip to content

Commit 16b0278

Browse files
committed
feat(ai): extend the injection guard to the stream
The one-shot path scans the finished answer before returning it. A streamed answer leaves the server while it is still being written, so that scan cannot be applied: a nonce split across two deltas passes a per-delta check untouched and reaches the client. Run the stream through a guard that withholds the last nonce.length - 1 characters and scans them together with each new delta, releasing only text that can no longer begin the nonce. That length is the exact minimum, since an occurrence spans nonce.length characters and holding one less leaves it inside a single scanned window. The holdback is released at the end of a text block, because that text cannot be emitted under the next block's id, and its tail is kept as scanning context, without which a nonce split across two blocks would pass unseen. The guard runs in experimental_transform, over the model's typed parts rather than the encoded SSE bytes, where JSON envelopes and escaping would split a marker beyond the reach of any substring scan. stopStream is not used: it obliges the caller to synthesize finish chunks whose shape follows the SDK version, while suppressing the remaining text keeps the stream well formed. A rejection is sent as an error part, not as another text delta. The client concatenates deltas, so a fallback message sent that way would land glued to the truncated prefix already on screen; on its own channel it tells the client to drop what it has. The prefix itself cannot be taken back, and the holdback cuts it at an arbitrary character. Reasoning is turned off at the route. Those deltas carry model text built from the same untrusted payload and the UI message stream sends them by default, so they would reach the client without passing the nonce check. Guarding them instead would mean a second holdback over a second stream, for text nothing renders. TransformStream is declared as an eslint global: it is on globalThis since Node 18, but eslint's node env predates the WHATWG Streams API.
1 parent e796dd5 commit 16b0278

10 files changed

Lines changed: 633 additions & 10 deletions

File tree

.eslintrc.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ module.exports = {
44
'node': true,
55
'jest': true
66
},
7+
globals: {
8+
/**
9+
* Global since Node 18 (this project runs Node 24 per .nvmrc), but not part of
10+
* eslint's "node" env, which predates the WHATWG Streams API
11+
*/
12+
'TransformStream': 'readonly'
13+
},
714
rules: {
815
'@typescript-eslint/camelcase': 'warn',
916
'@typescript-eslint/no-unused-vars': 'warn',

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.5.14",
3+
"version": "1.5.15",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {

src/integrations/vercel-ai/index.ts

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { generateText, streamText } from 'ai';
1+
import { generateText, streamText, type TextStreamPart, type ToolSet } from 'ai';
22
import { ProviderOptions } from '@ai-sdk/provider-utils';
3+
import type { GuardVerdict, StreamGuard } from '../../services/askAi/security/holdback';
34

45
/**
56
* Params for a single completion call to the model
@@ -16,6 +17,108 @@ export interface CompletionParams {
1617
prompt: string;
1718
}
1819

20+
/**
21+
* Params for a streaming completion call to the model
22+
*/
23+
export interface StreamParams extends CompletionParams {
24+
/**
25+
* Inspects the model's text before it leaves the server. Supplied by the
26+
* service layer, because what counts as unsafe output is a domain question,
27+
* not a transport one. Required: a stream cannot be checked after the fact,
28+
* so an omitted guard would mean an unchecked answer.
29+
*/
30+
guard: StreamGuard;
31+
32+
/**
33+
* Called once, the first time the guard rejects the answer
34+
*/
35+
onReject: () => void;
36+
}
37+
38+
/**
39+
* Wrap the model's stream so every text delta passes through `guard`.
40+
*
41+
* Operates on typed stream parts rather than the encoded SSE bytes, where JSON
42+
* envelopes and escaping would split a marker beyond the reach of any substring
43+
* scan. The guard's holdback is released on `text-end`, so emitted deltas stay
44+
* inside the text block they belong to; the TransformStream's own `flush` only
45+
* covers a stream that ends without one.
46+
*
47+
* `stopStream` is not used: it obliges the caller to synthesize finish chunks
48+
* whose shape follows the SDK version. Suppressing text keeps the stream well
49+
* formed instead.
50+
*
51+
* @param guard - guard for this stream
52+
* @param onReject - called once when the guard first rejects the answer
53+
* @returns transform factory accepted by `streamText`
54+
*/
55+
function guardedTransform<TOOLS extends ToolSet>(guard: StreamGuard, onReject: () => void) {
56+
return (): TransformStream<TextStreamPart<TOOLS>, TextStreamPart<TOOLS>> => {
57+
let lastTextId: string | null = null;
58+
let rejectReported = false;
59+
60+
/**
61+
* Forward the guard's verdict downstream, reporting a rejection at most once.
62+
*
63+
* A rejection travels as an error part rather than more text, so the client
64+
* can tell it apart from the answer and drop what it has already rendered.
65+
*
66+
* @param verdict - what the guard allows to be sent
67+
* @param controller - transform stream controller
68+
* @param id - id of the text block the delta belongs to
69+
*/
70+
const forward = (
71+
verdict: GuardVerdict,
72+
controller: TransformStreamDefaultController<TextStreamPart<TOOLS>>,
73+
id: string | null
74+
): void => {
75+
if (verdict.rejected) {
76+
if (!rejectReported) {
77+
rejectReported = true;
78+
79+
controller.enqueue({
80+
type: 'error',
81+
error: new Error(verdict.emit),
82+
} as TextStreamPart<TOOLS>);
83+
84+
onReject();
85+
}
86+
87+
return;
88+
}
89+
90+
if (verdict.emit && id !== null) {
91+
controller.enqueue({
92+
type: 'text-delta',
93+
id,
94+
text: verdict.emit,
95+
} as TextStreamPart<TOOLS>);
96+
}
97+
};
98+
99+
return new TransformStream<TextStreamPart<TOOLS>, TextStreamPart<TOOLS>>({
100+
transform(chunk, controller): void {
101+
if (chunk.type === 'text-delta') {
102+
lastTextId = chunk.id;
103+
forward(guard.push(chunk.text), controller, chunk.id);
104+
105+
return;
106+
}
107+
108+
if (chunk.type === 'text-end') {
109+
forward(guard.flush(), controller, chunk.id);
110+
}
111+
112+
controller.enqueue(chunk);
113+
},
114+
115+
flush(controller): void {
116+
forward(guard.flush(), controller, lastTextId);
117+
},
118+
});
119+
};
120+
}
121+
19122
/**
20123
* Interface for interacting with Vercel AI Gateway
21124
*
@@ -68,15 +171,17 @@ class VercelAIApi {
68171
/**
69172
* Send a system/prompt pair to the model and return the generated text as a stream
70173
*
71-
* @param {CompletionParams} params - system instruction and prompt to complete
174+
* @param {StreamParams} params - system instruction, prompt and output guard
72175
* @returns {StreamTextResult} text generated by the model, as a stream
73176
*/
74-
public stream({ system, prompt }: CompletionParams): ReturnType<typeof streamText> {
177+
public stream({ system, prompt, guard, onReject }: StreamParams): ReturnType<typeof streamText> {
75178
return streamText({
76179
model: this.modelId,
77180
system,
78181
prompt,
79182
providerOptions: this.providerOptions,
183+
// eslint-disable-next-line camelcase, @typescript-eslint/camelcase
184+
experimental_transform: guardedTransform(guard, onReject),
80185
});
81186
}
82187
}

src/services/askAi/routes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ export function createAiStreamRouter(): express.Router {
9595
return;
9696
}
9797

98-
result.pipeUIMessageStreamToResponse(res);
98+
/** Reasoning is neither scanned for the nonce nor rendered anywhere */
99+
result.pipeUIMessageStreamToResponse(res, { sendReasoning: false });
99100
} catch (error) {
100101
next(error);
101102
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { echoesNonce, SUGGESTION_FALLBACK_MESSAGE } from './nonceEcho';
2+
3+
/**
4+
* What the guard allows the transport to send downstream
5+
*/
6+
export interface GuardVerdict {
7+
/**
8+
* Text safe to forward now, which is what was fed in minus the holdback
9+
*/
10+
emit: string;
11+
12+
/**
13+
* Whether the nonce was found. Once true it stays true and no further model
14+
* text is forwarded.
15+
*/
16+
rejected: boolean;
17+
}
18+
19+
/**
20+
* Port implemented by the domain and consumed by the transport, so the
21+
* provider adapter never needs to know what makes output unsafe
22+
*/
23+
export interface StreamGuard {
24+
/**
25+
* Inspect the next piece of model output
26+
*
27+
* @param chunk - text delta produced by the model
28+
* @returns {GuardVerdict} text safe to forward now
29+
*/
30+
push(chunk: string): GuardVerdict;
31+
32+
/**
33+
* Release whatever is still withheld, at the end of a text block
34+
*
35+
* @returns {GuardVerdict} remaining text safe to forward
36+
*/
37+
flush(): GuardVerdict;
38+
}
39+
40+
/**
41+
* Streaming counterpart of {@link echoesNonce}.
42+
*
43+
* The nonce can arrive split across two deltas, so scanning each delta alone
44+
* would never see it whole. The guard keeps a *holdback*: the last
45+
* `nonce.length - 1` characters fed in so far, kept unsent. Every new delta is
46+
* scanned together with the holdback, and only the part that can no longer
47+
* begin the nonce is released.
48+
*
49+
* That length is the exact minimum. An occurrence spans `nonce.length`
50+
* characters, so holding one less leaves it inside a single scanned window.
51+
*
52+
* {@link StreamGuard.flush} has to release the holdback at the end of a text
53+
* block, because that text belongs to the block and cannot be emitted under the
54+
* next one's id. Its tail is kept as scanning context instead, without which a
55+
* nonce split across two blocks would pass unseen.
56+
*
57+
* On rejection nothing more is released and {@link SUGGESTION_FALLBACK_MESSAGE}
58+
* is returned once, for the transport to deliver as it sees fit. Text already
59+
* sent cannot be taken back, and the holdback cuts it at an arbitrary character.
60+
*
61+
* @param nonce - per-request nonce used in the prompt markers
62+
* @returns {StreamGuard} guard for a single stream, not reusable
63+
*/
64+
export function createStreamGuard(nonce: string): StreamGuard {
65+
const holdback = Math.max(nonce.length - 1, 0);
66+
67+
let withheld = '';
68+
let sentTail = '';
69+
let rejected = false;
70+
71+
/**
72+
* Keep only as much already-sent text as a nonce could still overlap
73+
*
74+
* @param text - text sent so far, ending with what was just emitted
75+
* @returns {string} trailing scanning context
76+
*/
77+
const keepTail = (text: string): string => text.slice(Math.max(text.length - holdback, 0));
78+
79+
/**
80+
* Mark the stream as rejected and produce the one verdict that still carries
81+
* text: the fallback message
82+
*
83+
* @returns {GuardVerdict} verdict replacing the rest of the answer
84+
*/
85+
const reject = (): GuardVerdict => {
86+
rejected = true;
87+
withheld = '';
88+
sentTail = '';
89+
90+
return {
91+
emit: SUGGESTION_FALLBACK_MESSAGE,
92+
rejected: true,
93+
};
94+
};
95+
96+
return {
97+
push(chunk: string): GuardVerdict {
98+
if (rejected) {
99+
return {
100+
emit: '',
101+
rejected: true,
102+
};
103+
}
104+
105+
if (echoesNonce(sentTail + withheld + chunk, nonce)) {
106+
return reject();
107+
}
108+
109+
const pending = withheld + chunk;
110+
const sendable = Math.max(pending.length - holdback, 0);
111+
const emit = pending.slice(0, sendable);
112+
113+
withheld = pending.slice(sendable);
114+
sentTail = keepTail(sentTail + emit);
115+
116+
return {
117+
emit,
118+
rejected: false,
119+
};
120+
},
121+
122+
flush(): GuardVerdict {
123+
if (rejected) {
124+
return {
125+
emit: '',
126+
rejected: true,
127+
};
128+
}
129+
130+
const pending = withheld;
131+
132+
withheld = '';
133+
134+
if (echoesNonce(sentTail + pending, nonce)) {
135+
return reject();
136+
}
137+
138+
sentTail = keepTail(sentTail + pending);
139+
140+
return {
141+
emit: pending,
142+
rejected: false,
143+
};
144+
},
145+
};
146+
}

src/services/askAi/service.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import HawkCatcher from '@hawk.so/nodejs';
22
import { vercelAIApi } from '../../integrations/vercel-ai/';
33
import { buildEventPrompt, spotlightInstruction } from './security/spotlighting';
44
import { echoesNonce, SUGGESTION_FALLBACK_MESSAGE } from './security/nonceEcho';
5+
import { createStreamGuard } from './security/holdback';
56
import { ctoInstruction } from './instructions/cto';
67
import { EventsFactoryInterface } from '../types';
78
import type { Event } from '../types';
@@ -67,8 +68,9 @@ export class AskAiService {
6768
/**
6869
* Generate streaming suggestion for the event
6970
*
70-
* The payload is spotlighted by {@link buildEventPrompt} exactly as in
71-
* {@link AskAiService.generateSuggestion}.
71+
* Defended exactly as {@link AskAiService.generateSuggestion}, except the
72+
* answer is checked by {@link createStreamGuard} as it streams out rather than
73+
* by {@link echoesNonce} once it is complete.
7274
*
7375
* @param eventsFactory - events factory
7476
* @param eventId - event id
@@ -87,6 +89,8 @@ export class AskAiService {
8789
return vercelAIApi.stream({
8890
system: ctoInstruction + spotlightInstruction(nonce),
8991
prompt,
92+
guard: createStreamGuard(nonce),
93+
onReject: () => reportRejectedSuggestion(eventId, originalEventId),
9094
});
9195
}
9296

0 commit comments

Comments
 (0)