← 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,
};