{"version":3,"file":"pii.mjs","names":[],"sources":["../../../../../../../../ai/src/guard/detectors/pii.ts"],"sourcesContent":["import type {\n  GuardrailMatch,\n  GuardrailVerdict,\n  PiiCategory,\n  PiiDetectorOptions,\n  SyncGuardrailDetector,\n} from \"../contracts\";\n\n/** Detector name, used as the namespace prefix on every {@link GuardrailMatch.rule}. */\nconst DETECTOR_NAME = \"pii\";\n\n/** Placeholder substituted for a matched span when the caller supplies no `mask`. */\nconst DEFAULT_MASK = \"[REDACTED]\";\n\n/**\n * The built-in PII category regexes. Each is linear (anchored alternations,\n * no nested quantifiers) so it is safe against catastrophic backtracking on\n * adversarial input. All carry the global flag so a single pass over the\n * text yields every occurrence; `lastIndex` is reset per use so a shared\n * instance never leaks state across calls.\n *\n * - `ssn`         — US Social Security number, `123-45-6789` / `123 45 6789`.\n * - `email`       — a pragmatic address shape, not full RFC 5322.\n * - `phone`       — North-American style, optional `+1`, separators, parens.\n * - `credit-card` — 13–16 digit runs, optional space / hyphen grouping.\n * - `ipv4`        — four dotted octets (loosely; out-of-range octets still match).\n */\nconst CATEGORY_PATTERNS: Record<PiiCategory, RegExp> = {\n  ssn: /\\b\\d{3}[-\\s]\\d{2}[-\\s]\\d{4}\\b/g,\n  email: /\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b/g,\n  phone: /(?:\\+?1[-.\\s]?)?(?:\\(\\d{3}\\)|\\d{3})[-.\\s]?\\d{3}[-.\\s]?\\d{4}\\b/g,\n  \"credit-card\": /\\b(?:\\d[ -]?){13,16}\\b/g,\n  ipv4: /\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\b/g,\n};\n\n/** Every built-in category, in a stable scan order. */\nconst ALL_CATEGORIES: readonly PiiCategory[] = [\n  \"ssn\",\n  \"email\",\n  \"phone\",\n  \"credit-card\",\n  \"ipv4\",\n];\n\n/**\n * A raw hit located inside the inspected text, before it is folded into a\n * {@link GuardrailMatch}. `label` is the category (built-in) or\n * `\"dictionary\"` (an extra term); `start` / `end` are inclusive offsets.\n */\ninterface RawHit {\n  readonly label: string;\n  readonly start: number;\n  readonly end: number;\n}\n\n/**\n * Escape a string for safe interpolation into a `RegExp` source, so an\n * extra dictionary term containing regex metacharacters (`.`, `+`, `(`, …)\n * matches literally rather than as a pattern.\n */\nfunction escapeRegExp(term: string): string {\n  return term.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/**\n * Build the `{label}` mask for a hit. The template's `{label}` token is\n * substituted with the hit's category; a template without the token is used\n * verbatim. Falls back to {@link DEFAULT_MASK} when no template is given.\n */\nfunction applyMask(template: string | undefined, label: string): string {\n  if (template === undefined) {\n    return DEFAULT_MASK;\n  }\n\n  return template.replace(/\\{label\\}/g, label);\n}\n\n/**\n * Collect every built-in-category hit in `text` for the requested\n * categories, in document order per category.\n */\nfunction scanCategories(text: string, categories: readonly PiiCategory[]): RawHit[] {\n  const hits: RawHit[] = [];\n\n  for (const category of categories) {\n    const pattern = CATEGORY_PATTERNS[category];\n    pattern.lastIndex = 0;\n\n    let match = pattern.exec(text);\n\n    while (match !== null) {\n      hits.push({\n        label: category,\n        start: match.index,\n        end: match.index + match[0].length - 1,\n      });\n\n      // Guard the zero-length-match case so `exec` can never spin forever.\n      if (match[0].length === 0) {\n        pattern.lastIndex += 1;\n      }\n\n      match = pattern.exec(text);\n    }\n  }\n\n  return hits;\n}\n\n/**\n * Collect every occurrence of each extra dictionary term in `text`,\n * case-insensitively, as `\"dictionary\"`-labelled hits.\n */\nfunction scanDictionary(text: string, dictionary: readonly string[]): RawHit[] {\n  const hits: RawHit[] = [];\n\n  for (const term of dictionary) {\n    if (term.length === 0) {\n      continue;\n    }\n\n    const pattern = new RegExp(escapeRegExp(term), \"gi\");\n    let match = pattern.exec(text);\n\n    while (match !== null) {\n      hits.push({\n        label: \"dictionary\",\n        start: match.index,\n        end: match.index + match[0].length - 1,\n      });\n\n      match = pattern.exec(text);\n    }\n  }\n\n  return hits;\n}\n\n/**\n * Sort hits by start offset, then drop any hit fully contained in (or\n * duplicating) an already-kept span. Different category regexes can overlap\n * on the same characters (e.g. a credit-card run inside a phone-shaped\n * span); keeping the earliest, widest span makes redaction deterministic\n * and avoids masking a sub-span twice.\n */\nfunction dedupeHits(hits: RawHit[]): RawHit[] {\n  const sorted = [...hits].sort((a, b) => {\n    if (a.start !== b.start) {\n      return a.start - b.start;\n    }\n\n    // Same start: keep the wider span first so the narrower one is absorbed.\n    return b.end - a.end;\n  });\n\n  const kept: RawHit[] = [];\n\n  for (const hit of sorted) {\n    const overlaps = kept.some(\n      existing => hit.start <= existing.end && hit.end >= existing.start,\n    );\n\n    if (!overlaps) {\n      kept.push(hit);\n    }\n  }\n\n  return kept;\n}\n\n/**\n * Rewrite `text`, replacing every kept hit's span with its mask. Applied\n * right-to-left so earlier offsets stay valid as later spans are spliced.\n */\nfunction redactText(text: string, hits: RawHit[], mask: string | undefined): string {\n  const ordered = [...hits].sort((a, b) => b.start - a.start);\n  let result = text;\n\n  for (const hit of ordered) {\n    const replacement = applyMask(mask, hit.label);\n    result = result.slice(0, hit.start) + replacement + result.slice(hit.end + 1);\n  }\n\n  return result;\n}\n\n/** Fold a {@link RawHit} into the public {@link GuardrailMatch} shape. */\nfunction toMatch(hit: RawHit): GuardrailMatch {\n  return {\n    rule: `${DETECTOR_NAME}.${hit.label}`,\n    span: [hit.start, hit.end],\n    label: hit.label,\n  };\n}\n\n/**\n * Build the built-in **PII detector** (`ai.guardrail.pii`) — a zero-runtime-\n * dependency {@link GuardrailDetector} that scans text for personally\n * identifiable information via a curated set of linear regexes plus an\n * optional exact-string dictionary.\n *\n * Categories (`detect`, default: all): `ssn`, `email`, `phone`,\n * `credit-card`, `ipv4`. `dictionary` adds extra exact terms matched\n * case-insensitively as literal strings (regex metacharacters escaped).\n *\n * On a hit the verdict follows `onMatch` (default `\"redact\"`):\n *\n * - **`redact`** — every matched span is replaced by the `mask` template\n *   (`{label}` → the matched category, default `\"[REDACTED]\"`) and the\n *   rewritten text is returned for the factory to substitute. Output and\n *   tool phases honour the rewrite; on the input phase the factory treats a\n *   `redact` verdict as a `block` (the core `trip.before` hook can only\n *   short-circuit, not rewrite-and-continue — see {@link PiiDetectorOptions}).\n * - **`block`** — a hard stop carrying the matches.\n * - **`flag`**  — the content passes but the matches are recorded.\n *\n * Clean text returns `{ type: \"allow\" }`.\n *\n * @example\n * ai.guardrail({ output: [ai.guardrail.pii()] }); // redact, default mask\n *\n * @example\n * ai.guardrail.pii({\n *   detect: [\"ssn\", \"credit-card\"],\n *   onMatch: \"redact\",\n *   mask: \"[PII:{label}]\",\n *   dictionary: [\"Project Aurora\"],\n * });\n */\nexport function pii(options: PiiDetectorOptions = {}): SyncGuardrailDetector {\n  const categories = options.detect ?? ALL_CATEGORIES;\n  const onMatch = options.onMatch ?? \"redact\";\n  const dictionary = options.dictionary ?? [];\n\n  return {\n    name: DETECTOR_NAME,\n    check(text: string): GuardrailVerdict {\n      const rawHits = [\n        ...scanCategories(text, categories),\n        ...scanDictionary(text, dictionary),\n      ];\n\n      if (rawHits.length === 0) {\n        return { type: \"allow\" };\n      }\n\n      const hits = dedupeHits(rawHits);\n      const matches = hits.map(toMatch);\n      const labels = [...new Set(hits.map(hit => hit.label))].join(\", \");\n\n      if (onMatch === \"block\") {\n        return {\n          type: \"block\",\n          reason: `PII detected: ${labels}.`,\n          matches,\n        };\n      }\n\n      if (onMatch === \"flag\") {\n        return {\n          type: \"flag\",\n          reason: `PII detected: ${labels}.`,\n          matches,\n        };\n      }\n\n      return {\n        type: \"redact\",\n        text: redactText(text, hits, options.mask),\n        reason: `Redacted PII: ${labels}.`,\n        matches,\n      };\n    },\n  };\n}\n"],"mappings":"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"}