[object Object]

← 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

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 →