Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
* accumulated output on every push rather than each chunk in isolation. Once any
* detector trips, the guard latches `blocked` so later (clean) chunks stay blocked.
*
* The window (default 8192 chars) bounds per-push cost to O(window) — any realistic
* attack pattern is far shorter than the window, and the guard scans on every push,
* so a pattern completing in a chunk is always within the window at that moment.
* The window (default 8192 chars) is the straddle context: every push scans the
* full newly-appended chunk plus the preceding `window` chars, so a pattern is seen
* the moment its final character arrives, even when a single push is larger than the
* window (e.g. a non-streaming completion fed through `guardTextStream` as one chunk).
* Per-push cost is O(window + chunk) — O(window) for the usual small token chunks.
*
* Framework-agnostic and zero-dependency. Compose it with any async iterable of
* strings via `guardTextStream`, or drive it manually with `createStreamGuard`.
Expand Down Expand Up @@ -54,8 +56,12 @@ export function createStreamGuard(options: StreamGuardOptions = {}): StreamGuard
let blocked = false;
let last: ScanResult = SAFE;

async function rescan(): Promise<StreamGuardResult> {
const slice = buf.length > window ? buf.slice(buf.length - window) : buf;
// Scan the last `chunkLen + window` chars: the whole chunk just appended (so its
// leading bytes are never skipped, even if the chunk is larger than the window)
// plus `window` chars of prior context to catch a pattern straddling the boundary.
async function rescan(chunkLen: number): Promise<StreamGuardResult> {
const span = chunkLen + window;
const slice = buf.length > span ? buf.slice(buf.length - span) : buf;
last = await guard.scan(slice, { scope });
if (!last.safe) blocked = true;
return { safe: !blocked, blocked, result: last };
Expand All @@ -64,11 +70,11 @@ export function createStreamGuard(options: StreamGuardOptions = {}): StreamGuard
return {
async push(chunk: string): Promise<StreamGuardResult> {
buf += chunk;
return rescan();
return rescan(chunk.length);
},
async end(): Promise<StreamGuardResult> {
if (buf.length === 0) return { safe: !blocked, blocked, result: last };
return rescan();
return rescan(0);
},
buffer: () => buf,
};
Expand Down
24 changes: 24 additions & 0 deletions tests/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,30 @@ describe("createStreamGuard", () => {
expect(r2.result.threatType).toBe(ThreatType.IMPROPER_OUTPUT);
});

it("detects PII in a single chunk larger than the window (no size-based evasion)", async () => {
// A non-streaming completion fed as one chunk: PII at the start, then more
// than `window` chars of trailing text. The leading bytes must still be scanned.
const sg = createStreamGuard({ window: 256 });
const r = await sg.push("Contact alice@example.com. " + "x".repeat(400));
expect(r.blocked).toBe(true);
expect(r.result.threatType).toBe(ThreatType.PII_OUTPUT);
});

it("verdict is independent of chunk size for identical content", async () => {
const text = "Reach me at bob@example.com. " + "filler ".repeat(80);
const big = createStreamGuard({ window: 128 });
const oneShot = await big.push(text);

const split = createStreamGuard({ window: 128 });
let splitBlocked = false;
for (let i = 0; i < text.length; i += 32) {
const r = await split.push(text.slice(i, i + 32));
if (r.blocked) splitBlocked = true;
}
expect(oneShot.blocked).toBe(true);
expect(splitBlocked).toBe(true);
});

it("latches blocked: a later clean chunk does not un-block", async () => {
const sg = createStreamGuard();
await sg.push("leaking sk-abcdefghijklmnopqrstuvwxyz012345 ");
Expand Down
Loading