← back to AbramsOS
lib/compliance-guardian.js
162 lines
// Compliance Guardian — three layered defenses per AGENTS.md hard rules:
// 1. isAllowedDestination(url) — outbound HTTP allowlist
// 2. detectPromptInjection(txt) — scan external content before feeding to LLM
// 3. redactPii(text) — mask PII before sending to model providers
//
// Pure functions, no I/O. Caller decides what to do on a positive signal.
// ── 1. Outbound allowlist ───────────────────────────────────────────
// Only these hosts (or their public subdomains) are allowed for outbound
// fetches that feed LLM prompts. Add merchants/issuers here as needed.
const ALLOWED_HOSTS = new Set([
// Local LLM endpoints
'localhost', '127.0.0.1', '192.168.1.133',
// Gov + safety registries (read-only, public)
'cpsc.gov', 'saferproducts.gov',
'nhtsa.gov', 'vpic.nhtsa.dot.gov',
'fda.gov', 'api.fda.gov', 'accessgudid.nlm.nih.gov',
'dailymed.nlm.nih.gov', 'rxnav.nlm.nih.gov',
'medicare.gov',
'consumerfinance.gov', 'ftc.gov', 'transportation.gov',
// Plaid (sandbox + production endpoints; we only ever speak to ours via SDK)
'production.plaid.com', 'development.plaid.com', 'sandbox.plaid.com',
// Google APIs (Gmail / Drive / OAuth)
'gmail.googleapis.com', 'www.googleapis.com', 'oauth2.googleapis.com',
'accounts.google.com',
]);
// Allow any subdomain of these (e.g. *.cpsc.gov)
const ALLOWED_DOMAIN_SUFFIXES = [
'.cpsc.gov', '.fda.gov', '.nhtsa.gov', '.consumerfinance.gov',
'.ftc.gov', '.transportation.gov', '.medicare.gov', '.nlm.nih.gov',
'.googleapis.com', '.plaid.com',
];
function isAllowedDestination(urlOrHost) {
if (!urlOrHost) return false;
let host;
try {
host = (typeof urlOrHost === 'string' && urlOrHost.includes('://'))
? new URL(urlOrHost).hostname.toLowerCase()
: String(urlOrHost).toLowerCase();
} catch (_) {
return false;
}
if (ALLOWED_HOSTS.has(host)) return true;
for (const suffix of ALLOWED_DOMAIN_SUFFIXES) {
if (host.endsWith(suffix)) return true;
}
return false;
}
// ── 2. Prompt-injection scanner ─────────────────────────────────────
// Pattern-based, deliberately conservative. Returns { hit: bool, patterns: [str] }.
// The agent should refuse-or-quarantine content that tests positive, NOT auto-strip.
const INJECTION_PATTERNS = [
/\bignore (?:all |any |the |your )?(?:previous|prior|above|earlier|preceding) (?:instructions?|prompt|context|rules?)\b/i,
/\bdisregard (?:all |any |the |your )?(?:previous|prior|above|earlier|preceding) (?:instructions?|prompt|context|rules?)\b/i,
/\bforget (?:everything|all|previous|prior)\b/i,
/\b(?:you are|act as|role[- ]?play as) (?:now|a) (?:different|new|another) (?:assistant|ai|bot|agent|system)\b/i,
/\b(?:override|bypass|jailbreak)\b.{0,20}\b(?:safety|filter|rule|prompt|system|instruction)\b/i,
/<\|?(?:system|user|assistant|im_start|im_end)\|?>/i, // chatml fragments
/\[\[(?:system|admin|sudo)\]\]/i,
/^\s*system:\s/im, // role injection at line start
/^\s*assistant:\s/im,
/\[INST\]|\[\/INST\]/i, // llama-style instruction tags
/<<\s*sys\s*>>/i,
/\bdo\s+not\s+(?:tell|inform|reveal|notify)\s+(?:the\s+)?(?:user|owner|steve)\b/i,
/\bsend\s+(?:all|the|your)\s+(?:data|emails?|secrets?|tokens?|keys?)\s+to\b/i, // exfiltration
/\bcurl\s+[^\s]+@/i, // exfiltration via curl
/\beval\s*\(/i, // code execution attempt
/api[_-]?key\s*[:=]/i, // mentions of api keys in untrusted text
];
function detectPromptInjection(text) {
if (!text || typeof text !== 'string') return { hit: false, patterns: [] };
const hits = [];
for (const re of INJECTION_PATTERNS) {
const m = text.match(re);
if (m) hits.push(m[0].slice(0, 80));
}
return { hit: hits.length > 0, patterns: hits };
}
// ── 3. PII redactor ─────────────────────────────────────────────────
// Mask values that should never reach a model provider unless explicitly authorized.
// Returns { redacted, replacements } where replacements is { kind -> count }.
function redactPii(text) {
if (!text) return { redacted: text || '', replacements: {} };
const counts = {};
function bump(kind) { counts[kind] = (counts[kind] || 0) + 1; }
let redacted = text;
// Credit-card-ish (13–19 digits, with optional spaces/dashes)
redacted = redacted.replace(
/\b(?:\d[ -]?){13,19}\b/g,
(m) => { bump('card'); return '[CARD-REDACTED]'; }
);
// SSN
redacted = redacted.replace(
/\b\d{3}[- ]?\d{2}[- ]?\d{4}\b/g,
() => { bump('ssn'); return '[SSN-REDACTED]'; }
);
// US phone (very loose; runs after SSN to avoid double-match)
redacted = redacted.replace(
/(?:\+?1[-. ]?)?\(?\b[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
() => { bump('phone'); return '[PHONE-REDACTED]'; }
);
// Email — keep the domain as a hint, mask the local part
redacted = redacted.replace(
/\b([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/g,
(_, _local, domain) => { bump('email'); return `[EMAIL-REDACTED]@${domain}`; }
);
// US ZIP (only keep the 3-digit prefix)
redacted = redacted.replace(
/\b(\d{3})\d{2}(?:-\d{4})?\b(?=\s|,|$|\.)/g,
(_m, prefix) => { bump('zip'); return `${prefix}xx`; }
);
// Stripe-like keys, OpenAI-like keys
redacted = redacted.replace(
/\b(?:sk|pk|rk|whsec)_(?:test|live)?_?[a-zA-Z0-9]{16,}\b/g,
() => { bump('api_key'); return '[API-KEY-REDACTED]'; }
);
redacted = redacted.replace(
/\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g,
() => { bump('api_key'); return '[API-KEY-REDACTED]'; }
);
return { redacted, replacements: counts };
}
// ── 4. One-shot guard for LLM-bound text ────────────────────────────
// Convenience: redact PII + scan for injection. Returns:
// { safe: bool, text: <redacted text>, redactions: {...}, injection: {...} }
function guardForLlm(text) {
const { redacted, replacements } = redactPii(text);
const injection = detectPromptInjection(redacted);
return {
safe: !injection.hit,
text: redacted,
redactions: replacements,
injection,
};
}
module.exports = {
isAllowedDestination,
detectPromptInjection,
redactPii,
guardForLlm,
ALLOWED_HOSTS,
ALLOWED_DOMAIN_SUFFIXES,
};