Ctrl K
ComunidadGuías…

lib/llm-outbound.mjs

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

Archivo original ↗Preguntar a KiBot
/**
 * llm-outbound.mjs — the shared boundary every outbound LLM request passes through.
 *
 * WHY THIS EXISTS
 * ---------------
 * The privacy guard (`lib/privacy-guard.mjs`) is only worth anything if it is
 * invoked at the moment a request is about to leave the machine. Prompt
 * construction happens in a dozen places; the network call happens in a few.
 * This module is the one function those call sites share, so the policy lives
 * in a single spot instead of being re-implemented (or forgotten) per provider.
 *
 * TWO ENFORCEMENT LAYERS
 * ----------------------
 * 1. EXPLICIT — a call site builds its request body and calls
 *    `assertLlmRequestAllowed(url, body)` before `fetch`. This is the primary
 *    layer: it runs after prompt construction and before the network call, and
 *    it fails closed.
 * 2. AMBIENT — `lib/pii-interceptor.mjs` installs a global `fetch` wrapper that
 *    calls the same function for any request whose host is an LLM provider.
 *    This covers a path nobody remembered to instrument, including third-party
 *    SDKs (the Google Generative AI SDK issues its own `fetch`).
 *
 * The ambient layer is a safety net, not a substitute: a request that never
 * reaches global `fetch` (a native HTTP client, a child process, a browser) is
 * outside both layers, and the docs say so plainly.
 *
 * HOST MATCHING
 * -------------
 * Only requests to known LLM hosts are inspected by the ambient layer. Job
 * boards, ATS APIs and general web scraping must keep working untouched, and a
 * false block there would break the scanner. The host list is deliberately a
 * list of LLM providers, not "anything that looks like an API".
 */

import {
  CATEGORY,
  PrivacyGuardError,
  getDefaultGuard,
  serializePayload,
} from './privacy-guard.mjs';

/**
 * Is redaction explicitly requested? Only the exact string `1`/`true` counts, so
 * an unrelated truthy value cannot switch modes by accident.
 */
export function redactModeEnabled(env = process.env) {
  const raw = String(env.PRIVACY_REDACT ?? '').trim().toLowerCase();
  return raw === '1' || raw === 'true';
}

/**
 * Hosts that are treated as external LLM providers.
 *
 * Matched against the request URL's hostname (suffix match on label boundaries,
 * so `openrouter.ai` does not match `evil-openrouter.ai.example`). A port may be
 * present — Ollama on `localhost:11434` is a local model, but the payload still
 * leaves the process, and a user who has pointed `OLLAMA_BASE_URL` at a remote
 * box is sending CV text over the network all the same.
 */
export const LLM_HOSTS = Object.freeze([
  'openrouter.ai',
  'api.openai.com',
  'api.anthropic.com',
  'generativelanguage.googleapis.com',
  'api.deepseek.com',
  'api.moonshot.ai',
  'api.mistral.ai',
  'api.groq.com',
  'api.together.xyz',
  'api.fireworks.ai',
  'api.perplexity.ai',
  'api.cohere.ai',
  'api.x.ai',
  'api.z.ai',
  'router.requesty.ai',
  'api.cerebras.ai',
  // Local/self-hosted, matched by host+port pair below rather than suffix.
]);

/** host:port pairs for local model servers. */
const LOCAL_LLM_ENDPOINTS = Object.freeze([
  'localhost:11434',      // Ollama
  '127.0.0.1:11434',
  'localhost:1234',       // LM Studio / llama.cpp
  '127.0.0.1:1234',
  'localhost:8000',       // generic vLLM
  '127.0.0.1:8000',
]);

/**
 * Path fragments that mark a URL as an LLM completion endpoint even on a host
 * that is not in the list (a self-hosted gateway, a corporate proxy).
 *
 * Two shapes exist. Most are path SEGMENTS (`/v1/chat/completions`), and a
 * bare `includes` was too loose for those — `/feedback/completions-stats`
 * contains `/completions` and would have been scanned. Google's is a colon
 * METHOD (`models:generateContent`) with no leading segment boundary, so it
 * needs a substring match. The shape is declared per entry rather than guessed.
 */
const LLM_PATH_HINTS = Object.freeze([
  { fragment: '/chat/completions', match: 'segment' },
  { fragment: '/completions', match: 'segment' },
  { fragment: '/v1/messages', match: 'segment' },
  { fragment: '/api/chat', match: 'segment' },
  { fragment: ':generatecontent', match: 'substring' },
]);

/**
 * Resolve a fetch argument to a URL string.
 *
 * Handles the three shapes `fetch` accepts: a string, a `URL`, and a `Request`.
 *
 * @param {string|URL|{url?: string}} input
 * @returns {string}
 */
export function urlOf(input) {
  if (typeof input === 'string') return input;
  if (input instanceof URL) return input.href;
  if (input && typeof input === 'object' && typeof input.url === 'string') return input.url;
  return '';
}

/**
 * Is this URL an external LLM request the guard should inspect?
 *
 * @param {string} rawUrl
 * @returns {boolean}
 */
export function isLlmRequestUrl(rawUrl) {
  if (!rawUrl) return false;
  let url;
  try {
    url = new URL(rawUrl);
  } catch {
    return false;
  }
  if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;

  const host = url.hostname.toLowerCase();
  const hostPort = url.port ? `${host}:${url.port}` : host;

  if (LOCAL_LLM_ENDPOINTS.includes(hostPort)) return true;

  for (const known of LLM_HOSTS) {
    if (host === known || host.endsWith(`.${known}`)) return true;
  }

  const path = url.pathname.toLowerCase();
  return LLM_PATH_HINTS.some(({ fragment, match }) =>
    match === 'substring' ? path.includes(fragment) : matchesPathHint(path, fragment)
  );
}

/**
 * Does a URL pathname END IN a known path segment, on a boundary?
 *
 * A bare `includes` was too loose for segment hints: `/feedback/completions-stats`
 * contains `/completions` and would be treated as an LLM endpoint, so the
 * ambient layer would serialize and scan an unrelated request. Requiring the
 * hint at the tail keeps real endpoints (`/v1/chat/completions`, `/api/chat`)
 * and drops the false shapes, since an LLM path segment is always the tail.
 *
 * @param {string} path Lowercased URL pathname.
 * @param {string} hint Lowercased fragment such as `/chat/completions`.
 * @returns {boolean}
 */
function matchesPathHint(path, hint) {
  return path === hint || path.endsWith(hint) || path.endsWith(`${hint}/`);
}

/** The Gemini generateContent endpoint the Google SDK targets. */
export const GOOGLE_GENAI_URL =
  'https://generativelanguage.googleapis.com/v1/models:generateContent';

/**
 * Inspect a Google Generative AI request built from the SAME parts the SDK is
 * about to receive.
 *
 * `@google/generative-ai` issues its own global `fetch`, so there is no way to
 * hand the SDK's serialized body to the guard. The next best thing is to build
 * the body here, pass it to the guard, and then pass the very same parts to
 * `generateContent()` — one function decides both, so the inspected payload and
 * the transmitted payload cannot drift apart.
 *
 * Shape approximates the REST body the SDK produces: `systemInstruction` (when
 * the model was configured with one) plus `contents` with one `parts[].text`
 * entry per turn. It is serialized for inspection only; it is never sent.
 *
 * The ambient interceptor (`lib/pii-interceptor.mjs`) remains the backstop for
 * anything this reconstruction misses, since the SDK's real request still goes
 * through the wrapped global `fetch`.
 *
 * @param {{systemInstruction?: string, turns: string[]}} request
 * @param {object} [options] Passed through to the guard.
 * @returns {{allowed: boolean, inspectedBytes: number}}
 * @throws {PrivacyGuardError}
 */
export function assertGoogleRequestAllowed(request, options = {}) {
  const { systemInstruction, turns } = request ?? {};
  const body = {
    contents: (turns ?? []).map((text) => ({ role: 'user', parts: [{ text }] })),
  };
  if (systemInstruction) {
    body.systemInstruction = { parts: [{ text: systemInstruction }] };
  }
  return assertLlmRequestAllowed(GOOGLE_GENAI_URL, JSON.stringify(body), options);
}

/**
 * The outcome of a guard check, for callers that want to branch.
 *
 * @typedef {{allowed: boolean, reason?: string, inspectedBytes: number}} GuardDecision
 */

function redactPreview(text, guard) {
  if (!guard?.textValues || typeof text !== 'string') return text;
  let out = text;
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  for (const value of guard.textValues) {
    if (value.length < 5) continue;
    const re = new RegExp(escapeRe(value), 'gi');
    out = out.replace(re, '{{PRIVACY_REDACTED}}');
  }
  return out;
}

/**
 * Inspect a fully-built request body and throw if it carries protected data.
 *
 * This is THE function to call immediately before a network request. It fails
 * closed in both directions:
 *   - a protected value found  → `PrivacyGuardError` (nothing is sent)
 *   - the body cannot be read  → `PrivacyGuardError` (nothing is sent), because
 *     "I could not inspect this" must never be reported as "this is clean".
 *
 * A body that is absent or genuinely empty is allowed: there is nothing to leak.
 *
 * @param {string} url
 * @param {unknown} body The serialized string or object about to be transmitted.
 * @param {object} [options]
 * @param {import('./privacy-guard.mjs').PrivacyGuard} [options.guard]
 * @param {boolean} [options.requireLlmHost] When true, also verify the URL is an LLM host.
 * @returns {GuardDecision}
 * @throws {PrivacyGuardError}
 */
export function assertLlmRequestAllowed(
  url,
  body,
  { guard = getDefaultGuard(), requireLlmHost = false, allowRedact = redactModeEnabled() } = {}
) {
  if (requireLlmHost && !isLlmRequestUrl(url)) {
    throw new PrivacyGuardError('request to a non-LLM host', {
      detail: 'The outbound LLM guard was asked to inspect a URL it does not recognise as an LLM provider.',
    });
  }

  // Nothing to inspect. `undefined`/`null`/`''` mean no payload was set at all.
  if (body === undefined || body === null || body === '') {
    return { allowed: true, inspectedBytes: 0 };
  }

  let text;
  try {
    text = serializePayload(body);
  } catch (err) {
    // Fail closed: a request we cannot read is not a request we can clear.
    // The message names the body SHAPE (e.g. "ReadableStream"), which is not
    // the payload content, so this stays a category-only error.
    throw new PrivacyGuardError('request body that could not be inspected', {
      detail:
        err instanceof TypeError && err.message.includes('cannot be inspected as text')
          ? `${err.message}.`
          : `The outbound payload could not be serialized (${err?.name ?? 'Error'}).`,
    });
  }
  if (typeof text !== 'string') {
    throw new PrivacyGuardError('request body that could not be inspected', {
      detail: 'The outbound payload did not serialize to text.',
    });
  }

  if (allowRedact) {
    // When redact mode is enabled, evaluate a preview with configured text
    // redacted so that configured values (such as an email address) do not
    // trip pattern detection during preflight while still allowing the
    // request to proceed to wrapFetch where the text will be redacted.
    // Non-redactable findings (numbers and pattern-detected PII outside configured text)
    // are evaluated on the preview and still rejected immediately.
    const preview = redactPreview(text, guard);
    const findings = guard.inspect(preview, { haystack: preview });
    const nonTextFindings = findings.filter((f) => f.category !== CATEGORY.CONFIGURED_TEXT);
    if (nonTextFindings.length > 0) {
      guard.assertSafe(preview, { haystack: preview });
    }
  } else {
    guard.assertSafe(text, { haystack: text });
  }

  return { allowed: true, inspectedBytes: text.length };
}

/**
 * Non-throwing variant. Returns a decision instead of raising, for callers that
 * want to render their own message.
 *
 * Note it still returns `allowed: false` for an unreadable body — the
 * fail-closed behaviour is a property of the check, not of the calling style.
 *
 * @param {string} url
 * @param {unknown} body
 * @param {object} [options]
 * @returns {GuardDecision}
 */
export function checkLlmRequest(url, body, options) {
  try {
    return assertLlmRequestAllowed(url, body, options);
  } catch (err) {
    return { allowed: false, reason: err?.category ?? err?.message ?? 'unknown', inspectedBytes: 0 };
  }
}