Ctrl K
ComunidadGuías…

lib/privacy-guard.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * privacy-guard.mjs — centralized PII privacy guard for outbound LLM requests.
 *
 * WHAT IT IS FOR
 * --------------
 * career-ops sends CV text, profile data and job descriptions to external LLM
 * providers. This module is the single policy object that decides whether a
 * fully-built request is allowed to leave the machine. It is enforced at the
 * outbound request boundary (see `lib/llm-outbound.mjs` and
 * `lib/pii-interceptor.mjs`), never at prompt-construction time, so a caller
 * cannot accidentally send a request the guard would have blocked.
 *
 * TWO TIERS OF PROTECTION
 * -----------------------
 * 1. CONFIGURED PROTECTED VALUES — exact values the user has declared sensitive
 *    in `config/privacy.yml` (email, phone, address, portfolio URLs, salary,
 *    and any free-form custom strings). These are normalized before comparison
 *    so spelling variants still match (see `normalizeValue`).
 * 2. AUTOMATICALLY DETECTED PII — recognizable patterns (email addresses, phone
 *    numbers, social/profile URLs) found anywhere in the request. Only the
 *    pattern kinds the user enables are detected. When no config/privacy.yml
 *    exists, auto-detection is opt-in (off by default) so third-party job
 *    postings containing recruiter emails or phone numbers are not blocked.
 *
 * Ordinary professional information — skills, employers, dates, education,
 * achievements, the job description itself — is NOT matched by either tier
 * unless the user explicitly configures those strings. The policy is explicit:
 * nothing is blocked merely for being personal.
 *
 * FAIL-CLOSED
 * -----------
 * The guard distinguishes "I inspected the payload and found nothing" from "I
 * could not inspect the payload". The latter (unreadable/unserializable
 * request, unreadable config that is present but broken) raises instead of
 * returning a pass, so a request is never sent on the strength of a check that
 * did not actually run. The one deliberate exception is a config file that is
 * simply ABSENT: that is a documented, safe state meaning "no configured
 * values", and it is reported as such rather than treated as a failure.
 *
 * NO VALUE LEAKAGE
 * ----------------
 * A blocked request raises `PrivacyGuardError` (or the legacy `PiiGuardError`
 * alias). The message names the CATEGORY of what was found — never the value,
 * never a fragment, length or hash of it. Neither the error nor any diagnostic
 * this module emits contains the matched text.
 *
 * LIMITATIONS (documented, not hidden)
 * ------------------------------------
 * - Pattern detection is heuristic. It catches recognizable emails/phones/URLs;
 *   it does NOT catch indirect, encoded, obfuscated, base64'd, spelled-out, or
 *   paraphrased PII, aliases, or values the user did not configure.
 * - A configured value shorter than the length floors is not guarded, because
 *   guarding it would block ordinary prose (see MIN_TEXT_LEN / MIN_DIGITS_*).
 *   Store money as absolute annual amounts so they stay guardable.
 * - Enforcement only exists on the request paths that actually call the guard.
 *   It is not a guarantee of anonymity, only a check at the boundary.
 *
 * Design reference: the `PIIGuard` class in the `hunter` project
 * (`src/llm.py`, `tests/test_pii_guard.py`), adapted to career-ops' Node/ESM
 * architecture, extended with pattern detection and fail-closed config
 * handling. Not copied — the layering, error taxonomy, config surface and
 * enforcement points here are career-ops' own.
 */

import { existsSync, readFileSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadYaml } from './yaml-document.mjs';
import { getCareerOpsRoot } from '../path-resolver.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));

/**
 * The category name used in errors and diagnostics. Deliberately closed: an
 * error must not echo user input, so the only thing it ever interpolates is one
 * of these constants plus a count.
 */
export const CATEGORY = Object.freeze({
  CONFIGURED_TEXT: 'configured protected value',
  CONFIGURED_NUMBER: 'configured protected number',
  EMAIL: 'email address',
  PHONE: 'phone number',
  PROFILE_URL: 'personal profile URL',
});

/**
 * A blocked request. Distinct from a network or config error so callers (and
 * tests) can tell "policy refused this" from "the request was malformed".
 *
 * `category` is a CATEGORY constant. `hits` is the number of distinct protected
 * values found — a count, never the values.
 */
export class PrivacyGuardError extends Error {
  constructor(category, { hits = 1, detail = '' } = {}) {
    const suffix = hits > 1 ? ` (${hits} matches)` : '';
    super(
      `Privacy guard blocked an outbound LLM request: it contains a ${category}${suffix}. ` +
        `${detail ? detail + ' ' : ''}` +
        'Nothing was sent. Remove or generalize the value, or (only if you understand the ' +
        'risk) adjust config/privacy.yml.'
    );
    this.name = 'PrivacyGuardError';
    this.code = 'PRIVACY_GUARD_BLOCKED';
    this.category = category;
    this.hits = hits;
    this.safeToSend = false;
  }
}

/**
 * Legacy alias. The earlier redaction flow imports `PiiGuard` / the token API;
 * both names resolve to the same class so the two generations of the guard
 * cannot diverge.
 */
export const PiiGuardError = PrivacyGuardError;

/** A config file that is present but cannot be used. Never thrown for "absent". */
export class PrivacyConfigError extends Error {
  constructor(message) {
    super(message);
    this.name = 'PrivacyConfigError';
    this.code = 'PRIVACY_GUARD_CONFIG';
  }
}

/**
 * Floors that keep ordinary prose from being blocked.
 *
 * A protected value shorter than these is not guarded: a 2-character "value"
 * would match inside unrelated words, and guarding a bare 3-digit number would
 * refuse every posting that mentions a team size. Hunter arrived at the same
 * shape (`MIN_TEXT_LEN = 5`, `MIN_DIGITS_SUBSTR = 7`, `MIN_DIGITS_RUN = 5`) and
 * the numbers are kept because the reasoning transfers directly.
 */
export const MIN_TEXT_LEN = 5;
export const MIN_DIGITS_SUBSTR = 7;
export const MIN_DIGITS_RUN = 5;

/** Strip everything that is not a digit. */
function digitsOf(value) {
  return String(value).replace(/\D/g, '');
}

/**
 * Is this string mostly a number? Used to route a configured value to the
 * numeric comparison path (digits only) instead of the text path, so a phone
 * written `+91 98765 43210` still matches `9876543210`.
 */
function looksNumeric(raw) {
  const digits = digitsOf(raw);
  if (!digits) return false;
  const nonSpace = raw.replace(/\s/g, '');
  return digits.length >= nonSpace.length * 0.6;
}

/**
 * Normalize a configured value the way comparison expects it.
 *
 * Returns `null` when the value is too short/generic to be guardable — the
 * caller drops it rather than guarding something that would false-positive.
 *
 * @param {unknown} value
 * @returns {{kind: 'text'|'digit_str'|'digit_run', key: string}|null}
 */
export function normalizeValue(value) {
  if (value === null || value === undefined || typeof value === 'boolean') return null;
  const raw = String(value).trim();
  if (!raw) return null;
  const digits = digitsOf(raw);

  // Numeric-looking values compare as digits.
  if (typeof value === 'number' || looksNumeric(raw)) {
    if (digits.length >= MIN_DIGITS_SUBSTR) {
      // A phone with a country code also matches its national number.
      return { kind: 'digit_str', key: digits };
    }
    if (digits.length >= MIN_DIGITS_RUN) return { kind: 'digit_run', key: digits };
    return null; // too short to identify anyone
  }

  if (raw.length >= MIN_TEXT_LEN) return { kind: 'text', key: raw.toLowerCase() };
  return null;
}

/**
 * Extra keys to guard when a configured value is a URL: the bare handle is
 * identifying on its own ("linkedin.com/in/jane-example" implies "jane-example").
 *
 * For recognized profile hosts (LinkedIn, GitHub, GitLab, etc.) the last
 * non-empty path segment is the personal handle and is derived as a guarded
 * key. For all other hosts the normalized hostname is derived instead —
 * guarding a generic path segment like "portfolio" or "projects" from an
 * arbitrary site would cause false positives on unrelated prose.
 *
 * Accepts both the scheme'd and bare forms — a value without `https://` is
 * prefixed before parsing. Returns `[]` for anything that is not a URL, so the
 * caller can invoke it unconditionally.
 *
 * @param {string} raw
 * @returns {string[]}
 */

/** Hosts whose last path segment is a personal handle. */
const PROFILE_HOSTS = new Set([
  'linkedin.com',
  'github.com',
  'gitlab.com',
  'twitter.com',
  'x.com',
  'medium.com',
  'behance.net',
  'dribbble.com',
  'instagram.com',
  'dev.to',
]);

function derivedKeys(raw) {
  const out = [];
  try {
    const url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
    // "jane@gmail.com" parses as user-info + host; the host is not a secret.
    if (url.username || url.password) return out;
    // Strip leading "www." for host comparison, and normalize LinkedIn country subdomains.
    const host = url.hostname
      .replace(/^www\./, '')
      .replace(/^[a-z]{2,3}\.(?=linkedin\.com$)/, '')
      .toLowerCase();
    if (PROFILE_HOSTS.has(host)) {
      // Known profile host: the last path segment is a personal handle.
      const handle = url.pathname.replace(/\/+$/, '').split('/').pop();
      if (handle && handle.length >= MIN_TEXT_LEN) out.push(handle.toLowerCase());
    } else {
      // Unknown host: guard the hostname, not a generic path segment.
      if (host.length >= MIN_TEXT_LEN) out.push(host);
    }
  } catch {
    /* not a URL — nothing extra to derive */
  }
  return out;
}

/**
 * Recursively collect every non-null scalar out of a parsed config tree.
 *
 * Generic tree-walker: it does NOT filter booleans. A caller that needs
 * boolean-free scalars (the config reader, for consistent entry numbering)
 * filters them itself.
 *
 * @param {unknown} node
 * @param {unknown[]} [out]
 * @returns {unknown[]}
 */
export function flattenScalars(node, out = []) {
  if (node === null || node === undefined) return out;
  if (Array.isArray(node)) {
    for (const item of node) flattenScalars(item, out);
    return out;
  }
  if (typeof node === 'object') {
    for (const value of Object.values(node)) flattenScalars(value, out);
    return out;
  }
  out.push(node);
  return out;
}

/**
 * Default auto-detection policy. When no configuration exists, pattern detection
 * is opt-in (disabled by default) so third-party job postings containing
 * recruiter emails or phone numbers are not blocked during evaluation.
 */
const DEFAULT_AUTO_DETECT = Object.freeze({ email: false, phone: false, profile_url: false });

/**
 * The detection patterns, one entry per CATEGORY constant.
 *
 * Each is anchored to a recognizable shape rather than a broad digit run, so
 * `2026-10-20` or `25 LPA` do not read as a phone. The regexes are created per
 * call so they carry no shared `lastIndex` between requests.
 */
function patterns() {
  return [
    {
      category: CATEGORY.EMAIL,
      key: 'email',
      // Local@domain.tld — requires a dot in the domain so "user@host" in prose
      // is not treated as an address.
      regex: /[a-z0-9._%+-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}/gi,
    },
    {
      category: CATEGORY.PHONE,
      key: 'phone',
      // International or national formats with separators. Bounded length keeps
      // dates, years, ids and bare amounts out. Deliberately conservative: it
      // wants a leading + or a separator group, so a plain 10-digit blob that
      // is really an ID does not trip it.
      regex:
        /(?:\+\d{1,3}[\s.-]?)?(?:\(\d{2,4}\)|\d{2,4})[\s.-]\d{3,4}[\s.-]\d{3,4}(?!\d)|(?:\+\d{1,3}[\s.-]?)\d{6,11}(?!\d)/g,
    },
    {
      category: CATEGORY.PROFILE_URL,
      key: 'profile_url',
      // Personal profile URLs, not company/job pages.
      regex:
        /https?:\/\/(?:[a-z0-9-]+\.)?(?:linkedin\.com\/(?:in|pub)\/[\w%.-]+|github\.com\/[\w.-]+|gitlab\.com\/[\w.-]+|twitter\.com\/[\w.-]+|x\.com\/[\w.-]+|medium\.com\/@[\w.-]+|behance\.net\/[\w.-]+|dribbble\.com\/[\w.-]+)/gi,
    },
  ];
}

/**
 * Serialize an arbitrary request payload to a single string for inspection.
 *
 * Strings pass through unchanged. Everything else is JSON-stringified, which is
 * what the network layer would transmit anyway. `undefined` fields are dropped
 * by JSON, `BigInt`/cycles throw — the caller treats a throw as fail-closed.
 *
 * UNINSPECTABLE BODIES ARE REFUSED, NOT CLEARED.
 * ----------------------------------------------
 * `JSON.stringify` returns `"{}"` for several shapes `fetch` accepts as a body
 * — a `ReadableStream`, `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer`.
 * The guard would then "inspect" two bytes and clear a request whose real
 * content it never read, which is exactly the fail-open the module promises not
 * to have. Those shapes carry their content in internal slots JSON cannot see,
 * so there is no faithful text form to scan and the only honest answer is to
 * refuse. A caller that genuinely has one of these must read it to text itself
 * and pass the string.
 *
 * @param {unknown} payload
 * @returns {string}
 * @throws {TypeError} When the payload's shape cannot be read as text.
 */
export function serializePayload(payload) {
  if (typeof payload === 'string') return payload;
  if (isUninspectableBody(payload)) {
    throw new TypeError(
      `privacy guard: a ${Object.prototype.toString.call(payload).slice(8, -1)} body ` +
        'cannot be inspected as text and is refused rather than assumed clean'
    );
  }
  // A Buffer / typed array holds bytes whose text is invisible to the default
  // JSON view (`{"type":"Buffer","data":[74,111,...]}`), so a secret inside one
  // would never match a substring. Decode it to UTF-8 first — this is what the
  // network would carry, and it is the only form the patterns can read.
  if (isBinaryView(payload)) {
    return decodeBinaryView(payload);
  }
  return JSON.stringify(replaceBinaryViews(payload));
}

/**
 * Decode a binary view (Buffer / typed array) to UTF-8 text.
 *
 * Uses `TextDecoder` rather than `Buffer`, because this module is also loaded
 * by browser-side callers where `Buffer` is not defined.
 *
 * @param {Uint8Array} view
 * @returns {string}
 */
function decodeBinaryView(view) {
  const bytes =
    view instanceof Uint8Array && view.byteOffset === 0 && view.byteLength === view.buffer.byteLength
      ? view
      : new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
  return new TextDecoder('utf-8', { fatal: false }).decode(bytes);
}

/**
 * Is this a raw binary view (Buffer or a typed array) whose text is hidden by
 * its default JSON representation?
 *
 * @param {unknown} payload
 * @returns {boolean}
 */
function isBinaryView(payload) {
  return (
    payload instanceof Uint8Array ||
    payload instanceof Int8Array ||
    payload instanceof Uint8ClampedArray
  );
}

/**
 * Recursively replace binary views inside a JSON-able payload with their UTF-8
 * text, so a Buffer nested in a request object is scanned as text rather than
 * as a byte table.
 *
 * @param {unknown} node
 * @param {WeakSet<object>} [seen]
 * @returns {unknown}
 */
function replaceBinaryViews(node, seen = new WeakSet()) {
  if (node === null || typeof node !== 'object') return node;
  if (isBinaryView(node)) return decodeBinaryView(node);
  if (seen.has(node)) return node; // cycle: JSON.stringify will throw as before
  seen.add(node);
  if (Array.isArray(node)) return node.map((item) => replaceBinaryViews(item, seen));
  const out = {};
  for (const [key, value] of Object.entries(node)) out[key] = replaceBinaryViews(value, seen);
  return out;
}

/**
 * Shapes `fetch` accepts as a body whose content JSON cannot see.
 *
 * Detected structurally (duck-typed) rather than by `instanceof`, because the
 * caller's realm may be a different one from this module's — a cross-realm
 * `FormData` still fails `instanceof` here but is still uninspectable.
 *
 * @param {unknown} payload
 * @returns {boolean}
 */
function isUninspectableBody(payload) {
  if (payload === null || typeof payload !== 'object') return false;
  if (typeof ReadableStream !== 'undefined' && payload instanceof ReadableStream) return true;
  // Duck-type the rest: a stream-like body (has getReader), or one of the
  // multipart/urlencoded/binary containers that stringify to {}.
  if (typeof payload.getReader === 'function') return true;
  const tag = Object.prototype.toString.call(payload);
  return (
    tag === '[object FormData]' ||
    tag === '[object URLSearchParams]' ||
    tag === '[object Blob]' ||
    tag === '[object File]' ||
    tag === '[object ArrayBuffer]' ||
    tag === '[object SharedArrayBuffer]' ||
    tag === '[object DataView]'
  );
}

/**
 * The guard.
 *
 * Construct directly for tests, or use `loadPrivacyGuard()` /
 * `getDefaultGuard()` for the process-wide instance.
 */
export class PrivacyGuard {
  /**
   * @param {object} [options]
   * @param {unknown[]} [options.values] Configured protected values (already flattened).
   * @param {string[]} [options.valueKeys] YAML key path per value, e.g.
   *   `protected.salary.current_base`. Used in block errors so a finding names a
   *   line the user can find; falls back to a position label when absent.
   * @param {{email?: boolean, phone?: boolean, profile_url?: boolean}} [options.autoDetect]
   * @param {boolean} [options.enabled] When false the guard allows everything (explicit opt-out).
   * @param {string} [options.source] Where the values came from, for diagnostics.
   */
  constructor({ values = [], valueKeys = [], autoDetect = {}, enabled = true, source = 'inline' } = {}) {
    this.enabled = enabled !== false;
    this.source = source;
    this.autoDetect = { ...DEFAULT_AUTO_DETECT, ...autoDetect };

    /** @type {Set<string>} lowercased text keys */
    this.textValues = new Set();
    /** @type {Set<string>} digit strings (phone / long numbers) */
    this.digitStrings = new Set();
    /** @type {Set<string>} short whole-number runs (amounts) */
    this.digitRuns = new Set();

    /**
     * Which configured entry produced each guarded key, as its YAML path
     * (`protected.salary.current_base`) — or `value N` when the caller supplied
     * no key. Used to make an error actionable without ever echoing the value.
     * @type {Map<string, string>}
     */
    this.keyOrigin = new Map();

    /**
     * Each guarded text key mapped back to the spelling the user configured.
     *
     * `textValues` holds lowercased keys because matching is case-insensitive.
     * Redact mode needs the original to put back: rehydrating `jordan a.
     * placeholder` in place of the name the user typed is a visible corruption
     * of their own document. Only the FIRST spelling seen wins, so a value
     * written twice in different cases restores consistently.
     * @type {Map<string, string>}
     */
    this.textOriginals = new Map();

    let position = 0;
    for (const value of values) {
      position += 1;
      const label = valueKeys[position - 1] || `value ${position}`;
      const normalized = normalizeValue(value);
      if (!normalized) continue;
      if (normalized.kind === 'text') {
        this.textValues.add(normalized.key);
        this.keyOrigin.set(normalized.key, label);
        // Remember the user's own spelling for redact-mode rehydration.
        if (!this.textOriginals.has(normalized.key)) {
          this.textOriginals.set(normalized.key, String(value).trim());
        }
        // Derive the bare handle regardless of how the value was written. The
        // earlier version gated this on a URL-shaped regex, so
        // "linkedin.com/in/jane" (no scheme) derived nothing while the docs
        // promised the handle was guarded. `derivedKeys` is the single decision
        // point — it returns [] for anything that is not a URL.
        for (const extra of derivedKeys(String(value).trim())) {
          this.textValues.add(extra);
          this.keyOrigin.set(extra, label);
        }
      } else if (normalized.kind === 'digit_str') {
        this.digitStrings.add(normalized.key);
        this.keyOrigin.set(normalized.key, label);
        // A country-coded number also matches without the country code.
        if (normalized.key.length > 10) {
          const national = normalized.key.slice(-10);
          this.digitStrings.add(national);
          this.keyOrigin.set(national, label);
        }
      } else {
        this.digitRuns.add(normalized.key);
        this.keyOrigin.set(normalized.key, label);
      }
    }
  }

  /** How many configured values are actually being guarded. Diagnostic only. */
  get configuredCount() {
    return this.textValues.size + this.digitStrings.size + this.digitRuns.size;
  }

  /** The enabled pattern list for this instance. */
  get activePatterns() {
    return patterns().filter((p) => this.autoDetect[p.key]);
  }

  /**
   * Inspect a built request and return every finding.
   *
   * Never throws for a finding, and never includes a value in the result: each
   * finding is `{category, id, count}` where `id` is a stable, non-reversible
   * label (the configured key's index / the pattern's category), not the match.
   *
   * @param {unknown} payload A string, or a request body object to serialize.
   * @param {object} [options]
   * @param {string} [options.haystack] Pre-serialized text, to avoid re-serializing.
   * @returns {{category: string, id: string, count: number}[]}
   * @throws {Error} If the payload cannot be serialized (fail-closed; see caller).
   */
  inspect(payload, { haystack } = {}) {
    if (!this.enabled) return [];
    const text = haystack !== undefined ? haystack : serializePayload(payload);
    if (typeof text !== 'string') {
      throw new TypeError('privacy guard: payload could not be serialized to text');
    }

    const findings = [];
    const lower = text.toLowerCase();

    // ── Tier 1: configured text values ──────────────────────────────────────
    for (const needle of this.textValues) {
      if (lower.includes(needle)) {
        findings.push({
          category: CATEGORY.CONFIGURED_TEXT,
          id: this.keyOrigin.get(needle) ?? 'configured value',
          count: 1,
        });
      }
    }

    // ── Tier 1: configured numeric values ────────────────────────────────────
    const digitStream = digitsOf(text);
    for (const needle of this.digitStrings) {
      if (digitStream.includes(needle)) {
        findings.push({
          category: CATEGORY.CONFIGURED_NUMBER,
          id: this.keyOrigin.get(needle) ?? 'configured value',
          count: 1,
        });
      }
    }
    if (this.digitRuns.size > 0) {
      // Extract numeric runs from the original text AND after stripping
      // thousands-separator commas, so a protected number like 98765 is matched
      // both when formatted with separators (98,765) and when adjacent to
      // other numbers in arrays or lists (e.g. [98765,123]).
      const rawRuns = text.match(/\d+/g) ?? [];
      const normalizedRuns = text.replace(/(\d),(?=\d{3}(?!\d))/g, '$1').match(/\d+/g) ?? [];
      const runs = new Set([...rawRuns, ...normalizedRuns]);
      for (const needle of this.digitRuns) {
        if (runs.has(needle)) {
          findings.push({
            category: CATEGORY.CONFIGURED_NUMBER,
            id: this.keyOrigin.get(needle) ?? 'configured value',
            count: 1,
          });
        }
      }
    }

    // ── Tier 2: automatic pattern detection ──────────────────────────────────
    for (const { category, regex } of this.activePatterns) {
      // Fresh regex per request: a shared /g regex keeps lastIndex between calls.
      const matches = text.match(new RegExp(regex.source, regex.flags));
      if (matches && matches.length > 0) {
        findings.push({ category, id: `pattern-${category.replace(/\s+/g, '-')}`, count: matches.length });
      }
    }

    return findings;
  }

  /**
   * Inspect a request and throw if anything protected is present.
   *
   * @param {unknown} payload
   * @param {object} [options]
   * @param {string} [options.haystack]
   * @returns {true} When the payload is safe to send.
   * @throws {PrivacyGuardError} When a protected value is found.
   */
  assertSafe(payload, options) {
    const findings = this.inspect(payload, options);
    if (findings.length === 0) return true;
    // One error, most severe category first, naming only counts and WHERE the
    // configured entries live. The category is what the user must act on; the
    // YAML key path lets them find it in config/privacy.yml without the error
    // ever echoing the value. A 10-digit configured number is matched as a
    // substring of any longer digit run, so without the label a user reading
    // "configured protected number" has no way to tell which line is at fault.
    const byCategory = new Map();
    for (const f of findings) byCategory.set(f.category, (byCategory.get(f.category) ?? 0) + f.count);
    const [category, hits] = [...byCategory.entries()].sort((a, b) => b[1] - a[1])[0];
    const origins = [...new Set(findings.map((f) => f.id))].join(', ');
    const detail = `Found: ${[...byCategory.keys()].join(', ')} (${origins}).`;
    throw new PrivacyGuardError(category, { hits, detail });
  }
}

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

/** The config path, honouring a data-root override and an explicit env var. */
export function getPrivacyConfigPath(explicit) {
  if (explicit) return resolve(explicit);
  if (process.env.CAREER_OPS_PRIVACY?.trim()) {
    const override = process.env.CAREER_OPS_PRIVACY.trim();
    return isAbsolute(override) ? override : resolve(getCareerOpsRoot(), override);
  }
  return join(getCareerOpsRoot(), 'config', 'privacy.yml');
}

/** The shipped sample, used only to explain setup in errors and docs. */
export function getPrivacyExamplePath() {
  return join(__dirname, '..', 'config', 'privacy.example.yml');
}

/**
 * Read the privacy config.
 *
 * Absent → `{present: false}` and the guard runs without configured values or
 * pattern detection (opt-in safe default). Present but unparseable/shapeless →
 * `PrivacyConfigError`, so an enabled guard never reports success on a config it
 * could not read.
 *
 * @param {string} [explicitPath]
 * @returns {{present: boolean, path: string, enabled: boolean, autoDetect: object, values: unknown[], source: string}}
 */
export function readPrivacyConfig(explicitPath) {
  const path = getPrivacyConfigPath(explicitPath);
  if (!existsSync(path)) {
    return { present: false, path, enabled: true, autoDetect: {}, values: [], source: 'absent' };
  }

  let parsed;
  try {
    parsed = loadYaml(readFileSync(path, 'utf-8'));
  } catch (err) {
    throw new PrivacyConfigError(
      `privacy: could not parse ${path}. Fix it, or remove it to disable all configured ` +
        `checks (pattern detection is opt-in and off by default). The guard refuses to treat ` +
        `an unreadable config as protection.`
    );
  }

  // A comment-only file parses to null/empty, which is the same "nothing
  // configured" state as an absent file — not an error, but reported distinctly.
  if (parsed === null || parsed === undefined || (typeof parsed === 'object' && Object.keys(parsed).length === 0)) {
    return { present: true, path, enabled: true, autoDetect: {}, values: [], source: 'empty' };
  }
  if (typeof parsed !== 'object' || Array.isArray(parsed)) {
    throw new PrivacyConfigError(
      `privacy: ${path} must be a YAML mapping at the top level, got ${Array.isArray(parsed) ? 'a list' : typeof parsed}.`
    );
  }

  if (parsed.protected !== undefined) {
    if (parsed.protected === null || typeof parsed.protected !== 'object') {
      throw new PrivacyConfigError(
        `privacy: ${path} protected section must be a mapping or list, got ${parsed.protected === null ? 'null' : typeof parsed.protected}.`
      );
    }
  }

  const protectedSection = parsed.protected ?? parsed;

  /**
   * Walk `protected:` and collect every guardable leaf WITH its YAML key path,
   * e.g. `protected.salary.current_base`.
   *
   * The path is what a block error reports (`protected.salary.current_base`),
   * so it must name something the user can find in the file — the earlier
   * flattened-index label ("entry 3") pointed at nothing visible once a value
   * was nested, which made the label useless for the nested case it was added
   * for. Booleans are control flags, not values, and are skipped here so they
   * never occupy a position.
   *
   * @param {unknown} node
   * @param {string} keyPath Dotted path to `node`.
   * @param {{value: unknown, key: string}[]} out
   */
  const collect = (node, keyPath, out) => {
    if (node === null || node === undefined || typeof node === 'boolean') return;
    if (Array.isArray(node)) {
      node.forEach((item, index) => collect(item, `${keyPath}[${index}]`, out));
      return;
    }
    if (typeof node === 'object') {
      for (const [key, value] of Object.entries(node)) collect(value, `${keyPath}.${key}`, out);
      return;
    }
    out.push({ value: node, key: keyPath });
  };

  const entries = [];
  const sectionName = parsed.protected !== undefined ? 'protected' : '';
  // `protected:` is the documented block. Unknown top-level keys are ignored so
  // a comment or extra metadata does not become a "protected value".
  if (Array.isArray(protectedSection)) {
    protectedSection.forEach((item, index) => {
      collect(item, sectionName ? `${sectionName}[${index}]` : `[${index}]`, entries);
    });
  } else if (protectedSection && typeof protectedSection === 'object') {
    for (const [key, value] of Object.entries(protectedSection)) {
      collect(value, sectionName ? `${sectionName}.${key}` : key, entries);
    }
  }

  const values = entries.map((entry) => entry.value);
  const valueKeys = entries.map((entry) => entry.key);

  const enabled = parsed.enabled !== false;
  const autoDetect = typeof parsed.auto_detect === 'object' && parsed.auto_detect !== null ? parsed.auto_detect : {};

  return { present: true, path, enabled, autoDetect, values, valueKeys, source: path };
}

/**
 * True when the guard has been explicitly switched off for this process.
 *
 * Two conditions must both hold: `PRIVACY_GUARD=off` AND a non-empty
 * `PRIVACY_BYPASS_TOKEN`. A single flag is not enough — this exists so a typo
 * or an inherited shell variable cannot silently disable protection in
 * production. Disabling is not something a workflow does by accident.
 *
 * @param {NodeJS.ProcessEnv} [env] Environment to read. Defaults to `process.env`.
 * @returns {boolean}
 */
export function bypassRequested(env = process.env) {
  const mode = String(env.PRIVACY_GUARD ?? '').trim().toLowerCase();
  if (mode !== 'off' && mode !== 'disabled' && mode !== '0') return false;
  const token = String(env.PRIVACY_BYPASS_TOKEN ?? '').trim();
  if (!token) {
    // Asked to disable without the second factor: refuse, and say so loudly.
    console.warn(
      'privacy: PRIVACY_GUARD=off was set without PRIVACY_BYPASS_TOKEN — the guard stays ON. ' +
        'See docs/PRIVACY.md.'
    );
    return false;
  }
  return true;
}

/**
 * Build the process-wide guard from the environment and config.
 *
 * @param {object} [options]
 * @param {string} [options.configPath] Override the config location (tests).
 * @param {object} [options.env] Override the environment (tests).
 * @returns {PrivacyGuard}
 */
export function loadPrivacyGuard({ configPath, env = process.env, config: preRead } = {}) {
  // `env` is threaded through explicitly rather than by reassigning the global
  // `process.env`: dotenv and other modules hold a reference to that object, so
  // swapping it out would break them for the duration of the call.
  if (bypassRequested(env)) {
    return new PrivacyGuard({ enabled: false, source: 'bypass' });
  }
  // A caller that already read the config (to report on its `source`, say) can
  // pass it in rather than have the file parsed a second time. The CLI status
  // path did exactly that, and re-reading could also disagree with itself if the
  // file changed between the two reads.
  const config = preRead ?? readPrivacyConfig(configPath);
  return new PrivacyGuard({
    values: config.values,
    valueKeys: config.valueKeys,
    autoDetect: config.autoDetect,
    enabled: config.enabled,
    source: config.source,
  });
}

/** Memoized process-wide guard. Reset with `resetDefaultGuard()` (tests). */
let defaultGuardInstance = null;

/** @returns {PrivacyGuard} */
export function getDefaultGuard() {
  if (defaultGuardInstance === null) defaultGuardInstance = loadPrivacyGuard();
  return defaultGuardInstance;
}

/** Drop the memoized guard so the next `getDefaultGuard()` reloads config. */
export function resetDefaultGuard() {
  defaultGuardInstance = null;
}

/**
 * Legacy compatibility surface.
 *
 * The earlier redaction flow (`lib/pii-interceptor.mjs`) imports `defaultGuard`
 * and the `PiiGuard` name. Keeping the same names means an install mid-upgrade
 * does not crash on a missing export, and the two generations of the guard
 * resolve to one policy object.
 *
 * `defaultGuard` is a lazy proxy rather than an eagerly-evaluated instance so
 * it does not call `loadPrivacyGuard()` at module-evaluation time — before
 * dotenv has had a chance to populate `process.env`. Any call site that needs
 * the guard should use `getDefaultGuard()` directly; this export exists only
 * for callers that were written against the old API.
 */
export class PiiGuard extends PrivacyGuard {}

/** @deprecated Use `getDefaultGuard()`. Kept for the legacy redaction flow. */
export const defaultGuard = new Proxy({}, {
  get(_target, prop, receiver) {
    const instance = getDefaultGuard();
    const value = Reflect.get(instance, prop, receiver);
    return typeof value === 'function' ? value.bind(instance) : value;
  },
  has(_target, prop) {
    return Reflect.has(getDefaultGuard(), prop);
  },
  getOwnPropertyDescriptor(_target, prop) {
    return Reflect.getOwnPropertyDescriptor(getDefaultGuard(), prop);
  },
  ownKeys(_target) {
    return Reflect.ownKeys(getDefaultGuard());
  },
  getPrototypeOf(_target) {
    return Reflect.getPrototypeOf(getDefaultGuard());
  },
});

/** Default pattern policy, exported so docs and tests read one definition. */
export const AUTO_DETECT_DEFAULTS = DEFAULT_AUTO_DETECT;

/** The four-shape confirmation UX vocabulary from AGENTS.md, for callers to reuse. */
export const CONFIRMATION_OUTCOMES = Object.freeze([
  'confirm-accurate',
  'provide-figure',
  'narrative-only',
  'user-cannot-confirm',
]);