/** * 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} [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} lowercased text keys */ this.textValues = new Set(); /** @type {Set} digit strings (phone / long numbers) */ this.digitStrings = new Set(); /** @type {Set} 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} */ 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} */ 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', ]);