lib/pii-interceptor.mjs
Rama main · réplica de lectura del repositorio original.
/**
* 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 };