← back to AbramsOS
chat: local-Ollama NER scrub as 2nd layer on external lanes (fail-closed) — closes free-text PII gap (TK-11084)
4acae37c83e536db79b57b1c9c736075652c489f · 2026-09-01 17:06:54 -0700 · Steve
Files touched
A lib/chat-ner.jsM routes/chat.jsM tests/chat.test.js
Diff
commit 4acae37c83e536db79b57b1c9c736075652c489f
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Sep 1 17:06:54 2026 -0700
chat: local-Ollama NER scrub as 2nd layer on external lanes (fail-closed) — closes free-text PII gap (TK-11084)
---
lib/chat-ner.js | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++
routes/chat.js | 25 ++++++++++-
tests/chat.test.js | 61 +++++++++++++++++++++++++++
3 files changed, 206 insertions(+), 1 deletion(-)
diff --git a/lib/chat-ner.js b/lib/chat-ner.js
new file mode 100644
index 0000000..96def47
--- /dev/null
+++ b/lib/chat-ner.js
@@ -0,0 +1,121 @@
+// Defense-in-depth PII scrub — a LOCAL-Ollama NER pass that runs on EXTERNAL
+// lanes only, AFTER the regex/list redactor (lib/chat-redact.js). It closes the
+// one gap the list-based redactor cannot: a person NAME sitting inside a
+// free-text field (a claim note "spoke with John Smith", a reminder title) that
+// was never enumerated in the grounding names list. The model only DETECTS name
+// spans; the replacement is done deterministically in code (the model never
+// rewrites the outgoing text). The NER call goes to the SAME Ollama base as the
+// private lane (Steve's LAN — Mac1 by default), so the text stays inside the
+// same trust boundary as the local lane while it is being scrubbed.
+//
+// FAIL-CLOSED: if the Ollama NER call errors or times out, detectNames() THROWS.
+// The caller (routes/chat.js) turns that into a clean 503 and never sends the
+// un-NER-scrubbed text to an external provider — failing toward LESS exposure.
+
+const BASE = process.env.OLLAMA_BASE_URL || 'http://192.168.1.133:11434';
+const NER_MODEL = process.env.CHAT_NER_MODEL || 'qwen2.5:7b';
+const NER_TIMEOUT_MS = parseInt(process.env.CHAT_NER_TIMEOUT_MS || '12000', 10);
+const MIN_SCRUB_CHARS = 40; // skip trivially-short spans with no grounded context
+
+const SYSTEM = [
+ 'You are a strict PII entity detector. You are given TEXT.',
+ 'Return ONLY the PERSON NAMES (real human first/last/full names) that appear literally in the TEXT.',
+ 'Do NOT return company names, brands, product names, cities, or generic words.',
+ 'Respond with JSON only, exactly: {"names": ["<name>", ...]}. Empty array if none.',
+].join(' ');
+
+function escapeRegExp(s) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+// Pure, deterministic. Replace every occurrence of each detected name with
+// [NAME]. Longest names first so "John Smith" is consumed before "John".
+function applyNames(text, names) {
+ if (!text || !Array.isArray(names) || !names.length) return { text: text || '', redactions: 0 };
+ let out = String(text);
+ let redactions = 0;
+ const ordered = [...new Set(names.map((n) => String(n).trim()).filter(Boolean))]
+ .filter((n) => n.length >= 2 && n.length <= 60 && /[A-Za-z]/.test(n))
+ .sort((a, b) => b.length - a.length);
+ for (const name of ordered) {
+ // separator-tolerant (space/NBSP/hyphen/comma) between tokens, case-insensitive
+ const pattern = name
+ .split(/[\s ,-]+/)
+ .filter(Boolean)
+ .map(escapeRegExp)
+ .join('[\\s\\u00a0\\u2007\\u202f,-]+');
+ if (!pattern) continue;
+ const re = new RegExp(pattern, 'gi');
+ out = out.replace(re, () => { redactions += 1; return '[NAME]'; });
+ }
+ return { text: out, redactions };
+}
+
+// Call local Ollama to DETECT person-name spans. Throws (fail-closed) on any
+// network/timeout/HTTP/parse failure so an external send can be aborted.
+async function detectNames(text) {
+ const src = String(text || '');
+ if (src.trim().length < MIN_SCRUB_CHARS) return [];
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), NER_TIMEOUT_MS);
+ try {
+ const r = await fetch(`${BASE}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: NER_MODEL,
+ format: 'json',
+ stream: false,
+ options: { temperature: 0 },
+ messages: [
+ { role: 'system', content: SYSTEM },
+ { role: 'user', content: `TEXT:\n${src}` },
+ ],
+ }),
+ signal: ctrl.signal,
+ });
+ if (!r.ok) throw new Error(`ner ollama ${r.status}`);
+ const j = await r.json();
+ const content = j.message?.content || '';
+ let parsed;
+ try {
+ parsed = JSON.parse(content);
+ } catch (_) {
+ const m = content.match(/\{[\s\S]*\}/);
+ if (!m) throw new Error('ner unparseable response');
+ parsed = JSON.parse(m[0]);
+ }
+ const names = Array.isArray(parsed?.names) ? parsed.names : [];
+ return names.map((n) => String(n).trim()).filter(Boolean);
+ } catch (err) {
+ throw new Error(`ner_unavailable: ${err.message || err}`);
+ } finally {
+ clearTimeout(t);
+ }
+}
+
+async function scrub(text) {
+ const names = await detectNames(text);
+ return applyNames(text, names);
+}
+
+// Batched: ONE detect call over all spans joined, then deterministic apply to
+// each span. Keeps a chat request to a single NER round-trip regardless of how
+// many message contents are outgoing. Throws (fail-closed) if detection fails.
+async function scrubSpans(spans) {
+ const list = (spans || []).map((s) => String(s ?? ''));
+ const joined = list.join('\n\n----\n\n');
+ if (joined.trim().length < MIN_SCRUB_CHARS) {
+ return { spans: list, redactions: 0 };
+ }
+ const names = await detectNames(joined);
+ let redactions = 0;
+ const out = list.map((s) => {
+ const r = applyNames(s, names);
+ redactions += r.redactions;
+ return r.text;
+ });
+ return { spans: out, redactions };
+}
+
+module.exports = { detectNames, applyNames, scrub, scrubSpans, BASE, NER_MODEL };
diff --git a/routes/chat.js b/routes/chat.js
index 54f859a..884e7b9 100644
--- a/routes/chat.js
+++ b/routes/chat.js
@@ -8,6 +8,7 @@ const express = require('express');
const registry = require('../lib/chat-registry');
const grounding = require('../lib/chat-grounding');
const { redact } = require('../lib/chat-redact');
+const ner = require('../lib/chat-ner');
const providers = require('../lib/chat-providers');
const audit = require('../lib/audit');
@@ -72,8 +73,9 @@ router.post('/api/chat', async (req, res) => {
let system = agent.system;
let redactions = 0;
+ let nerRedactions = 0;
if (isExternal) {
- // Redact EVERY outgoing span: system + all message contents.
+ // Layer 1 — regex/list redactor: strip EVERY outgoing span.
const sys = redact(system, { names });
system = sys.text;
redactions += sys.redactions;
@@ -82,6 +84,26 @@ router.post('/api/chat', async (req, res) => {
redactions += r.redactions;
return { role: m.role, content: r.text };
});
+
+ // Layer 2 — local-Ollama NER scrub (defense in depth): catch person names
+ // sitting in free-text fields that the list-based redactor never enumerated.
+ // ONE batched detect over all external-bound spans; deterministic replace.
+ // FAIL-CLOSED: if the local NER is unavailable, do NOT send un-scrubbed text
+ // to the external provider — return a clean 503 that steers to a local model.
+ try {
+ const spans = [system, ...messages.map((m) => m.content)];
+ const scrubbed = await ner.scrubSpans(spans);
+ system = scrubbed.spans[0];
+ messages = messages.map((m, i) => ({ role: m.role, content: scrubbed.spans[i + 1] }));
+ nerRedactions = scrubbed.redactions;
+ redactions += nerRedactions;
+ } catch (nerErr) {
+ return res.status(503).json({
+ error: 'Local PII scrub (NER) is unavailable, so this external model was not called. Pick a local/private model (Ollama) to ask about your records right now.',
+ lane: 'external',
+ nerUnavailable: true,
+ });
+ }
}
const { text: reply } = await providers.dispatch(model, { system, messages });
@@ -100,6 +122,7 @@ router.post('/api/chat', async (req, res) => {
visibility: model.visibility,
redaction_applied: isExternal,
redactions,
+ ner_redactions: nerRedactions,
},
}).catch(() => {});
diff --git a/tests/chat.test.js b/tests/chat.test.js
index 0efb332..ac116fb 100644
--- a/tests/chat.test.js
+++ b/tests/chat.test.js
@@ -228,3 +228,64 @@ test('claude-cli: spawn env excludes secrets (DB_/AWS_/OPENAI/ANTHROPIC)', () =>
assert.ok(!('OPENAI_API_KEY' in env), 'OPENAI key leaked to child');
assert.ok(!('ANTHROPIC_API_KEY' in env), 'ANTHROPIC key leaked to child');
});
+
+// ── Follow-up: local-Ollama NER scrub (defense in depth on external lanes) ────
+const ner = require('../lib/chat-ner');
+
+// Stub global fetch so the NER unit tests are deterministic + offline.
+function withFetch(impl, fn) {
+ const orig = global.fetch;
+ global.fetch = impl;
+ return Promise.resolve()
+ .then(fn)
+ .finally(() => { global.fetch = orig; });
+}
+const okNames = (names) => async () => ({
+ ok: true,
+ json: async () => ({ message: { content: JSON.stringify({ names }) } }),
+ text: async () => '',
+});
+
+test('ner.applyNames: free-text person name is deterministically scrubbed', () => {
+ const src = 'Claim note: spoke with John Smith about the roof, he will call back.';
+ const { text, redactions } = ner.applyNames(src, ['John Smith']);
+ assert.ok(!/John Smith/.test(text), 'free-text name survived NER apply');
+ assert.match(text, /\[NAME\]/);
+ assert.ok(redactions >= 1);
+ // separator-tolerant + case-insensitive
+ assert.ok(!/JOHN, SMITH/i.test(ner.applyNames('re: JOHN, SMITH', ['John Smith']).text));
+});
+
+test('ner.applyNames: no names → no-op (benign text preserved)', () => {
+ const { text, redactions } = ner.applyNames('What savings do I have from Amazon this month?', []);
+ assert.match(text, /Amazon/);
+ assert.strictEqual(redactions, 0);
+});
+
+test('ner.detectNames: parses the model JSON of person spans', async () => {
+ await withFetch(okNames(['John Smith']), async () => {
+ const names = await ner.detectNames('A long enough claim note where we spoke with John Smith at length today.');
+ assert.deepStrictEqual(names, ['John Smith']);
+ });
+});
+
+test('ner.scrubSpans: batched detect scrubs the name across all external spans', async () => {
+ await withFetch(okNames(['John Smith']), async () => {
+ const { spans, redactions } = await ner.scrubSpans([
+ 'System prompt with no PII here at all, just instructions to be helpful.',
+ 'Claim note: spoke with John Smith about the roof; John Smith will call back.',
+ ]);
+ assert.ok(!spans.join(' ').match(/John Smith/), 'name leaked past NER scrub');
+ assert.ok(redactions >= 2);
+ });
+});
+
+test('ner.detectNames: FAIL-CLOSED — throws when local Ollama is unavailable', async () => {
+ await withFetch(async () => { throw new Error('ECONNREFUSED'); }, async () => {
+ await assert.rejects(
+ () => ner.detectNames('A sufficiently long message that would otherwise be sent to NER for scrubbing.'),
+ /ner_unavailable/,
+ 'NER must throw (fail-closed) so the caller can abort the external send'
+ );
+ });
+});
← 7fca2fd Harden nav-bar chat PII redaction boundary (TK-11084)
·
back to AbramsOS
·
chat: guard nav-bar chat behind authed userId (hide on unaut 9098e9c →