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
7 changes: 7 additions & 0 deletions src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import anyAscii from 'any-ascii';
import { MAX_INPUT_BYTES } from './defaults.js';
import { PLACEHOLDER_SENTINEL_LEFT, PLACEHOLDER_SENTINEL_RIGHT } from './placeholder-escape.js';

/** Adversarial-resistant input normalisation with offset map.
* Steps in order: whitespace-PII collapse (gated by digit/@ post-check),
Expand All @@ -22,6 +23,12 @@ const ZERO_WIDTH_CHARS = new Set([
'', // ZERO WIDTH NO-BREAK SPACE / BOM
'⁠', // WORD JOINER
'­', // SOFT HYPHEN
// Placeholder-escape PUA sentinels — strip before detection so the
// GLiNER tokenizer never sees them. Leaving them in causes spurious
// private_person spans on the sentinel bytes themselves, which then
// corrupt user-authored `{{...}}` templates after vault substitution.
PLACEHOLDER_SENTINEL_LEFT,
PLACEHOLDER_SENTINEL_RIGHT,
]);

/** Same characters as the Python `_SPACED_PII_RE`. Word-anchored on
Expand Down
6 changes: 5 additions & 1 deletion src/placeholder-escape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
*/

export const PLACEHOLDER_OPEN = '{{';
export const PLACEHOLDER_OPEN_ESCAPED = '';
/** First char of the 2-char PUA sentinel pair (U+E000). */
export const PLACEHOLDER_SENTINEL_LEFT = '\uE000';
/** Second char of the 2-char PUA sentinel pair (U+E001). */
export const PLACEHOLDER_SENTINEL_RIGHT = '\uE001';
export const PLACEHOLDER_OPEN_ESCAPED = PLACEHOLDER_SENTINEL_LEFT + PLACEHOLDER_SENTINEL_RIGHT;

export function escapePlaceholders(text: string): string {
return text.split(PLACEHOLDER_OPEN).join(PLACEHOLDER_OPEN_ESCAPED);
Expand Down
25 changes: 25 additions & 0 deletions test/normalize.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { normalizeForDetection, remapSpan } from '../src/normalize.js';
import { PLACEHOLDER_OPEN_ESCAPED, escapePlaceholders } from '../src/placeholder-escape.js';

describe('normalizeForDetection', () => {
it('passes through ASCII unchanged', () => {
Expand All @@ -13,6 +14,30 @@ describe('normalizeForDetection', () => {
}
});

it('strips placeholder-escape PUA sentinels (U+E000 / U+E001)', () => {
// The escape mechanism rewrites user-authored `{{` to a 2-char PUA
// pair before detection. Normalise must strip those bytes so the
// GLiNER tokenizer never tags them as PII — leaving them in produces
// spurious `private_person` spans on the sentinel itself, which
// corrupts the user's `{{...}}` template after vault substitution.
const escaped = escapePlaceholders('{{name}}');
const r = normalizeForDetection(escaped);
expect(r.normalized).toBe('name}}');
// A span over just `name` in normalised coords [0, 4) remaps to the
// word's location in escaped coords [2, 6) — sentinel positions are
// skipped, so vault substitution never touches them.
const [origStart, origEnd] = remapSpan(0, 4, r.normToOrig);
expect(origStart).toBe(2);
expect(origEnd).toBe(6);
});

it('does not pass-through inputs that contain the PUA sentinel', () => {
// Bare sentinel triggers the non-ASCII path; ensure it is stripped.
const text = `prefix${PLACEHOLDER_OPEN_ESCAPED}suffix`;
const r = normalizeForDetection(text);
expect(r.normalized).toBe('prefixsuffix');
});

it('strips zero-width characters', () => {
// ZWSP between `john` and `@`. Original length 18, normalised 17.
const text = 'john​@example.com';
Expand Down
19 changes: 19 additions & 0 deletions test/nullpii.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,25 @@ describe('NullPii e2e pipeline (mocked ONNX)', () => {
await b.dispose();
});

it('preserves user-authored {{...}} templates around a real PII hit', async () => {
// Regression: gateway report showed `{{short-kebab-case-slug}}` in a
// system prompt was corrupted to `{{PII_PRIVATE_PERSON_2_…}}short-kebab-case-slug}}`
// because the escape PUA sentinel was tagged as `private_person`.
// With sentinels stripped before detection, the template survives the
// round-trip and the real PII (email) is the only thing replaced.
const n = new NullPii({ modelDir: '/fake', backend: 'cpu' });
const text = 'name: {{short-kebab-case-slug}} — contact alice@acme.io';
const out = await n.sanitize(text);
// Exactly one span — the email — fires from the recognizer pack.
// The template syntax is untouched.
expect(out.spans.find((s) => s.label === 'private_email')?.text).toBe('alice@acme.io');
expect(out.sanitized).toContain('{{short-kebab-case-slug}}');
expect(out.sanitized).not.toContain('{{PII_PRIVATE_PERSON');
const restored = n.restore(out.sanitized, out.sessionId);
expect(restored.restored).toBe(text);
await n.dispose();
});

it('init runs once across many sanitize calls', async () => {
const n = new NullPii({ modelDir: '/fake', backend: 'cpu' });
const r1 = await n.sanitize('First email: a@acme.io');
Expand Down
Loading