Ctrl K
ComunidadGuías…

lib/pii-interceptor.mjs

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

Archivo original ↗Preguntar a KiBot
/**
 * pii-interceptor.mjs — ambient outbound guard installed over global `fetch`.
 *
 * WHAT THIS IS
 * ------------
 * A monkey-patch over `globalThis.fetch` that runs the privacy guard on any
 * request to an LLM provider before it leaves the machine. It exists because
 * the explicit call-site guard (`lib/llm-outbound.mjs`) can only cover the call
 * sites that remember to use it — and because a third-party SDK issues its own
 * `fetch` the explicit layer never sees. The Google Generative AI SDK is the
 * concrete example: `@google/generative-ai` calls the global `fetch` directly,
 * so `gemini-eval.mjs` and `scan-hn.mjs` are covered here and nowhere else.
 *
 * TWO MODES
 * ---------
 * BLOCK (default) — a protected value in an outbound request aborts the request
 *   with `PrivacyGuardError` before the network call. The prompt is never
 *   silently rewritten, because rewriting could change what the model is asked.
 *
 * REDACT (opt-in, `PRIVACY_REDACT=1`) — the legacy behaviour: replace protected
 *   values with placeholder tokens on the way out and re-hydrate tokens in the
 *   response. This is kept because some workflows depend on it, but it is NOT
 *   the default, because silently altering a prompt's meaning is worse than
 *   refusing to send it. Rehydration covers `text()` and `json()` only — a
 *   streaming reader of `response.body` sees the tokens; see the caveat at the
 *   mutation site and limitation #7 in docs/PRIVACY.md.
 *
 * HOW IT GETS INSTALLED
 * ---------------------
 * Two ways, and both are supported:
 *   1. `NODE_OPTIONS="--import ./lib/pii-interceptor.mjs"` in `.env` — covers
 *      every `node *.mjs` command in the checkout, including scripts that do
 *      not import the guard themselves.
 *   2. `installPiiInterceptor()` called explicitly by a script. `openrouter-runner.mjs`
 *      and the other evaluators do this so the ambient layer is present even
 *      when NODE_OPTIONS is not set.
 *
 * Installation is idempotent — calling it twice does not double-wrap `fetch`.
 */

import {
  PrivacyGuardError,
  getDefaultGuard,
  loadPrivacyGuard,
} from './privacy-guard.mjs';
import {
  assertLlmRequestAllowed,
  isLlmRequestUrl,
  redactModeEnabled,
  urlOf,
} from './llm-outbound.mjs';

/** Marker placed on the wrapper so a second install is a no-op. */
const INSTALLED = Symbol.for('career-ops.pii-interceptor.installed');

/** Tokens produced in redact mode, and the map to reverse them. */
const REDACT_TOKEN_PREFIX = '{{PRIVACY_REDACTED_';

export { redactModeEnabled };

/**
 * Build a redactor over a guard: replace each configured value with a stable
 * placeholder and remember the mapping so the response can be rehydrated.
 *
 * Only configured values are redacted — pattern-detected PII (an email in a job
 * posting) is left alone, because replacing text the user never declared
 * sensitive would corrupt postings the evaluator needs to read.
 */
function makeRedactor(guard) {
  const tokens = new Map(); // token -> real value
  let counter = 0;

  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

  return {
    tokens,
    scrub(text) {
      let out = text;
      for (const value of guard.textValues) {
        if (value.length < 5) continue;
        // Restore the user's own spelling, not the lowercased match key.
        const original = guard.textOriginals?.get(value) ?? value;
        const re = new RegExp(escapeRe(value), 'gi');
        out = out.replace(re, () => {
          const token = `${REDACT_TOKEN_PREFIX}${++counter}}}`;
          tokens.set(token, original);
          return token;
        });
      }
      return out;
    },
    rehydrate(text) {
      let out = text;
      for (const [token, value] of tokens) out = out.split(token).join(value);
      return out;
    },
  };
}

/**
 * Wrap a fetch function.
 *
 * Exported so tests can install it over a mock without touching the real global,
 * and so a caller can compose it deliberately.
 *
 * @param {typeof fetch} baseFetch
 * @param {object} [options]
 * @param {import('./privacy-guard.mjs').PrivacyGuard} [options.guard]
 * @param {boolean} [options.redact]
 * @returns {typeof fetch}
 */
export function wrapFetch(baseFetch, { guard, redact } = {}) {
  const wrapped = async function piiProtectedFetch(input, init = {}) {
    const url = urlOf(input);

    if (!isLlmRequestUrl(url)) {
      // Not an LLM provider: job boards and ATS endpoints must pass untouched.
      return baseFetch(input, init);
    }

    const activeGuard = guard ?? getDefaultGuard();
    const activeRedact = redact !== undefined ? redact : redactModeEnabled();

    let nextInit = init;
    // Prefer the explicit body from `init`. If none was supplied but `input`
    // is a Request object with a body, clone it (so the original remains
    // readable) and extract the text for inspection. Guard the `Request`
    // global in case this runs in an environment where it is not defined.
    let body = init?.body;
    if (body === undefined && typeof Request !== 'undefined' && input instanceof Request && input.body !== null) {
      body = await input.clone().text();
    }

    // The redactor is created PER REQUEST, not per process. A process-global one
    // leaked across requests in two ways: its `tokens.size > 0` gate patched
    // `text`/`json` on every later response, including requests that carried no
    // value at all; and a rehydration pass then substituted this request's token
    // strings on the strength of an EARLIER request, so a token echoed back by
    // the provider in unrelated output came out as the real value (case-folded,
    // since the token replaced a case-insensitive match). Scoping the map to the
    // request that produced it removes both.
    let redactor = null;

    if (activeRedact) {
      // Scrub configured text values from string bodies. Numeric configured
      // values and pattern-detected PII cannot be tokenized by the redactor,
      // so after scrubbing we always run assertLlmRequestAllowed against the
      // body that will actually be sent. This preserves blocking when anything
      // the scrubber could not remove remains in the payload.
      if (typeof body === 'string') {
        redactor = makeRedactor(activeGuard);
        const scrubbed = redactor.scrub(body);
        if (scrubbed !== body) nextInit = { ...init, body: scrubbed };
        // Inspect the post-scrub body: block if any configured value, number or pattern remains.
        assertLlmRequestAllowed(url, scrubbed, { guard: activeGuard, allowRedact: false });
      } else {
        // Non-string body (undefined, Buffer, object): scrubbing is not
        // possible, so fall through to the same blocking check used in block
        // mode. This ensures non-string bodies are never silently bypassed.
        assertLlmRequestAllowed(url, body, { guard: activeGuard, allowRedact: false });
      }
    } else {
      // Blocking mode. Throws before any network activity on a finding.
      assertLlmRequestAllowed(url, body, { guard: activeGuard });
    }

    const response = await baseFetch(input, nextInit);

    // Rehydrate tokens in the response only when THIS request produced some.
    //
    // CAVEAT: this patches the two methods on the live response object and
    // nothing else. A caller that captured a reference to `response.text`
    // BEFORE this runs keeps the un-rehydrated version, and `response.body`
    // (the raw ReadableStream) is never rehydrated at all — a streaming caller
    // reads tokens straight off the wire. That is tolerable because redact mode
    // is the opt-in legacy path, not the default, and the workflows that use it
    // read the response as text. It is recorded here so
    // the gap is a known one rather than a surprise.
    if (redactor && redactor.tokens.size > 0) {
      const origText = response.text.bind(response);
      response.text = async () => redactor.rehydrate(await origText());
      response.json = async () => JSON.parse(redactor.rehydrate(await origText()));
    }

    return response;
  };

  return wrapped;
}

/**
 * Install the wrapper over `globalThis.fetch`.
 *
 * Idempotent: the second call returns the existing wrapper. Returns the wrapper
 * so a caller can restore it (`installPiiInterceptor` is what a test uses to
 * put the real `fetch` back).
 *
 * @param {object} [options]
 * @param {typeof fetch} [options.target] Where to install. Defaults to `globalThis`.
 * @param {typeof fetch} [options.baseFetch] The function to wrap.
 * @param {import('./privacy-guard.mjs').PrivacyGuard} [options.guard]
 * @param {boolean} [options.redact]
 * @returns {typeof fetch}
 */
export function installPiiInterceptor({ target = globalThis, baseFetch, guard, redact } = {}) {
  if (target[INSTALLED]) return target[INSTALLED].wrapped;
  const base = baseFetch ?? target.fetch;
  if (typeof base !== 'function') return base;

  const wrapped = wrapFetch(base, {
    guard,
    redact,
  });

  const record = { wrapped, base };
  target[INSTALLED] = record;
  target.fetch = wrapped;
  return wrapped;
}

/** Remove the wrapper, restoring the original `fetch`. For tests and teardown. */
export function uninstallPiiInterceptor({ target = globalThis } = {}) {
  const record = target[INSTALLED];
  if (!record) return false;
  target.fetch = record.base;
  delete target[INSTALLED];
  return true;
}

// Auto-install on import. This is the whole point of the `--import` form:
// a script that lists this file gets the guard without editing its fetch calls.
installPiiInterceptor();

export { PrivacyGuardError };