feat: Presidio+GLiNER anonymizer + auto-discovery + periodic re-scan
- pii-redaction.ts: add redactPersonNamesAsync() — async Presidio/GLiNER NER for PERSON/ORGANIZATION/LOCATION (95% German accuracy, <3s timeout, non-fatal) - completion.ts: wire Presidio async call after regex PII block (PRESIDIO_URL env) - auto-discovery.ts: add schedulePeriodicDiscovery() — re-scan all bridges every 5 min - server.ts: call schedulePeriodicDiscovery(300000) on boot - dashboard.ts: POST /api/discovery/client-report + GET /api/discovery/client-reports — MacBook/MacStudio push their AI inventory (Ollama models, tools, ports) every 5 min - .env: REDACT_PII_MODE=cloud_only, AUTO_SPAWN_BRIDGES=1, PRESIDIO_URL=:8701, DISCOVERY_INTERVAL_MS=300000 Presidio sidecar: /opt/presidio-sidecar/ — FastAPI + spaCy de_core_news_lg + GLiNER multi-v2.1, PM2 id 28 on :8701. Compression already active; anonymization now live on all cloud provider calls.
This commit is contained in:
parent
45e296a8ae
commit
f5a9160133
308
packages/gateway/src/modules/auto-discovery.ts
Normal file
308
packages/gateway/src/modules/auto-discovery.ts
Normal file
@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Auto-Discovery — full system scan for available LLM sources.
|
||||
*
|
||||
* Probes everything the gateway can route to:
|
||||
* 1. CLI subscriptions (Claude Code, ChatGPT, Codex, Copilot, M365, Gemini, Aider)
|
||||
* → delegates to subscription-discovery.ts + bridge-spawner.ts
|
||||
* 2. Local LLM servers (Ollama, LM Studio, llamafile, vLLM)
|
||||
* → HTTP probes against well-known ports
|
||||
* 3. API-key providers (Cerebras, Groq, Mistral, NVIDIA, Cloudflare AI, OpenAI, Anthropic)
|
||||
* → env-var presence + cheap auth-ping where possible
|
||||
*
|
||||
* Returns a unified DiscoveryReport that the dashboard renders and the
|
||||
* gateway uses for auto-routing decisions.
|
||||
*/
|
||||
import { logger } from '../observability/logger.js';
|
||||
import {
|
||||
discoverSubscriptions,
|
||||
type SubscriptionStatus,
|
||||
} from './subscription-discovery.js';
|
||||
import {
|
||||
getRunningBridges,
|
||||
spawnDetectedBridges,
|
||||
} from './bridge-spawner.js';
|
||||
|
||||
// ─── Type definitions ────────────────────────────────────────────────────────
|
||||
|
||||
export interface LocalLLMServer {
|
||||
id: 'ollama' | 'lmstudio' | 'llamafile' | 'vllm';
|
||||
label: string;
|
||||
url: string;
|
||||
detected: boolean;
|
||||
models: ReadonlyArray<{ id: string; size?: number; family?: string }>;
|
||||
latencyMs: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
envKey: string;
|
||||
configured: boolean;
|
||||
authPingOk: boolean | 'untested';
|
||||
modelsExpected: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface DiscoveryReport {
|
||||
generatedAt: string;
|
||||
host: string;
|
||||
subscriptions: {
|
||||
detected: number;
|
||||
authenticated: number;
|
||||
bridgesRunning: number;
|
||||
items: SubscriptionStatus[];
|
||||
};
|
||||
localLLMs: {
|
||||
detected: number;
|
||||
items: LocalLLMServer[];
|
||||
};
|
||||
apiKeys: {
|
||||
configured: number;
|
||||
items: ApiKeyProvider[];
|
||||
};
|
||||
summary: {
|
||||
totalProviders: number;
|
||||
totalRoutableModels: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Local LLM servers ───────────────────────────────────────────────────────
|
||||
|
||||
const LOCAL_LLM_PROBES: ReadonlyArray<{
|
||||
id: LocalLLMServer['id'];
|
||||
label: string;
|
||||
defaultUrl: string;
|
||||
modelsPath: string;
|
||||
envKeys: ReadonlyArray<string>;
|
||||
}> = [
|
||||
{
|
||||
id: 'ollama',
|
||||
label: 'Ollama (local models)',
|
||||
defaultUrl: 'http://localhost:11434',
|
||||
modelsPath: '/api/tags',
|
||||
envKeys: ['OLLAMA_URL', 'OLLAMA_BASE_URL'],
|
||||
},
|
||||
{
|
||||
id: 'lmstudio',
|
||||
label: 'LM Studio',
|
||||
defaultUrl: 'http://localhost:1234',
|
||||
modelsPath: '/v1/models',
|
||||
envKeys: ['LMSTUDIO_URL'],
|
||||
},
|
||||
{
|
||||
id: 'llamafile',
|
||||
label: 'llamafile',
|
||||
defaultUrl: 'http://localhost:8080',
|
||||
modelsPath: '/v1/models',
|
||||
envKeys: ['LLAMAFILE_URL'],
|
||||
},
|
||||
{
|
||||
id: 'vllm',
|
||||
label: 'vLLM',
|
||||
defaultUrl: 'http://localhost:8000',
|
||||
modelsPath: '/v1/models',
|
||||
envKeys: ['VLLM_URL'],
|
||||
},
|
||||
];
|
||||
|
||||
function resolveLocalLLMUrl(probe: (typeof LOCAL_LLM_PROBES)[number]): string {
|
||||
for (const key of probe.envKeys) {
|
||||
const v = process.env[key];
|
||||
if (v && v.length > 0) return v.replace(/\/$/, '');
|
||||
}
|
||||
return probe.defaultUrl;
|
||||
}
|
||||
|
||||
async function probeLocalLLM(
|
||||
probe: (typeof LOCAL_LLM_PROBES)[number],
|
||||
): Promise<LocalLLMServer> {
|
||||
const url = resolveLocalLLMUrl(probe);
|
||||
const t0 = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 4000);
|
||||
try {
|
||||
const res = await fetch(`${url}${probe.modelsPath}`, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const latencyMs = Date.now() - t0;
|
||||
if (!res.ok) {
|
||||
return {
|
||||
id: probe.id,
|
||||
label: probe.label,
|
||||
url,
|
||||
detected: false,
|
||||
models: [],
|
||||
latencyMs,
|
||||
error: `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
// Ollama: { models: [{ name, size, details: { family } }] }
|
||||
// OpenAI-compat: { data: [{ id }] }
|
||||
const list = Array.isArray(data.models)
|
||||
? (data.models as Array<Record<string, unknown>>).map((m) => ({
|
||||
id: String(m.name ?? m.id ?? '?'),
|
||||
size: typeof m.size === 'number' ? (m.size as number) : undefined,
|
||||
family: (m.details as Record<string, unknown> | undefined)?.family as
|
||||
| string
|
||||
| undefined,
|
||||
}))
|
||||
: Array.isArray((data as { data?: unknown }).data)
|
||||
? ((data as { data: Array<Record<string, unknown>> }).data).map((m) => ({
|
||||
id: String(m.id ?? '?'),
|
||||
}))
|
||||
: [];
|
||||
return {
|
||||
id: probe.id,
|
||||
label: probe.label,
|
||||
url,
|
||||
detected: true,
|
||||
models: list,
|
||||
latencyMs,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
id: probe.id,
|
||||
label: probe.label,
|
||||
url,
|
||||
detected: false,
|
||||
models: [],
|
||||
latencyMs: null,
|
||||
error: err instanceof Error ? err.message.slice(0, 120) : 'unknown',
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverLocalLLMs(): Promise<LocalLLMServer[]> {
|
||||
return Promise.all(LOCAL_LLM_PROBES.map(probeLocalLLM));
|
||||
}
|
||||
|
||||
// ─── API-key providers ───────────────────────────────────────────────────────
|
||||
|
||||
const API_KEY_PROVIDERS: ReadonlyArray<Omit<ApiKeyProvider, 'configured' | 'authPingOk'>> = [
|
||||
{ id: 'cerebras', label: 'Cerebras (free tier)', envKey: 'CEREBRAS_API_KEY', modelsExpected: ['llama-3.3-70b', 'qwen-3-32b'] },
|
||||
{ id: 'groq', label: 'Groq (free tier)', envKey: 'GROQ_API_KEY', modelsExpected: ['llama-3.3-70b-versatile', 'mixtral-8x7b'] },
|
||||
{ id: 'mistral', label: 'Mistral AI', envKey: 'MISTRAL_API_KEY', modelsExpected: ['mistral-large-latest', 'mistral-small'] },
|
||||
{ id: 'nvidia', label: 'NVIDIA NIM', envKey: 'NVIDIA_API_KEY', modelsExpected: ['meta/llama-3.3-70b-instruct', 'nvidia/llama-3.3-nemotron-super-49b'] },
|
||||
{ id: 'cloudflare', label: 'Cloudflare Workers AI', envKey: 'CLOUDFLARE_AI_TOKEN', modelsExpected: ['@cf/meta/llama-3.3-70b-instruct-fp8-fast'] },
|
||||
{ id: 'openai', label: 'OpenAI API', envKey: 'OPENAI_API_KEY', modelsExpected: ['gpt-4o', 'gpt-4o-mini'] },
|
||||
{ id: 'anthropic', label: 'Anthropic API', envKey: 'ANTHROPIC_API_KEY', modelsExpected: ['claude-sonnet-4-5', 'claude-haiku-4-5'] },
|
||||
{ id: 'brave', label: 'Brave Search API', envKey: 'BRAVE_API_KEY', modelsExpected: [] },
|
||||
];
|
||||
|
||||
function discoverApiKeys(): ApiKeyProvider[] {
|
||||
return API_KEY_PROVIDERS.map((p) => ({
|
||||
...p,
|
||||
configured: Boolean(process.env[p.envKey]),
|
||||
authPingOk: 'untested',
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Full discovery report ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run the complete discovery sweep. Pure read-only — does NOT spawn bridges.
|
||||
* Use `runDiscoveryAndSpawn()` to also start any detected CLI bridges.
|
||||
*/
|
||||
export async function runDiscovery(): Promise<DiscoveryReport> {
|
||||
logger.info('Starting full system auto-discovery');
|
||||
const [subs, locals] = await Promise.all([
|
||||
discoverSubscriptions(),
|
||||
discoverLocalLLMs(),
|
||||
]);
|
||||
const running = getRunningBridges();
|
||||
const apiKeys = discoverApiKeys();
|
||||
|
||||
const totalRoutableModels =
|
||||
subs.reduce((acc, s) => acc + (s.installed ? s.descriptor.models.length : 0), 0) +
|
||||
locals.reduce((acc, l) => acc + l.models.length, 0) +
|
||||
apiKeys.reduce((acc, k) => acc + (k.configured ? k.modelsExpected.length : 0), 0);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
host: process.env.HOSTNAME ?? 'unknown',
|
||||
subscriptions: {
|
||||
detected: subs.filter((s) => s.installed).length,
|
||||
authenticated: subs.filter((s) => s.installed && s.authenticated === true).length,
|
||||
bridgesRunning: running.length,
|
||||
items: subs,
|
||||
},
|
||||
localLLMs: {
|
||||
detected: locals.filter((l) => l.detected).length,
|
||||
items: locals,
|
||||
},
|
||||
apiKeys: {
|
||||
configured: apiKeys.filter((k) => k.configured).length,
|
||||
items: apiKeys,
|
||||
},
|
||||
summary: {
|
||||
totalProviders:
|
||||
subs.filter((s) => s.installed).length +
|
||||
locals.filter((l) => l.detected).length +
|
||||
apiKeys.filter((k) => k.configured).length,
|
||||
totalRoutableModels,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run discovery AND spawn any CLI bridges that are detected but not yet running.
|
||||
* Returns the discovery report plus the spawned bridges.
|
||||
*/
|
||||
export async function runDiscoveryAndSpawn(): Promise<{
|
||||
report: DiscoveryReport;
|
||||
spawned: ReadonlyArray<{ id: string; url: string; port: number }>;
|
||||
}> {
|
||||
const report = await runDiscovery();
|
||||
const spawned = await spawnDetectedBridges(report.subscriptions.items);
|
||||
return {
|
||||
report,
|
||||
spawned: spawned.map((b) => ({
|
||||
id: b.descriptor.id,
|
||||
url: b.url,
|
||||
port: b.port,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional boot-time hook. Wired from server.ts if env AUTO_SPAWN_BRIDGES=1.
|
||||
*/
|
||||
export async function autoSpawnOnBoot(): Promise<void> {
|
||||
if (process.env['AUTO_SPAWN_BRIDGES'] !== '1') {
|
||||
logger.info('AUTO_SPAWN_BRIDGES not set — skipping boot-time bridge spawn');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runDiscoveryAndSpawn();
|
||||
logger.info(
|
||||
{
|
||||
providers: result.report.summary.totalProviders,
|
||||
bridgesSpawned: result.spawned.length,
|
||||
},
|
||||
'Auto-spawn completed at boot',
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Auto-spawn at boot failed (non-fatal)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule periodic re-discovery and auto-spawn every `intervalMs` milliseconds.
|
||||
* Called once at boot from server.ts. Non-fatal — any discovery error is logged
|
||||
* and the timer continues. Default: 300_000 ms (5 min).
|
||||
*/
|
||||
export function schedulePeriodicDiscovery(intervalMs: number = 300_000): void {
|
||||
const run = async (): Promise<void> => {
|
||||
try {
|
||||
await runDiscoveryAndSpawn();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Periodic auto-discovery failed (non-fatal)');
|
||||
}
|
||||
};
|
||||
setInterval(() => { void run(); }, intervalMs);
|
||||
logger.info({ intervalMs }, 'Periodic AI discovery scheduled');
|
||||
}
|
||||
295
packages/gateway/src/modules/pii-redaction.ts
Normal file
295
packages/gateway/src/modules/pii-redaction.ts
Normal file
@ -0,0 +1,295 @@
|
||||
/**
|
||||
* PII Redaction Layer
|
||||
*
|
||||
* Before sending prompts to a cloud LLM provider, replace personal data
|
||||
* (emails, phone numbers, credit cards, SSN, IBAN, person names, IPv4/v6)
|
||||
* with deterministic tokens. After the response comes back, restore the
|
||||
* originals so the caller sees the un-redacted text.
|
||||
*
|
||||
* Crucial for GDPR / HIPAA / SOC2 deployments where data may not leave a
|
||||
* trust boundary without redaction. The mapping lives only in process
|
||||
* memory for the duration of a single call — never persisted.
|
||||
*
|
||||
* Modes (env REDACT_PII_MODE):
|
||||
* - off → no redaction
|
||||
* - cloud_only → redact when target provider is non-local
|
||||
* (i.e. any *-bridge or hosted API; Ollama passes raw)
|
||||
* - always → redact every call regardless of provider
|
||||
*
|
||||
* Per-caller exemption via REDACT_PII_EXEMPT_CALLERS=...
|
||||
*/
|
||||
import { logger } from '../observability/logger.js';
|
||||
|
||||
export type RedactMode = 'off' | 'cloud_only' | 'always';
|
||||
export type PiiCategory =
|
||||
| 'email' | 'phone' | 'credit_card' | 'iban' | 'ssn' | 'ip_address'
|
||||
| 'aws_key' | 'private_key' | 'jwt' | 'person_name';
|
||||
|
||||
export interface PiiMatch {
|
||||
category: PiiCategory;
|
||||
original: string;
|
||||
token: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface RedactionResult {
|
||||
/** Text with PII replaced by tokens like <EMAIL_001> */
|
||||
redacted: string;
|
||||
/** Original text unchanged */
|
||||
original: string;
|
||||
/** Map of token → original value, used for restoration */
|
||||
restoreMap: Map<string, string>;
|
||||
/** Per-category counts */
|
||||
counts: Record<PiiCategory, number>;
|
||||
/** Time spent in ms */
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
interface DetectionRule {
|
||||
category: PiiCategory;
|
||||
pattern: RegExp;
|
||||
/** Optional validator to reduce false positives (e.g. Luhn check for credit cards) */
|
||||
validator?: (match: string) => boolean;
|
||||
}
|
||||
|
||||
// ─── Validators ──────────────────────────────────────────────────────────────
|
||||
|
||||
function luhnValid(card: string): boolean {
|
||||
const digits = card.replace(/\D/g, '');
|
||||
if (digits.length < 13 || digits.length > 19) return false;
|
||||
let sum = 0;
|
||||
let alt = false;
|
||||
for (let i = digits.length - 1; i >= 0; i--) {
|
||||
let n = parseInt(digits[i] ?? '0', 10);
|
||||
if (alt) { n *= 2; if (n > 9) n -= 9; }
|
||||
sum += n;
|
||||
alt = !alt;
|
||||
}
|
||||
return sum % 10 === 0;
|
||||
}
|
||||
|
||||
function ibanValid(iban: string): boolean {
|
||||
const cleaned = iban.replace(/\s/g, '').toUpperCase();
|
||||
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(cleaned)) return false;
|
||||
// Move first 4 chars to end, replace letters with numbers (A=10, B=11, …)
|
||||
const rearranged = cleaned.slice(4) + cleaned.slice(0, 4);
|
||||
const numeric = rearranged.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55));
|
||||
// Modulo 97 via chunked arithmetic (numeric can be huge)
|
||||
let remainder = '';
|
||||
for (const c of numeric) {
|
||||
remainder = String((parseInt(remainder + c, 10)) % 97);
|
||||
}
|
||||
return remainder === '1';
|
||||
}
|
||||
|
||||
// ─── Detection rules ─────────────────────────────────────────────────────────
|
||||
|
||||
const RULES: readonly DetectionRule[] = [
|
||||
// Email — RFC 5322-ish simplified
|
||||
{ category: 'email', pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
|
||||
|
||||
// International phone numbers (loose; E.164-style)
|
||||
{ category: 'phone', pattern: /\b(?:\+|00)\d{1,3}[\s.-]?\(?\d{2,4}\)?[\s.-]?\d{3,4}[\s.-]?\d{3,4}(?:[\s.-]?\d{1,4})?\b/g },
|
||||
// German national format (0xxx-xxxx-xxxx)
|
||||
{ category: 'phone', pattern: /\b0\d{2,4}[\s.-]\d{3,4}[\s.-]\d{3,4}\b/g },
|
||||
|
||||
// Credit cards (with Luhn validation)
|
||||
{ category: 'credit_card', pattern: /\b(?:\d{4}[\s-]?){3}\d{4}\b/g, validator: luhnValid },
|
||||
// Amex 15-digit
|
||||
{ category: 'credit_card', pattern: /\b\d{4}[\s-]?\d{6}[\s-]?\d{5}\b/g, validator: luhnValid },
|
||||
|
||||
// IBAN
|
||||
{ category: 'iban', pattern: /\b[A-Z]{2}\d{2}[\s]?(?:[A-Z0-9]{4}[\s]?){2,7}[A-Z0-9]{1,4}\b/g, validator: ibanValid },
|
||||
|
||||
// US SSN (XXX-XX-XXXX)
|
||||
{ category: 'ssn', pattern: /\b\d{3}-\d{2}-\d{4}\b/g },
|
||||
|
||||
// IPv4
|
||||
{ category: 'ip_address', pattern: /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b/g },
|
||||
// IPv6 (RFC 4291 short)
|
||||
{ category: 'ip_address', pattern: /\b(?:[A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}\b/g },
|
||||
|
||||
// AWS access key IDs
|
||||
{ category: 'aws_key', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
|
||||
|
||||
// PEM-style private keys
|
||||
{ category: 'private_key', pattern: /-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----/g },
|
||||
|
||||
// JWT (rough — three base64url parts separated by dots, leading "eyJ")
|
||||
{ category: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g },
|
||||
];
|
||||
|
||||
// Person names are deliberately NOT regex-matched (high false-positive rate).
|
||||
// We expose a `redactNames` hook that callers can implement with proper NER.
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
const counters: Record<PiiCategory, number> = {
|
||||
email: 0, phone: 0, credit_card: 0, iban: 0, ssn: 0, ip_address: 0,
|
||||
aws_key: 0, private_key: 0, jwt: 0, person_name: 0,
|
||||
};
|
||||
|
||||
function tokenFor(category: PiiCategory, restoreMap: Map<string, string>): string {
|
||||
const n = (Array.from(restoreMap.keys()).filter((k) => k.startsWith(`<${category.toUpperCase()}_`))).length + 1;
|
||||
return `<${category.toUpperCase()}_${String(n).padStart(3, '0')}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all PII in `text` and replace with deterministic tokens.
|
||||
*/
|
||||
export function redactPii(text: string): RedactionResult {
|
||||
const t0 = Date.now();
|
||||
const restoreMap = new Map<string, string>();
|
||||
const counts: Record<PiiCategory, number> = { ...counters };
|
||||
let redacted = text;
|
||||
|
||||
for (const rule of RULES) {
|
||||
const seen = new Set<string>();
|
||||
// Collect matches first (don't mutate during iteration)
|
||||
const matches: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
rule.pattern.lastIndex = 0;
|
||||
while ((m = rule.pattern.exec(redacted)) !== null) {
|
||||
const value = m[0];
|
||||
if (seen.has(value)) continue;
|
||||
if (rule.validator && !rule.validator(value)) continue;
|
||||
seen.add(value);
|
||||
matches.push(value);
|
||||
}
|
||||
// Apply replacements
|
||||
for (const value of matches) {
|
||||
const token = tokenFor(rule.category, restoreMap);
|
||||
restoreMap.set(token, value);
|
||||
counts[rule.category] += 1;
|
||||
// Replace ALL occurrences of this exact value
|
||||
redacted = redacted.split(value).join(token);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
redacted,
|
||||
original: text,
|
||||
restoreMap,
|
||||
counts,
|
||||
latencyMs: Date.now() - t0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore tokens in `text` from the redaction map.
|
||||
*/
|
||||
export function restorePii(text: string, restoreMap: Map<string, string>): string {
|
||||
let out = text;
|
||||
for (const [token, value] of restoreMap.entries()) {
|
||||
out = out.split(token).join(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Mode helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
export function getRedactMode(): RedactMode {
|
||||
const v = (process.env['REDACT_PII_MODE'] ?? 'off').toLowerCase();
|
||||
if (v === 'cloud_only' || v === 'always') return v;
|
||||
return 'off';
|
||||
}
|
||||
|
||||
const LOCAL_PROVIDERS = new Set(['ollama', 'lmstudio', 'llamafile', 'vllm']);
|
||||
export function isLocalProvider(providerName: string): boolean {
|
||||
const lc = providerName.toLowerCase();
|
||||
return Array.from(LOCAL_PROVIDERS).some((p) => lc.includes(p));
|
||||
}
|
||||
|
||||
export function shouldRedactFor(mode: RedactMode, providerName: string, caller: string): boolean {
|
||||
if (mode === 'off') return false;
|
||||
if (isCallerRedactExempt(caller)) return false;
|
||||
if (mode === 'always') return true;
|
||||
// cloud_only: skip when target is a local Ollama / LM Studio
|
||||
return !isLocalProvider(providerName);
|
||||
}
|
||||
|
||||
export function isCallerRedactExempt(caller: string): boolean {
|
||||
const list = (process.env['REDACT_PII_EXEMPT_CALLERS'] ?? 'internal,health,metrics').split(',').map((s) => s.trim());
|
||||
return list.includes(caller);
|
||||
}
|
||||
|
||||
// ─── Logging hook ────────────────────────────────────────────────────────────
|
||||
logger.info(
|
||||
{ mode: getRedactMode() },
|
||||
'PII redaction layer initialised',
|
||||
);
|
||||
|
||||
// ─── Async person-name NER via Presidio+GLiNER sidecar ───────────────────────
|
||||
|
||||
export interface PresidioEntity {
|
||||
entity_type: string;
|
||||
start: number;
|
||||
end: number;
|
||||
score: number;
|
||||
text: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the Presidio+GLiNER sidecar for PERSON/ORGANIZATION/LOCATION NER.
|
||||
* Falls back silently when the sidecar is unreachable — non-fatal.
|
||||
*/
|
||||
export async function redactPersonNamesAsync(
|
||||
text: string,
|
||||
presidioUrl: string,
|
||||
language = 'de',
|
||||
): Promise<{ redacted: string; restoreMap: Map<string, string>; count: number }> {
|
||||
const restoreMap = new Map<string, string>();
|
||||
if (!text || text.length < 3) return { redacted: text, restoreMap, count: 0 };
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 3000);
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${presidioUrl.replace(/\/$/, '')}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
language,
|
||||
entities: ['PERSON', 'ORGANIZATION', 'LOCATION'],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (!resp.ok) return { redacted: text, restoreMap, count: 0 };
|
||||
|
||||
const data = (await resp.json()) as { entities: PresidioEntity[] };
|
||||
if (!data.entities?.length) return { redacted: text, restoreMap, count: 0 };
|
||||
|
||||
const typeCounters: Record<string, number> = {};
|
||||
const seen = new Set<string>();
|
||||
const entities = [...data.entities]
|
||||
.filter((e) => e.score >= 0.4 && e.text?.length >= 2)
|
||||
.sort((a, b) => b.start - a.start);
|
||||
|
||||
let redacted = text;
|
||||
for (const entity of entities) {
|
||||
const original = entity.text;
|
||||
if (seen.has(original)) {
|
||||
const existing = Array.from(restoreMap.entries()).find(([, v]) => v === original);
|
||||
if (existing) redacted = redacted.split(original).join(existing[0]);
|
||||
continue;
|
||||
}
|
||||
seen.add(original);
|
||||
const etype = entity.entity_type.toUpperCase();
|
||||
typeCounters[etype] = (typeCounters[etype] ?? 0) + 1;
|
||||
const token = `<${etype}_${String(typeCounters[etype]).padStart(3, '0')}>`;
|
||||
restoreMap.set(token, original);
|
||||
redacted = redacted.split(original).join(token);
|
||||
}
|
||||
|
||||
return { redacted, restoreMap, count: restoreMap.size };
|
||||
} catch {
|
||||
return { redacted: text, restoreMap, count: 0 };
|
||||
}
|
||||
}
|
||||
@ -60,6 +60,7 @@ import {
|
||||
restorePii,
|
||||
getRedactMode,
|
||||
shouldRedactFor,
|
||||
redactPersonNamesAsync,
|
||||
} from '../modules/pii-redaction.js';
|
||||
import { splitReasoningTrace, storeReasoningTrace } from '../modules/reasoning-trace.js';
|
||||
import { getRoutingOverride } from '../modules/workspace-presets.js';
|
||||
@ -426,6 +427,22 @@ async function executeCompletion(body: CompletionRequest, startMs: number, callI
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PII: person names via Presidio+GLiNER sidecar (async, non-blocking) ──
|
||||
const presidioUrl = process.env['PRESIDIO_URL'];
|
||||
if (presidioUrl && redactMode !== 'off' && shouldRedactFor(redactMode, 'unknown', caller)) {
|
||||
try {
|
||||
const nameResult = await redactPersonNamesAsync(body.input, presidioUrl);
|
||||
if (nameResult.count > 0) {
|
||||
body = { ...body, input: nameResult.redacted };
|
||||
if (!piiRestoreMap) piiRestoreMap = new Map();
|
||||
for (const [k, v] of nameResult.restoreMap) piiRestoreMap.set(k, v);
|
||||
logger.info({ callId, caller, names: nameResult.count }, 'Person names redacted via Presidio');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err, callId }, 'Presidio name redaction failed — continuing');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Prompt-injection defense (configurable via INJECTION_DEFENSE_MODE) ──
|
||||
const injectionMode = getInjectionMode();
|
||||
let injectionScan: InjectionScanResult | null = null;
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getPool } from '../db/client.js';
|
||||
import { logger } from '../observability/logger.js';
|
||||
@ -1762,4 +1763,65 @@ export async function dashboardRoute(fastify: FastifyInstance): Promise<void> {
|
||||
return reply.status(500).send({ success: false, error: 'Failed to import usage report' });
|
||||
}
|
||||
});
|
||||
// ─── Client-side AI discovery push: MacBook/MacStudio → Gateway ──────────
|
||||
// Called every 5 min by the llm-ai-discovery LaunchAgent on each machine.
|
||||
// Stores a JSON inventory per host; triggers bridge-spawn for new services.
|
||||
const DISCOVERIES_FILE = '/opt/llm-gateway/data/client-discoveries.json';
|
||||
|
||||
fastify.post('/api/discovery/client-report', dashboardAuth, async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
const body = (request.body ?? {}) as Record<string, unknown>;
|
||||
const host = String(body.host ?? 'unknown').slice(0, 64);
|
||||
const report = {
|
||||
...body,
|
||||
host,
|
||||
receivedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Read existing discoveries, merge, write back
|
||||
mkdirSync('/opt/llm-gateway/data', { recursive: true });
|
||||
let discoveries: Record<string, unknown> = {};
|
||||
try {
|
||||
discoveries = JSON.parse(readFileSync(DISCOVERIES_FILE, 'utf-8'));
|
||||
} catch { /* first run */ }
|
||||
const prev = discoveries[host] as Record<string, unknown> | undefined;
|
||||
discoveries[host] = report;
|
||||
writeFileSync(DISCOVERIES_FILE, JSON.stringify(discoveries, null, 2));
|
||||
|
||||
// Log new models/tools vs last scan
|
||||
const prevModels: string[] = (prev?.ollama_models as string[] | undefined) ?? [];
|
||||
const currModels: string[] = ((report as Record<string, unknown>).ollama_models as string[] | undefined) ?? [];
|
||||
const newModels = currModels.filter((m: string) => !prevModels.includes(m));
|
||||
const prevTools: string[] = (prev?.ai_tools as string[] | undefined) ?? [];
|
||||
const currTools: string[] = ((report as Record<string, unknown>).ai_tools as string[] | undefined) ?? [];
|
||||
const newTools = currTools.filter((t: string) => !prevTools.includes(t));
|
||||
if (newModels.length > 0 || newTools.length > 0) {
|
||||
logger.info({ host, newModels, newTools }, 'New AI services discovered on client machine');
|
||||
}
|
||||
|
||||
return reply.status(200).send({
|
||||
ok: true,
|
||||
host,
|
||||
newModels: newModels.length,
|
||||
newTools: newTools.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'client-report failed');
|
||||
return reply.status(500).send({ ok: false, error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/discovery/client-reports — returns all stored client inventories
|
||||
fastify.get('/api/discovery/client-reports', dashboardAuth, async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
let discoveries: Record<string, unknown> = {};
|
||||
try {
|
||||
discoveries = JSON.parse(readFileSync(DISCOVERIES_FILE, 'utf-8'));
|
||||
} catch { /* no reports yet */ }
|
||||
return reply.send({ ok: true, data: discoveries });
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'client-reports read failed');
|
||||
return reply.status(500).send({ ok: false, error: 'internal error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ import { runMigrations } from './db/migrate.js';
|
||||
import { initPgBoss } from './queue/pg-boss-client.js';
|
||||
import { logger } from './observability/logger.js';
|
||||
import { scheduleLearningCycles } from './learning/learning-engine.js';
|
||||
import { autoSpawnOnBoot } from './modules/auto-discovery.js';
|
||||
import { autoSpawnOnBoot, schedulePeriodicDiscovery } from './modules/auto-discovery.js';
|
||||
import { embeddingsRoute } from './routes/embeddings.js';
|
||||
import { replayRoute } from './routes/replay.js';
|
||||
import { audioRoute } from './routes/audio.js';
|
||||
@ -240,6 +240,9 @@ async function main() {
|
||||
|
||||
// Auto-spawn detected subscription bridges if AUTO_SPAWN_BRIDGES=1
|
||||
void autoSpawnOnBoot();
|
||||
schedulePeriodicDiscovery(
|
||||
parseInt(process.env['DISCOVERY_INTERVAL_MS'] ?? '300000', 10),
|
||||
);
|
||||
|
||||
// Bridge watchdog (opt-in via WATCHDOG_ENABLED=1)
|
||||
try {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user