diff --git a/README.md b/README.md index 1a72edc..ce442cc 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ All providers are raw `fetch` — no SDK dependencies. A provider is enabled whe | Tavily | search | `TAVILY_API_KEY` | quality tier, ~$8/1k | | Exa | search (semantic) | `EXA_API_KEY` | $7/1k, 1k free/mo | | SerpAPI | SERP + ads/PAA | `SERPAPI_KEY` | paid | -| Crawl4AI (self-hosted) | fetch | `CRAWL4AI_URL` (default `https://ora-scraper.fly.dev`) | $0 | +| Crawl4AI (self-hosted) | fetch | reviewed domains plus an egress-hardened crawler attestation | $0 | | Jina Reader | fetch fallback | `JINA_API_KEY` | free tier | | Reddit | social | `REDDIT_CLIENT_ID`, `REDDIT_CLIENT_SECRET`, `REDDIT_USER_AGENT` | free | | YouTube | video | `YOUTUBE_API_KEY` | free quota | @@ -52,10 +52,32 @@ All providers are raw `fetch` — no SDK dependencies. A provider is enabled whe | LinkedIn / FB Groups (ingestion layer) | ingestion | Bright Data: `BRIGHTDATA_API_TOKEN` (+ dataset IDs) | paid | | Perplexity | managed deep research | `PERPLEXITY_API_KEY` | ~$0.40–1.30/query (sonar-deep-research) | -Model/pipeline keys: `ANTHROPIC_API_KEY` (synthesis/critique), `GEMINI_RESEARCH_KEY` or `GEMINI_API_KEY` (planner/extraction), `VOYAGE_API_KEY` (rerank/embed), optional `OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY`. +Model/pipeline keys: `ANTHROPIC_API_KEY` (synthesis/critique), +`GEMINI_RESEARCH_KEY`, `GOOGLE_GEMINI_API_KEY`, +`GOOGLE_GENERATIVE_AI_API_KEY`, or `GEMINI_API_KEY` (one shared resolver for +planning/extraction/synthesis), +`VOYAGE_API_KEY` (rerank/embed), optional `OPENROUTER_API_KEY`, +`DEEPSEEK_API_KEY`. + +If multiple Gemini aliases contain different values, the shared package +resolver fails closed. Set `BRAINTIED_GEMINI_KEY_NAME` to the approved variable +name; the local runner also accepts `--gemini-key-name` and reports the selected +name without printing its value. > Env naming is standardized here — `SERPAPI_KEY` (not `SERP_API_KEY`), `SERPER_API_KEY`, `SEARXNG_URLS`. +Generic web fetches are fail-closed. The package accepts only public HTTP(S) +targets on the standard port, pins each DNS-validated connection, revalidates +every redirect, blocks private/link-local/reserved IPv4 and IPv6, and enforces +content-type and byte limits. The self-hosted Crawl4AI service is additionally +deny-by-default because a separate browser process cannot inherit the caller's +pinned DNS result. It remains disabled unless the separate crawler enforces +public-only DNS/IP checks on every navigation, redirect, and subresource and +the caller sets `BRAINTIED_CRAWL4AI_NETWORK_GUARD=enforced-v1` to attest that +boundary. `BRAINTIED_CRAWL4AI_ALLOWED_DOMAINS` is a second, comma-separated, +human-reviewed gate: entries are exact hosts, while `*.example.com` explicitly +allows subdomains. An allowlist alone is not an SSRF defense. + Instagram has a stricter boundary than the general web-fetch stack. Hashtag search uses Bright Data snapshot discovery; direct `/p/`, `/reel/`, and `/tv/` links use the Bright Data posts dataset; one-segment profile links use the @@ -140,8 +162,15 @@ the run, that checkpoint also records the durable run ID; terminal success replaces it with final metadata. Preserve the checkpoint and use `--request-id ` to reattach after a local timeout or interruption. `run-research.mjs` remains available as an explicit local-provider fallback; it -supports allowlisted interactive-shell environment loading and build-freshness -checks. Exact lane preflight requires an as-of date: +supports permission-checked, allowlisted dotenv loading, allowlisted +interactive-shell environment loading, and build-freshness checks. Use +`--research-env-file /absolute/path/to/.env` (or the +`BRAINTIED_RESEARCH_ENV_FILE` pointer) when approved credentials live in a +project file; this intentionally replaces or masks stale inherited research +variables without importing unrelated env entries. Never use Node's built-in +`--env-file` for this runner because Node imports every entry before the +allowlist can run; the runner refuses when it detects that preload. Exact lane +preflight requires an as-of date: ```bash node skills/run-braintied-research/scripts/run-research.mjs \ @@ -207,7 +236,7 @@ npm run pack:release # → releases/braintied-research-.tgz ```jsonc // consumer package.json -"@braintied/research": "file:vendor/braintied-research-0.8.2.tgz" +"@braintied/research": "file:vendor/braintied-research-0.8.3.tgz" ``` ## Development diff --git a/package-lock.json b/package-lock.json index 80586df..5cff81b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@braintied/research", - "version": "0.8.2", + "version": "0.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@braintied/research", - "version": "0.8.2", + "version": "0.8.4", "license": "UNLICENSED", "dependencies": { "@anthropic-ai/sdk": "^0.104.1", diff --git a/package.json b/package.json index 1a0c439..26bd2b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@braintied/research", - "version": "0.8.2", + "version": "0.8.4", "description": "Deep-research engine — multi-provider search, quote extraction, synthesis, critique, grounded report assembly, and research-driven document generation (PRDs, briefs, specs)", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/skills/run-braintied-research/SKILL.md b/skills/run-braintied-research/SKILL.md index 8130f7a..f89581d 100644 --- a/skills/run-braintied-research/SKILL.md +++ b/skills/run-braintied-research/SKILL.md @@ -87,8 +87,18 @@ model providers; treat every brief as outbound data. Use `run-research.mjs` only as an explicitly authorized local fallback when the internal service is unavailable. That fallback requires package build - freshness plus its own provider credentials; `--load-shell-env` imports only - the documented provider allowlist and never Agent Auth. For multichannel + freshness plus its own provider credentials. When approved credentials live + in a project dotenv file, prefer the runner's + `--research-env-file /absolute/path/to/.env` option. Never use Node's built-in + `--env-file`: Node imports every entry before the runner's allowlist executes, + and the runner refuses when it detects that preload. + The runner imports only its documented allowlist, requires every supplied + file to be a private regular non-symlink on supported POSIX systems, and + treats blank assignments as explicit masks for inherited values. + `BRAINTIED_RESEARCH_ENV_FILE` may point to that approved path so the command + works from any workspace; an explicit `--research-env-file` wins. + `--load-shell-env` imports the same allowlist from the interactive shell, + can discover that pointer, and never imports Agent Auth. For multichannel research, preflight exact lanes and an exact upper boundary: ```bash diff --git a/skills/run-braintied-research/references/runtime.md b/skills/run-braintied-research/references/runtime.md index bd75e21..f5b0ca0 100644 --- a/skills/run-braintied-research/references/runtime.md +++ b/skills/run-braintied-research/references/runtime.md @@ -106,6 +106,34 @@ the common case where source supports newer behavior but ignored `dist/` still contains an older build. Published packages intentionally omit source and report freshness as unavailable; built-kind and export checks still apply there. +For project-owned dotenv credentials, use the runner's allowlisted loader: + +```bash +node skills/run-braintied-research/scripts/run-research.mjs \ + --check \ + --kind standard \ + --max-cost-usd 2 \ + --research-env-file /absolute/path/to/.env.production.local +``` + +Never use Node's built-in `--env-file` for this runner: Node loads every entry +before the runner starts, bypassing its allowlist; the runner refuses when it +detects that preload. `--research-env-file` +deliberately replaces stale allowlisted values and treats an explicit blank as +a mask that prevents shell fallback. It never imports unrelated dotenv entries. +On POSIX, every supplied file must be a regular non-symlink with owner-only +permissions (0600 or stricter), and it is checked and read through one open file +handle. Secure dotenv loading fails closed on Windows; inject approved process +environment values there instead. `BRAINTIED_RESEARCH_ENV_FILE` may point to the +same approved absolute path so commands work from any workspace; an explicit +`--research-env-file` wins. The pointer contains no secret value, but access to +the target file still controls credential authority. + +The allowlisted parser intentionally accepts single-line dotenv assignments +only: optional `export`, unquoted values with `#` comments, and single-, double-, +or backtick-quoted values. Multiline values are rejected. Double-quoted `\\n` +and `\\r` escapes are expanded; other backslashes are preserved. + ## Local fallback credential matrix The runner reports only whether a variable is present. Never print, echo, or @@ -144,18 +172,24 @@ Recommended role/order: - GitHub REST performs repository/issue/PR discovery; an optional project-owned token raises the restrictive unauthenticated quota. -For compatibility, the runner maps `GEMINI_RESEARCH_KEY` to `GEMINI_API_KEY` -inside its child process when the latter is absent. It never persists the alias. +The Gemini resolver accepts `GEMINI_RESEARCH_KEY`, `GOOGLE_GEMINI_API_KEY`, +`GOOGLE_GENERATIVE_AI_API_KEY`, and `GEMINI_API_KEY`. If multiple aliases contain +different values, the shared package resolver fails without printing them. +Direct package consumers must set `BRAINTIED_GEMINI_KEY_NAME=NAME`; the runner +also accepts `--gemini-key-name NAME`. The chosen value becomes the canonical +planning/extraction/synthesis key for that process. Preflight reports only the +selected variable name, and the runner never persists the value. `VOYAGE_API_KEY` is optional: without it, reranking preserves provider order. Anthropic is optional for Gemini-backed runs: planner retries lose their Claude fallback, and critique returns its documented permissive fallback. Use credentials owned and approved for the project being researched. In Codex -Desktop, `--load-shell-env` imports only allowlisted provider variables and -defaults search to Braintied's two SearXNG instances. It never prints values. -Do not inspect or reuse a neighboring project's environment file merely because -it exists. +Desktop, `--research-env-file` imports only allowlisted values from the explicit +approved file. `--load-shell-env` then fills unmasked gaps from the interactive +shell and defaults search to Braintied's two SearXNG instances. Neither prints +values. Do not inspect or reuse a neighboring project's environment file merely +because it exists. ## Result contract @@ -236,12 +270,13 @@ node skills/run-braintied-research/scripts/run-research.mjs \ --synthesis-model gemini-3-flash-preview \ --load-shell-env -node --env-file=/path/owned-by-this-project/.env \ - skills/run-braintied-research/scripts/run-research.mjs \ +node skills/run-braintied-research/scripts/run-research.mjs \ --check \ --kind quick \ --max-cost-usd 0.25 \ - --synthesis-model gemini-3-flash-preview + --synthesis-model gemini-3-flash-preview \ + --research-env-file /absolute/path/owned-by-this-project/.env \ + --gemini-key-name GOOGLE_GEMINI_API_KEY node skills/run-braintied-research/scripts/run-research.mjs \ --check \ diff --git a/skills/run-braintied-research/scripts/research-env-file.mjs b/skills/run-braintied-research/scripts/research-env-file.mjs new file mode 100644 index 0000000..7e8caaf --- /dev/null +++ b/skills/run-braintied-research/scripts/research-env-file.mjs @@ -0,0 +1,50 @@ +export function parseDotenvValue(rawValue, filePath, lineNumber) { + const value = rawValue.trim(); + if (value.length === 0) return ''; + + const quote = value[0]; + if (quote === "'" || quote === '"' || quote === '`') { + let escaped = false; + let end = -1; + for (let index = 1; index < value.length; index += 1) { + const character = value[index]; + if (quote === '"' && character === '\\' && !escaped) { + escaped = true; + continue; + } + if (character === quote && !escaped) { + end = index; + break; + } + escaped = false; + } + if (end === -1) { + throw new Error(`Unterminated quoted value in ${filePath}:${lineNumber}.`); + } + const trailing = value.slice(end + 1).trim(); + if (trailing.length > 0 && !trailing.startsWith('#')) { + throw new Error(`Unexpected text after quoted value in ${filePath}:${lineNumber}.`); + } + const unquoted = value.slice(1, end); + if (quote !== '"') return unquoted; + return unquoted + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r'); + } + + const comment = value.indexOf('#'); + return (comment === -1 ? value : value.slice(0, comment)).trim(); +} + +export function parseAllowlistedEnvFile(contents, filePath, allowedNames) { + const parsed = new Map(); + const lines = contents.replace(/^\uFEFF/, '').split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (line.length === 0 || line.startsWith('#')) continue; + const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line); + if (match === null || !allowedNames.has(match[1])) continue; + parsed.set(match[1], parseDotenvValue(match[2], filePath, index + 1)); + } + return parsed; +} diff --git a/skills/run-braintied-research/scripts/run-research.mjs b/skills/run-braintied-research/scripts/run-research.mjs index b0d8bde..fd3a1fa 100644 --- a/skills/run-braintied-research/scripts/run-research.mjs +++ b/skills/run-braintied-research/scripts/run-research.mjs @@ -1,12 +1,14 @@ #!/usr/bin/env node import { execFile } from 'node:child_process'; -import { access, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { access, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parseArgs, promisify } from 'node:util'; import { assessGrounding } from './grounding-quality.mjs'; +import { parseAllowlistedEnvFile } from './research-env-file.mjs'; const VALID_KINDS = ['answer', 'quick', 'standard', 'deep', 'managed', 'social']; const PIPELINE_KINDS = new Set(['quick', 'standard', 'deep', 'social']); @@ -22,10 +24,15 @@ const SYNTHESIS_DEFAULTS = new Map([ ['deep', 'claude-sonnet-4-6'], ['social', 'claude-sonnet-4-6'], ]); -const SHELL_ENV_NAMES = [ - 'ANTHROPIC_API_KEY', +const GEMINI_KEY_NAMES = [ 'GEMINI_RESEARCH_KEY', + 'GOOGLE_GEMINI_API_KEY', + 'GOOGLE_GENERATIVE_AI_API_KEY', 'GEMINI_API_KEY', +]; +const RESEARCH_ENV_NAMES = [ + 'ANTHROPIC_API_KEY', + ...GEMINI_KEY_NAMES, 'VOYAGE_API_KEY', 'PERPLEXITY_API_KEY', 'SEARXNG_URLS', @@ -48,10 +55,16 @@ const SHELL_ENV_NAMES = [ 'BRIGHTDATA_API_TOKEN', 'JINA_API_KEY', 'CRAWL4AI_URL', + 'BRAINTIED_CRAWL4AI_ALLOWED_DOMAINS', + 'BRAINTIED_CRAWL4AI_NETWORK_GUARD', 'GITHUB_TOKEN', 'GH_TOKEN', ]; -const SHELL_ENV_NAME_SET = new Set(SHELL_ENV_NAMES); +const SHARED_ENV_FILE_VARIABLE = 'BRAINTIED_RESEARCH_ENV_FILE'; +const GEMINI_KEY_NAME_VARIABLE = 'BRAINTIED_GEMINI_KEY_NAME'; +const IMPORTABLE_ENV_NAMES = [...RESEARCH_ENV_NAMES, GEMINI_KEY_NAME_VARIABLE]; +const IMPORTABLE_ENV_NAME_SET = new Set(IMPORTABLE_ENV_NAMES); +const SHELL_CAPTURE_NAME_SET = new Set([...IMPORTABLE_ENV_NAMES, SHARED_ENV_FILE_VARIABLE]); const BRAINTIED_SEARXNG_URLS = 'https://cortex-searxng-a.fly.dev,https://cortex-searxng-b.fly.dev'; const PACKAGE_ROOT = fileURLToPath(new URL('../../../', import.meta.url)); const DIST_ENTRY = path.join(PACKAGE_ROOT, 'dist', 'index.mjs'); @@ -75,6 +88,9 @@ Options: (default: standard) --max-cost-usd Required for pipeline kinds; must be > 0 --synthesis-model Override the synthesis model/provider + --research-env-file + Import allowlisted settings from a secure dotenv file + --gemini-key-name Select one Gemini alias when configured values conflict --load-shell-env Import allowlisted settings from an interactive shell --recency-days Recency window for answer or pipeline searches --sources Explicit lanes: web,x,reddit,youtube,github,community,... @@ -88,9 +104,12 @@ Options: --help Show this help Environment: - Existing process variables take precedence. --load-shell-env imports only - allowlisted research settings and supplies Braintied's SearXNG pool when no - search provider is configured. Load only project-authorized credentials. + --research-env-file (or BRAINTIED_RESEARCH_ENV_FILE) accepts an absolute path + and imports only allowlisted research settings. Do not use Node's --env-file, + which loads every entry before this runner starts. Every supplied file must be + owner-only and regular; blank assignments explicitly disable inherited values. + --load-shell-env fills unmasked gaps and can discover the shared file pointer. + Use --gemini-key-name (or BRAINTIED_GEMINI_KEY_NAME) when aliases differ. `; function parseCli() { @@ -104,6 +123,8 @@ function parseCli() { kind: { type: 'string', default: 'standard' }, 'max-cost-usd': { type: 'string' }, 'synthesis-model': { type: 'string' }, + 'research-env-file': { type: 'string' }, + 'gemini-key-name': { type: 'string' }, 'load-shell-env': { type: 'boolean' }, 'recency-days': { type: 'string' }, sources: { type: 'string' }, @@ -149,8 +170,19 @@ function hasEnv(name) { return typeof value === 'string' && value.trim().length > 0; } -async function loadInteractiveShellEnvironment(enabled) { - if (!enabled) return { source: null, loadedNames: [], defaultedNames: [] }; +function emptyRuntimeEnvironment() { + return { + source: null, + loadedNames: [], + overriddenNames: [], + maskedNames: [], + defaultedNames: [], + envFiles: [], + }; +} + +async function captureInteractiveShellEnvironment(enabled) { + if (!enabled) return new Map(); const shell = process.env.SHELL !== undefined && process.env.SHELL.trim().length > 0 ? process.env.SHELL @@ -164,29 +196,187 @@ async function loadInteractiveShellEnvironment(enabled) { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, }); - const loadedNames = []; + const captured = new Map(); for (const entry of stdout.split('\0')) { const separator = entry.indexOf('='); if (separator <= 0) continue; const name = entry.slice(0, separator); - const value = entry.slice(separator + 1); - if (!SHELL_ENV_NAME_SET.has(name) || hasEnv(name) || value.trim().length === 0) continue; + if (!SHELL_CAPTURE_NAME_SET.has(name)) continue; + captured.set(name, entry.slice(separator + 1)); + } + return captured; +} + +function resolveEnvironmentFilePath(cliPath, shellEnvironment) { + if (cliPath !== undefined && cliPath.trim().length === 0) { + throw new Error('--research-env-file requires a non-empty absolute path.'); + } + let configuredPath = cliPath; + if (configuredPath === undefined) { + const inheritedPointer = process.env[SHARED_ENV_FILE_VARIABLE]; + configuredPath = inheritedPointer !== undefined && inheritedPointer.trim().length > 0 + ? inheritedPointer + : shellEnvironment.get(SHARED_ENV_FILE_VARIABLE); + } + if (configuredPath === undefined || configuredPath.trim().length === 0) return null; + if (!path.isAbsolute(configuredPath)) { + throw new Error(`${SHARED_ENV_FILE_VARIABLE} and --research-env-file must use an absolute path.`); + } + return path.normalize(configuredPath); +} + +function rejectNodeEnvironmentPreload() { + if (process.execArgv.some((argument) => ( + argument === '--env-file' + || argument.startsWith('--env-file=') + || argument === '--env-file-if-exists' + || argument.startsWith('--env-file-if-exists=') + ))) { + throw new Error("Refusing Node's env-file preload because it bypasses the research allowlist; use --research-env-file instead."); + } +} + +async function loadAllowlistedEnvironmentFile(absolutePath) { + if (absolutePath === null) return emptyRuntimeEnvironment(); + if (process.platform === 'win32') { + throw new Error('Secure --research-env-file loading is unavailable on Windows; inject approved process environment variables instead.'); + } + if (typeof fsConstants.O_NOFOLLOW !== 'number') { + throw new Error('This platform cannot safely reject symlinked research environment files.'); + } + + let fileHandle; + try { + fileHandle = await open( + absolutePath, + fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | (fsConstants.O_NONBLOCK ?? 0), + ); + } catch (error) { + if (error !== null && typeof error === 'object' && 'code' in error && error.code === 'ELOOP') { + throw new Error(`Research environment file must not be a symbolic link: ${absolutePath}.`); + } + throw error; + } + + let contents; + try { + const fileStats = await fileHandle.stat(); + if (!fileStats.isFile()) { + throw new Error(`Research environment path is not a regular file: ${absolutePath}.`); + } + if ((fileStats.mode & 0o077) !== 0) { + throw new Error(`Research environment file must have owner-only permissions (0600 or stricter): ${absolutePath}.`); + } + contents = await fileHandle.readFile({ encoding: 'utf8' }); + } finally { + await fileHandle.close(); + } + + const parsed = parseAllowlistedEnvFile(contents, absolutePath, IMPORTABLE_ENV_NAME_SET); + + const loadedNames = []; + const overriddenNames = []; + const maskedNames = []; + for (const [name, value] of parsed) { + if (value.trim().length === 0) { + if (hasEnv(name)) overriddenNames.push(name); + delete process.env[name]; + maskedNames.push(name); + continue; + } + if (process.env[name] !== undefined && process.env[name] !== value) { + overriddenNames.push(name); + } + process.env[name] = value; + loadedNames.push(name); + } + return { + source: 'env-file', + loadedNames, + overriddenNames: [...new Set(overriddenNames)], + maskedNames, + defaultedNames: [], + envFiles: [absolutePath], + }; +} + +function applyInteractiveShellEnvironment(shellEnvironment, enabled, maskedNames) { + if (!enabled) return emptyRuntimeEnvironment(); + const masked = new Set(maskedNames); + const loadedNames = []; + for (const name of IMPORTABLE_ENV_NAMES) { + const value = shellEnvironment.get(name); + if (value === undefined || masked.has(name) || hasEnv(name) || value.trim().length === 0) continue; process.env[name] = value; loadedNames.push(name); } const defaultedNames = []; - if (!hasEnv('SEARXNG_URLS')) { + if (!masked.has('SEARXNG_URLS') && !hasEnv('SEARXNG_URLS')) { process.env.SEARXNG_URLS = BRAINTIED_SEARXNG_URLS; defaultedNames.push('SEARXNG_URLS'); } - return { source: 'interactive-shell', loadedNames, defaultedNames }; + return { + source: 'interactive-shell', + loadedNames, + overriddenNames: [], + maskedNames: [], + defaultedNames, + envFiles: [], + }; +} + +function combineRuntimeEnvironments(...environments) { + const active = environments.filter((environment) => environment.source !== null); + return { + source: active.length > 0 ? active.map((environment) => environment.source).join('+') : null, + loadedNames: [...new Set(active.flatMap((environment) => environment.loadedNames))].sort(), + overriddenNames: [...new Set(active.flatMap((environment) => environment.overriddenNames))].sort(), + maskedNames: [...new Set(active.flatMap((environment) => environment.maskedNames))].sort(), + defaultedNames: [...new Set(active.flatMap((environment) => environment.defaultedNames))].sort(), + envFiles: [...new Set(active.flatMap((environment) => environment.envFiles))], + resolvedGeminiKeyName: null, + }; } -function applyGeminiAlias() { - if (!hasEnv('GEMINI_API_KEY') && hasEnv('GEMINI_RESEARCH_KEY')) { - process.env.GEMINI_API_KEY = process.env.GEMINI_RESEARCH_KEY; +function resolveGeminiEnvironment(cliKeyName, runtimeEnvironment) { + const configuredKeyName = cliKeyName ?? process.env[GEMINI_KEY_NAME_VARIABLE]; + if (configuredKeyName !== undefined && configuredKeyName.trim().length === 0) { + throw new Error('--gemini-key-name must name a supported Gemini environment variable.'); + } + const selectedByName = configuredKeyName?.trim(); + if (selectedByName !== undefined && !GEMINI_KEY_NAMES.includes(selectedByName)) { + throw new Error(`Unsupported Gemini key name ${selectedByName}; expected one of: ${GEMINI_KEY_NAMES.join(', ')}.`); + } + + const candidates = GEMINI_KEY_NAMES + .filter(hasEnv) + .map((name) => ({ name, value: process.env[name] })); + let selected = null; + if (selectedByName !== undefined) { + selected = candidates.find((candidate) => candidate.name === selectedByName) ?? null; + if (selected === null) { + throw new Error(`Selected Gemini key ${selectedByName} is not configured.`); + } + } else { + const distinctValues = new Set(candidates.map((candidate) => candidate.value)); + if (distinctValues.size > 1) { + const names = candidates.map((candidate) => candidate.name).join(', '); + throw new Error(`Conflicting Gemini aliases are configured (${names}); choose one with --gemini-key-name.`); + } + selected = candidates[0] ?? null; + } + + if (selected === null) return; + for (const canonicalName of ['GEMINI_RESEARCH_KEY', 'GEMINI_API_KEY']) { + if (hasEnv(canonicalName) && process.env[canonicalName] !== selected.value) { + runtimeEnvironment.overriddenNames.push(canonicalName); + } + process.env[canonicalName] = selected.value; } + runtimeEnvironment.overriddenNames = [...new Set(runtimeEnvironment.overriddenNames)].sort(); + runtimeEnvironment.resolvedGeminiKeyName = selected.name; + process.env[GEMINI_KEY_NAME_VARIABLE] = selected.name; } async function loadPackage() { @@ -196,7 +386,6 @@ async function loadPackage() { throw new Error(`Built package not found at ${DIST_ENTRY}. Run npm run build first.`); } - applyGeminiAlias(); const research = await import(pathToFileURL(DIST_ENTRY).href); if (typeof research.runResearch !== 'function') { throw new Error('Built package does not export runResearch. Run npm run build from current source.'); @@ -216,8 +405,9 @@ function addMissing(missing, name) { function requireSynthesisCredential(model, missing) { if (model === null) return; if (model.startsWith('gemini-')) { - if (!hasEnv('GEMINI_RESEARCH_KEY') && !hasEnv('GEMINI_API_KEY')) { - addMissing(missing, 'GEMINI_RESEARCH_KEY or GEMINI_API_KEY'); + if (!hasEnv('GEMINI_RESEARCH_KEY') && !hasEnv('GEMINI_API_KEY') + && !hasEnv('GOOGLE_GENERATIVE_AI_API_KEY') && !hasEnv('GOOGLE_GEMINI_API_KEY')) { + addMissing(missing, 'a supported Gemini API key'); } } else if (model.startsWith('deepseek-')) { if (!hasEnv('DEEPSEEK_API_KEY')) addMissing(missing, 'DEEPSEEK_API_KEY'); @@ -231,7 +421,8 @@ function requireSynthesisCredential(model, missing) { function requiredConfiguration(kind, enabledProviders, synthesisModel) { const missing = []; const warnings = []; - const geminiPresent = hasEnv('GEMINI_RESEARCH_KEY') || hasEnv('GEMINI_API_KEY'); + const geminiPresent = hasEnv('GEMINI_RESEARCH_KEY') || hasEnv('GEMINI_API_KEY') + || hasEnv('GOOGLE_GENERATIVE_AI_API_KEY') || hasEnv('GOOGLE_GEMINI_API_KEY'); const generalSearchPresent = enabledProviders.some((provider) => GENERAL_SEARCH_PROVIDERS.has(provider)); const socialProviders = new Set(['reddit', 'youtube', 'x', 'tiktok', 'instagram', 'facebook_groups', 'podcasts']); const socialSearchPresent = enabledProviders.some((provider) => socialProviders.has(provider)); @@ -242,7 +433,7 @@ function requiredConfiguration(kind, enabledProviders, synthesisModel) { if (!generalSearchPresent) addMissing(missing, 'at least one enabled general search provider'); requireSynthesisCredential(synthesisModel, missing); } else { - if (!geminiPresent) addMissing(missing, 'GEMINI_RESEARCH_KEY or GEMINI_API_KEY'); + if (!geminiPresent) addMissing(missing, 'a supported Gemini API key'); requireSynthesisCredential(synthesisModel, missing); if (!hasEnv('VOYAGE_API_KEY')) { warnings.push('VOYAGE_API_KEY is absent; quote reranking will use stable provider order.'); @@ -381,11 +572,15 @@ async function preflight( enabled_providers: enabled, enabled_search_providers: enabledSearch, source_plan: sourcePlan, - configured_key_names: SHELL_ENV_NAMES.filter(hasEnv), + configured_key_names: RESEARCH_ENV_NAMES.filter(hasEnv), runtime_environment: { source: runtimeEnvironment.source, loaded_names: runtimeEnvironment.loadedNames, + overridden_names: runtimeEnvironment.overriddenNames, + masked_names: runtimeEnvironment.maskedNames, defaulted_names: runtimeEnvironment.defaultedNames, + env_files: runtimeEnvironment.envFiles, + resolved_gemini_key_name: runtimeEnvironment.resolvedGeminiKeyName, }, missing: config.missing, warnings: config.warnings, @@ -422,6 +617,7 @@ async function readBrief(values) { } async function main() { + rejectNodeEnvironmentPreload(); let values; try { values = parseCli(); @@ -464,7 +660,17 @@ async function main() { if (!PIPELINE_KINDS.has(kind) && maxCostUsd !== undefined) { throw new Error(`--max-cost-usd is not enforced by ${kind} research; omit it and choose this kind deliberately.`); } - const runtimeEnvironment = await loadInteractiveShellEnvironment(values['load-shell-env'] === true); + const loadShellEnvironment = values['load-shell-env'] === true; + const capturedShellEnvironment = await captureInteractiveShellEnvironment(loadShellEnvironment); + const environmentFilePath = resolveEnvironmentFilePath(values['research-env-file'], capturedShellEnvironment); + const fileEnvironment = await loadAllowlistedEnvironmentFile(environmentFilePath); + const shellEnvironment = applyInteractiveShellEnvironment( + capturedShellEnvironment, + loadShellEnvironment, + fileEnvironment.maskedNames, + ); + const runtimeEnvironment = combineRuntimeEnvironments(fileEnvironment, shellEnvironment); + resolveGeminiEnvironment(values['gemini-key-name'], runtimeEnvironment); const research = await loadPackage(); const knownProviders = new Set(Array.isArray(research.PROVIDER_NAMES) ? research.PROVIDER_NAMES : []); const invalidProviders = requiredProviders.filter((provider) => !knownProviders.has(provider)); diff --git a/src/advice-rss.ts b/src/advice-rss.ts index f6a3bf7..a9e7f75 100644 --- a/src/advice-rss.ts +++ b/src/advice-rss.ts @@ -13,6 +13,7 @@ */ import { logger } from './logger.js'; +import { fetchPublicText } from './public-http.js'; // ── Types ──────────────────────────────────────────────────────────────────── @@ -287,19 +288,27 @@ export async function fetchRssFeed( }; try { - const response = await fetch(feedUrl, { + const response = await fetchPublicText(feedUrl, { + acceptedContentTypes: [ + 'application/atom+xml', + 'application/rss+xml', + 'application/xml', + 'text/plain', + 'text/xml', + ], headers: { 'User-Agent': 'OraResearch/1.0 (advice-pipeline; contact: team@braintied.com)', 'Accept': 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*', }, - signal: AbortSignal.timeout(20000), + maxBytes: 5_000_000, + timeoutMs: 20_000, }); if (!response.ok) { return { ...empty, error: `Feed returned ${response.status}` }; } - const xml = await response.text(); + const xml = response.text; if (xml.length < 100) { return { ...empty, error: 'Feed response too short' }; diff --git a/src/answer.ts b/src/answer.ts index d29eccc..f07d063 100644 --- a/src/answer.ts +++ b/src/answer.ts @@ -16,7 +16,7 @@ import { crawlWithCrawl4AI } from './pipeline-core.js'; import { synthesisGenerate } from './synthesis.js'; import { getModelPricing } from './depth-config.js'; import type { FinalReport } from './types.js'; -import { logger as defaultLogger } from './logger.js'; +import { safeLogger } from './logger.js'; import type { Logger } from './logger.js'; const ANSWER_SYNTH_MODEL_DEFAULT = 'gemini-3.6-flash'; @@ -128,7 +128,7 @@ export function deriveSearchQuery(query: string): string { * Answer a question with inline citations using the self-hosted search stack. */ export async function runAnswer(input: RunAnswerInput): Promise { - const log = input.logger !== undefined ? input.logger : defaultLogger; + const log = safeLogger(input.logger); const startedAt = Date.now(); const maxSources = Math.min(input.maxSources !== undefined ? input.maxSources : MAX_SOURCES_TO_READ, SEARCH_LIMIT); const synthModel = input.synthesisModelOverride !== undefined && input.synthesisModelOverride.length > 0 diff --git a/src/documents/index.ts b/src/documents/index.ts index c88e132..5b06281 100644 --- a/src/documents/index.ts +++ b/src/documents/index.ts @@ -14,7 +14,7 @@ import { z } from 'zod'; import { synthesisGenerate } from '../synthesis.js'; import { runResearch } from '../kinds.js'; import type { KindResearchResult, ResearchKind } from '../kinds.js'; -import { logger as defaultLogger } from '../logger.js'; +import { safeLogger } from '../logger.js'; import type { Logger } from '../logger.js'; import { deepResearchSynthesisCostUsd } from '../depth-config.js'; import type { FinalReport } from '../types.js'; @@ -121,7 +121,7 @@ export async function generateDocument( export async function generateDocument( input: GenerateDocumentInput, ): Promise { - const log: Logger = input.logger !== undefined ? input.logger : defaultLogger; + const log: Logger = safeLogger(input.logger); const definition = DOC_TYPE_REGISTRY[input.docType]; const model = input.model !== undefined ? input.model : DEFAULT_DOC_MODEL; diff --git a/src/evidence-validation.ts b/src/evidence-validation.ts new file mode 100644 index 0000000..a26548c --- /dev/null +++ b/src/evidence-validation.ts @@ -0,0 +1,73 @@ +/** + * Offline validation for model-extracted evidence. + * + * Extraction output is untrusted. A model-authored claim must never become + * grounding evidence merely because it shares vocabulary with fetched text. + * This module therefore uses a deliberately fail-closed contract: + * + * - quotes must equal one complete fetched sentence/line; + * - key claims must equal a complete fetched sentence/line and contain at + * least four tokens. + * + * Semantic paraphrases remain unverified until a real entailment boundary is + * introduced. Lower recall is preferable to circularly certifying a model's + * own wording. + */ + +const MIN_KEY_CLAIM_TOKENS = 4; + +/** Normalize presentation-only differences without changing words. */ +function normalizeUnicodePresentation(text: string): string { + return text + .normalize('NFKC') + .replace(/[\u200B-\u200D\u2060\uFEFF]/gu, '') + .replace(/[\u2018\u2019\u201A\u201B\u2032]/gu, "'") + .replace(/[\u201C\u201D\u201E\u201F\u2033]/gu, '"') + .replace(/[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/gu, '-') + .replace(/[\p{Zs}\t\f\v]+/gu, ' '); +} + +function evidenceTokens(text: string): string[] { + return normalizeUnicodePresentation(text) + .toLowerCase() + .match(/[\p{L}\p{M}\p{N}]+(?:[./:-][\p{L}\p{M}\p{N}]+)*(?:%)?/gu) ?? []; +} + +function normalizeExactText(text: string): string { + return normalizeUnicodePresentation(text) + .toLowerCase() + .replace(/\s+/gu, ' ') + .replace(/\s+([,.;:!?])/gu, '$1') + .trim() + .replace(/\.$/u, ''); +} + +function sourceEvidenceUnits(sourceContent: string): string[] { + return normalizeUnicodePresentation(sourceContent) + .split(/(?:\r?\n)+|(?<=[.!?\u3002\uFF01\uFF1F])\s+/u) + .map(normalizeExactText) + .filter((unit) => unit.length > 0); +} + +/** @internal True only when the quote equals one complete fetched sentence/line. */ +export function isVerbatimQuoteSupportedBySource( + quote: string, + sourceContent: string, +): boolean { + const normalizedQuote = normalizeExactText(quote); + if (normalizedQuote.length === 0) return false; + return sourceEvidenceUnits(sourceContent).includes(normalizedQuote); +} + +/** + * @internal True only for a material claim equal to one complete fetched + * sentence or line after conservative presentation normalization. + */ +export function isKeyClaimSupportedBySource( + claim: string, + sourceContent: string, +): boolean { + const normalizedClaim = normalizeExactText(claim); + if (evidenceTokens(claim).length < MIN_KEY_CLAIM_TOKENS) return false; + return sourceEvidenceUnits(sourceContent).includes(normalizedClaim); +} diff --git a/src/grounding.ts b/src/grounding.ts index ae32ab0..699d98a 100644 --- a/src/grounding.ts +++ b/src/grounding.ts @@ -8,13 +8,15 @@ * 1. Extract all [^N] markers + the sentence containing each marker * 2. Look up bibliography[N-1] to resolve source_url * 3. Find chunks where chunk.source_url === source_url - * 4. Accept either strong 3-gram overlap or conservative content-token - * overlap with exact numeric consistency (for faithful paraphrases) + * 4. Accept only a material citation sentence that matches one complete + * punctuation-preserving evidence sentence/line * 5. Return ratio + per-citation diagnostics * * Pure TS, no external deps. */ +import { resolveSameSourceCitationUrl } from './types.js'; +import { isKeyClaimSupportedBySource } from './evidence-validation.js'; import type { FinalReport, ProviderName, @@ -89,7 +91,11 @@ export function buildGroundingEvidenceChunks( for (const [sourceUrl, quotes] of Object.entries(input.sourceQuotesByUrl)) { for (const quote of quotes) { - const key = `quote\u0000${sourceUrl}\u0000${normalizeForMatch(quote.quote)}`; + // Revalidate at the grounding boundary because native provider + // extractors can also supply quote URLs. Preserve only a same-source + // anchor so the bibliography and evidence pool share one safe identity. + const quoteSourceUrl = resolveSameSourceCitationUrl(sourceUrl, quote.source_url); + const key = `quote\u0000${quoteSourceUrl}\u0000${normalizeForMatch(quote.quote)}`; if (seen.has(key)) continue; seen.add(key); chunks.push({ @@ -97,7 +103,7 @@ export function buildGroundingEvidenceChunks( section_path: '', heading: null, content: quote.quote, - source_url: sourceUrl, + source_url: quoteSourceUrl, source_provider: null, source_author: quote.author !== undefined ? quote.author : null, source_published_at: quote.published_at !== undefined ? quote.published_at : null, @@ -199,46 +205,7 @@ function extractCitationWindows( } /** - * Build 3-word phrases (trigrams) from a text string. - * Returns an array of lower-cased trigrams. - */ -function buildTrigrams(text: string): string[] { - const words = text - .toLowerCase() - // Citation markers are markup, not content — stripped whole so their - // digits don't pollute trigrams in citation-dense windows. - .replace(/\[\^\d+\]/g, ' ') - .replace(/[^a-z0-9\s]/g, ' ') - .trim() - .split(/\s+/) - .filter((w) => w.length > 0); - - if (words.length < 3) { - // Fall back to bigrams or unigrams if text is very short - return words; - } - - const trigrams: string[] = []; - for (let i = 0; i <= words.length - 3; i++) { - const w0 = words[i]; - const w1 = words[i + 1]; - const w2 = words[i + 2]; - if (w0 !== undefined && w1 !== undefined && w2 !== undefined) { - trigrams.push(`${w0} ${w1} ${w2}`); - } - } - return trigrams; -} - -/** - * Check if ≥60% of the claim trigrams appear in the chunk content. - */ -/** - * Normalize text for trigram substring matching — same transform as - * buildTrigrams' word stream. Both sides MUST be normalized identically: - * matching normalized word-trigrams against RAW text (the original - * behavior) silently fails on any trigram straddling punctuation or a - * citation marker, structurally deflating every ratio. + * Normalize text for stable evidence identities. */ function normalizeForMatch(text: string): string { return text @@ -249,148 +216,12 @@ function normalizeForMatch(text: string): string { .trim(); } -function directionalTrigramRatio(needleText: string, haystackText: string): number { - const needleTrigrams = buildTrigrams(needleText); - if (needleTrigrams.length === 0) { - return 0; - } - - const haystackNormalized = normalizeForMatch(haystackText); - let matched = 0; - for (const trigram of needleTrigrams) { - if (haystackNormalized.includes(trigram)) { - matched++; - } - } - return matched / needleTrigrams.length; -} - -/** - * Bidirectional trigram overlap between a citation's ±200-char claim window - * and a chunk (verbatim quote). - * - * Forward (window→chunk) alone was structurally near-zero: the window is - * dominated by the model's own prose, so even a correctly-cited verbatim - * quote drowned in paraphrase. The reverse direction (chunk→window) measures - * the right thing for short quotes embedded in longer prose: what fraction of - * the QUOTE's content appears near the citation. Take the max. - */ -function trigramMatchRatio(claimText: string, chunkContent: string): number { - const forward = directionalTrigramRatio(claimText, chunkContent); - const reverse = directionalTrigramRatio(chunkContent, claimText); - return Math.max(forward, reverse); -} - -const TRIGRAM_MATCH_THRESHOLD = 0.6; - -const CONTENT_STOP_WORDS = new Set([ - 'a', 'about', 'after', 'again', 'against', 'all', 'also', 'an', 'and', 'any', - 'are', 'as', 'at', 'be', 'because', 'been', 'before', 'being', 'between', - 'both', 'but', 'by', 'can', 'could', 'did', 'do', 'does', 'doing', 'during', - 'each', 'for', 'from', 'further', 'had', 'has', 'have', 'having', 'he', 'her', - 'here', 'hers', 'herself', 'him', 'himself', 'his', 'how', 'i', 'if', 'in', - 'into', 'is', 'it', 'its', 'itself', 'just', 'may', 'me', 'more', 'most', - 'my', 'myself', 'no', 'nor', 'not', 'now', 'of', 'off', 'on', 'once', 'only', - 'or', 'other', 'our', 'ours', 'ourselves', 'out', 'over', 'own', 'same', 'she', - 'should', 'so', 'some', 'such', 'than', 'that', 'the', 'their', 'theirs', - 'them', 'themselves', 'then', 'there', 'these', 'they', 'this', 'those', - 'through', 'to', 'too', 'under', 'until', 'up', 'very', 'was', 'we', 'were', - 'what', 'when', 'where', 'which', 'while', 'who', 'whom', 'why', 'will', - 'with', 'would', 'you', 'your', 'yours', 'yourself', 'yourselves', -]); - -function stemContentToken(token: string): string { - if (/\d/.test(token) || token.includes('/') || token.includes(':')) return token; - if (token.length > 6 && token.endsWith('ies')) return `${token.slice(0, -3)}y`; - if (token.length > 6 && token.endsWith('ing')) return token.slice(0, -3); - if (token.length > 5 && token.endsWith('ed')) return token.slice(0, -2); - if (token.length > 5 && token.endsWith('es')) return token.slice(0, -2); - if (token.length > 4 && token.endsWith('s')) return token.slice(0, -1); - return token; -} - -function buildContentTokenSet(text: string): Set { - const withoutCitations = text.toLowerCase().replace(/\[\^\d+\]/g, ' '); - const tokens = withoutCitations.match(/[a-z0-9]+(?:[./:-][a-z0-9]+)*/g) ?? []; - const result = new Set(); - for (const rawToken of tokens) { - const token = stemContentToken(rawToken); - if (CONTENT_STOP_WORDS.has(token)) continue; - if (token.length < 2 && !/\d/.test(token)) continue; - result.add(token); - } - return result; -} - -function buildNumberSet(text: string): Set { - const withoutCitations = text - .replace(/\[\^\d+\]/g, ' ') - .replace(/(\d+(?:[.,]\d+)*)\s+percent\b/gi, '$1%'); - const matches = withoutCitations.match(/\d+(?:[.,]\d+)*(?:%|\b)/g) ?? []; - return new Set(matches.map((value) => value.replace(/,/g, ''))); -} - -function setsEqual(left: Set, right: Set): boolean { - if (left.size !== right.size) return false; - for (const value of left) { - if (!right.has(value)) return false; - } - return true; -} - -/** - * Conservative bag-of-content-words match for paraphrases. - * - * This is deliberately an additional high bar, not a lower trigram threshold: - * - at least four meaningful tokens (or every token for a short claim) match; - * - both the shorter and longer text need substantial coverage; - * - at least two matched tokens must be distinctive; and - * - every number/version/percentage must agree exactly. - * - * Numeric consistency is important: a sentence that changes 91% to 81% must - * not pass merely because the surrounding prose is similar. - */ -function contentTokenMatchRatio(claimText: string, evidenceText: string): number { - const claimTokens = buildContentTokenSet(claimText); - const evidenceTokens = buildContentTokenSet(evidenceText); - if (claimTokens.size === 0 || evidenceTokens.size === 0) return 0; - - if (!setsEqual(buildNumberSet(claimText), buildNumberSet(evidenceText))) { - return 0; - } - - const shared = new Set(); - for (const token of claimTokens) { - if (evidenceTokens.has(token)) shared.add(token); - } - - const shorterSize = Math.min(claimTokens.size, evidenceTokens.size); - const longerSize = Math.max(claimTokens.size, evidenceTokens.size); - const requiredShared = shorterSize <= 3 ? shorterSize : 4; - if (shared.size < requiredShared) return 0; - - const distinctiveShared = Array.from(shared).filter( - (token) => token.length >= 5 || /\d/.test(token) || token.includes('/') || token.includes(':'), - ).length; - if (distinctiveShared < Math.min(2, requiredShared)) return 0; - - const shorterCoverage = shared.size / shorterSize; - const longerCoverage = shared.size / longerSize; - if (shorterCoverage < 0.7 || longerCoverage < 0.4) return 0; - - return shorterCoverage * 0.65 + longerCoverage * 0.35; -} - function evidenceMatchRatio(claimText: string, evidenceText: string): number { - // Apply numeric consistency to every matching strategy, including otherwise - // high verbatim overlap. This closes the classic "91%" → "81%" mutation. - if (!setsEqual(buildNumberSet(claimText), buildNumberSet(evidenceText))) { - return 0; - } - return Math.max( - trigramMatchRatio(claimText, evidenceText), - contentTokenMatchRatio(claimText, evidenceText), - ); + const claimWithoutCitations = claimText.replace(/\[\^\d+\]/gu, ' '); + const claimTokens = normalizeForMatch(claimWithoutCitations).split(' ').filter(Boolean); + const evidenceTokens = normalizeForMatch(evidenceText).split(' ').filter(Boolean); + if (claimTokens.length < 4 || evidenceTokens.length < 4) return 0; + return isKeyClaimSupportedBySource(claimWithoutCitations, evidenceText) ? 1 : 0; } /** @@ -480,7 +311,7 @@ export function validateGrounding(input: ValidateGroundingInput): GroundingResul if (matchRatio > bestRatio) { bestRatio = matchRatio; } - if (matchRatio >= TRIGRAM_MATCH_THRESHOLD) { + if (matchRatio === 1) { foundMatch = true; break; } diff --git a/src/index.ts b/src/index.ts index bc63c8f..b7fffb8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,7 +58,7 @@ import { validateGrounding, } from './grounding.js'; import type { GroundingResult } from './grounding.js'; -import { logger as defaultLogger } from './logger.js'; +import { safeLogger } from './logger.js'; import type { Logger } from './logger.js'; // ============================================================================= @@ -217,6 +217,79 @@ export interface PipelineUsageEvent { export type OnPipelineUsage = (event: PipelineUsageEvent) => void | Promise; +const SAFE_USAGE_OPERATIONS = new Set([ + 'assembly-input', + 'assembly-input-cached', + 'assembly-output', + 'extract-input', + 'extract-input-estimated', + 'extract-output', + 'plan-input', + 'plan-output', + 'rerank-2', + 'synth-input', + 'synth-input-cached', + 'synth-output', +]); +const SAFE_USAGE_SORTS = new Set([ + 'comments', 'latest', 'mixed', 'new', 'rating', 'relevance', 'top', 'views', +]); +const SAFE_USAGE_SOURCE_MODES = new Set([ + 'all', 'all_public', 'all_social', 'community', 'cortex', 'facebook_groups', + 'github', 'instagram', 'reddit', 'telegram', 'tiktok', 'web', 'x', 'youtube', +]); +const SAFE_USAGE_MODELS = new Set([ + 'claude-haiku-4-5', + 'claude-sonnet-4-6', + 'claude-sonnet-5', + 'deepseek-v4-flash', + 'deepseek-v4-pro', + 'gemini-2.5-flash', + 'gemini-3-flash-preview', + 'gemini-3.5-flash-lite', + 'gemini-3.6-flash', + 'glm-5.2', + 'qwen/qwen3-235b-a22b-instruct-2507', + 'sonar-deep-research', + 'voyage-4-large', +]); + +const SAFE_USAGE_NUMERIC_FIELDS = new Set([ + 'actual_tokens', + 'estimated_sections', + 'gaps', + 'pass', + 'recency_days', + 'results', + 'sections', +]); + +function safeUsageString(key: string, value: string): string | undefined { + if (key === 'operation') return SAFE_USAGE_OPERATIONS.has(value) ? value : undefined; + if (key === 'sort') return SAFE_USAGE_SORTS.has(value) ? value : undefined; + if (key === 'source_mode') return SAFE_USAGE_SOURCE_MODES.has(value) ? value : undefined; + if (key === 'model') return SAFE_USAGE_MODELS.has(value) ? value : undefined; + return undefined; +} + +export function sanitizePipelineUsageMetadata( + metadata: Record, +): Record { + const safe: Record = {}; + for (const [key, value] of Object.entries(metadata)) { + if (typeof value === 'number' + && Number.isFinite(value) + && SAFE_USAGE_NUMERIC_FIELDS.has(key)) { + safe[key] = value; + } + if (typeof value === 'string') { + const sanitized = safeUsageString(key, value); + if (sanitized !== undefined) safe[key] = sanitized; + } + } + return safe; +} + /** CostTracker that also notifies an OnPipelineUsage listener per entry. */ class NotifyingCostTracker extends CostTracker { constructor( @@ -231,13 +304,16 @@ class NotifyingCostTracker extends CostTracker { super.record(entry); if (this.listener !== undefined) { const modelRaw = entry.metadata['model']; + const safeModel = typeof modelRaw === 'string' + ? safeUsageString('model', modelRaw) + : undefined; const event: PipelineUsageEvent = { provider: entry.provider, category: entry.category, - model: typeof modelRaw === 'string' ? modelRaw : undefined, + model: safeModel, units: entry.units, costUsd: entry.units * entry.unit_cost_usd, - metadata: entry.metadata, + metadata: sanitizePipelineUsageMetadata(entry.metadata), }; Promise.resolve(this.listener(event)).catch((err: unknown) => { this.log.warn( @@ -433,7 +509,7 @@ export interface RunDeepResearchResult { export async function runDeepResearch( input: RunDeepResearchInput, ): Promise { - const log: Logger = input.logger !== undefined ? input.logger : defaultLogger; + const log: Logger = safeLogger(input.logger); const depth: ResearchDepth = input.depth !== undefined ? input.depth : 'standard'; const depthConfig = DEPTH_CONFIG[depth]; const capUsd: number = input.maxCostUsd !== undefined ? input.maxCostUsd : depthConfig.hardCapUsd; @@ -891,7 +967,7 @@ async function searchOneProvider( units: 1, unit_cost_usd: perCall, metadata: { - query: subquery.query.slice(0, 80), results: results.length, + results: results.length, source_mode: subquery.source_mode, sort: effectiveOptions.sort, recency_days: effectiveOptions.recency_days, @@ -1288,14 +1364,14 @@ async function extractQuotes( category: 'extract', units: extracted.usage.prompt_tokens, unit_cost_usd: EXTRACTION_INPUT_USD_PER_M / 1_000_000, - metadata: { model: EXTRACTION_MODEL, operation: 'extract-input', url: result.url.slice(0, 80) }, + metadata: { model: EXTRACTION_MODEL, operation: 'extract-input' }, }); costTracker.record({ provider: 'google', category: 'extract', units: extracted.usage.candidate_tokens, unit_cost_usd: EXTRACTION_OUTPUT_USD_PER_M / 1_000_000, - metadata: { model: EXTRACTION_MODEL, operation: 'extract-output', url: result.url.slice(0, 80) }, + metadata: { model: EXTRACTION_MODEL, operation: 'extract-output' }, }); } else { const estimatedInputTokens = Math.ceil(Math.min(markdown.length, GEMINI_MAX_CONTENT_CHARS) / CHARS_PER_TOKEN); @@ -1304,7 +1380,7 @@ async function extractQuotes( category: 'extract', units: estimatedInputTokens, unit_cost_usd: EXTRACTION_INPUT_USD_PER_M / 1_000_000, - metadata: { model: EXTRACTION_MODEL, operation: 'extract-input-estimated', url: result.url.slice(0, 80) }, + metadata: { model: EXTRACTION_MODEL, operation: 'extract-input-estimated' }, }); } diff --git a/src/ingestion/categorize.ts b/src/ingestion/categorize.ts index 5c3e3a2..1a9b874 100644 --- a/src/ingestion/categorize.ts +++ b/src/ingestion/categorize.ts @@ -160,7 +160,7 @@ function buildPrompt(batch: IngestedItem[]): string { async function categorizeBatch(batch: IngestedItem[]): Promise { const geminiKey = getGeminiKey(); - const url = `https://generativelanguage.googleapis.com/v1beta/models/${EXTRACTION_MODEL}:generateContent?key=${geminiKey}`; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${EXTRACTION_MODEL}:generateContent`; const prompt = buildPrompt(batch); let rawJson: unknown; @@ -169,7 +169,10 @@ async function categorizeBatch(batch: IngestedItem[]): Promise { url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': geminiKey, + }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { temperature: 0.1, maxOutputTokens: 4096 }, @@ -180,8 +183,7 @@ async function categorizeBatch(batch: IngestedItem[]): Promise { 2000, ); if (!response.ok) { - const body = await response.text(); - throw new Error(`Gemini categorize error: ${response.status} ${body.slice(0, 200)}`); + throw new Error(`Gemini categorize error: ${response.status}`); } rawJson = await response.json(); } catch (err) { diff --git a/src/logger.ts b/src/logger.ts index c7e452a..ce0d151 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -19,33 +19,250 @@ export interface Logger { type ConsoleMethod = (...args: unknown[]) => void; +const SAFE_STRING_FIELDS = new Set([ + 'backend', + 'category', + 'depth', + 'errorClass', + 'kind', + 'method', + 'mode', + 'model', + 'operation', + 'phase', + 'provider', + 'providerName', + 'sort', + 'source_mode', + 'status', + 'target', +]); + +const SAFE_STRING_VALUES = new Set([ + 'GET', 'POST', + 'acceptable', 'anthropic', 'apify', 'brightdata', 'completed', 'crawl4ai', + 'critique', 'deep', 'embed', 'error', 'exa', 'extract', 'failed', + 'facebook_groups', 'fetch', 'gemini', 'github', 'google', 'hn', 'instagram', + 'jina', 'longform', 'mixed', 'negative', 'neutral', 'openai', 'pending', + 'perplexity', 'plan', 'podcasts', 'positive', 'processing', 'quick', 'reddit', + 'relevance', 'rss', 'search', 'searxng', 'serp', 'serpapi', 'serper', + 'standard', 'strong', 'success', 'synth', 'tavily', 'tiktok', 'top', + 'transcribe', 'ungrounded', 'unknown', 'video', 'voyage', 'weak', 'web', 'x', + 'youtube', +]); + +const SAFE_NUMERIC_FIELDS = new Set([ + 'actual_tokens', + 'attempt', + 'attempts', + 'candidate_tokens', + 'chars', + 'citations', + 'claims', + 'count', + 'dropped_claims', + 'dropped_quotes', + 'estimated_sections', + 'gaps', + 'input_tokens', + 'output_tokens', + 'parsed', + 'pass', + 'prompt_tokens', + 'quotes', + 'recency_days', + 'replies', + 'results', + 'returned', + 'sections', + 'status', + 'total', + 'upvotes', + 'views', + 'waitMs', +]); + +const SAFE_BOOLEAN_FIELDS = new Set([ + 'cached', + 'enabled', + 'passed', + 'retryable', + 'success', +]); + +const SAFE_LOG_KEYS = new Set([ + 'actual_tokens', 'attempt', 'attempts', 'backend', 'bytes', 'cached', + 'candidate_tokens', 'category', 'chars', 'citations', 'claims', 'count', + 'delayMs', 'depth', 'dropped_claims', 'dropped_quotes', 'enabled', 'error', + 'errorClass', 'errors', 'estimated_sections', 'eventType', 'feedTitle', + 'feedUrl', 'gaps', 'group', 'hashtagCount', 'input_tokens', 'kind', 'method', + 'mode', 'model', 'operation', 'output_tokens', 'parentId', 'parsed', 'pass', + 'passed', 'phase', 'prompt_tokens', 'provider', 'providerName', 'query', + 'quotes', 'rawText', 'reason', 'recency_days', 'redacted', 'replies', + 'resultCount', 'results', 'retryable', 'returned', 'sections', 'sort', 'sorts', + 'source_mode', 'status', 'success', 'target', 'taskId', 'timeFilter', 'total', + 'upvotes', 'url', 'videoId', 'views', 'waitMs', +]); + +const SAFE_MESSAGE_TAGS = new Set([ + 'BrightData', + 'Crawl4AI', + 'DirectFetch', + 'Exa', + 'FacebookGroups', + 'GeminiExtractor', + 'GitHub', + 'HN', + 'Instagram', + 'JinaReader', + 'Perplexity', + 'Podcasts', + 'RSS', + 'Reddit', + 'SearXNG', + 'SerpAPI', + 'Serper', + 'Tavily', + 'TikTok', + 'X', + 'YouTube', + 'YouTubeTranscript', + 'cache', + 'categorizeItems', + 'critique', + 'deep-research/rerank', + 'embedItems', + 'embedder', + 'fetchWithRetry', + 'generateDocument', + 'ingestSource', + 'planner', + 'research', + 'runAnswer', + 'runDeepResearch', + 'runManagedResearch', + 'synthesis', +]); + +function redactedString(value: string): { bytes: number; redacted: true } { + return { + bytes: Buffer.byteLength(value, 'utf8'), + redacted: true, + }; +} + +function safeStringField(key: string, value: string): string | ReturnType { + if (key === 'target' && /^[a-f0-9]{16}$/.test(value)) return value; + if (SAFE_STRING_FIELDS.has(key) + && SAFE_STRING_VALUES.has(value)) { + return value; + } + return redactedString(value); +} + +function sanitizeValue(key: string, value: unknown, depth: number): unknown { + if (value === null) return null; + if (typeof value === 'number') { + return SAFE_NUMERIC_FIELDS.has(key) && Number.isFinite(value) + ? value + : { redacted: true }; + } + if (typeof value === 'boolean') { + return SAFE_BOOLEAN_FIELDS.has(key) ? value : { redacted: true }; + } + if (typeof value === 'string') return safeStringField(key, value); + if (value instanceof Error) return { errorClass: safeStringField('errorClass', value.name) }; + if (Array.isArray(value)) return { redacted: true }; + if (typeof value === 'object' && depth < 2) { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([nestedKey]) => SAFE_LOG_KEYS.has(nestedKey)) + .slice(0, 40) + .map(([nestedKey, nestedValue]) => [ + nestedKey, + sanitizeValue(nestedKey, nestedValue, depth + 1), + ]), + ); + } + return { redacted: true }; +} + +export function sanitizeLogRecord(input: Record): Record { + return Object.fromEntries( + Object.entries(input) + .filter(([key]) => SAFE_LOG_KEYS.has(key)) + .slice(0, 80) + .map(([key, value]) => [key, sanitizeValue(key, value, 0)]), + ); +} + +function safeMessage(value: string | undefined): string { + if (value !== undefined) { + const match = /^\[([A-Za-z0-9_./-]{1,80})\]/.exec(value); + const tag = match?.[1]; + if (tag !== undefined && SAFE_MESSAGE_TAGS.has(tag)) { + return `[${tag}] event`; + } + } + return '[research] event'; +} + function emit(method: ConsoleMethod, obj: Record | string, msg?: string): void { if (typeof obj === 'string') { - method(obj); + method(safeMessage(obj)); return; } if (msg !== undefined) { - method(msg, obj); + method(safeMessage(msg), sanitizeLogRecord(obj)); return; } - method(obj); + method(sanitizeLogRecord(obj)); } /** * Console-backed logger used by default. Mirrors pino's `(obj, msg)` / * `(msg)` overloads so harvested call sites need no changes. */ -export const logger: Logger = { - info(obj, msg) { - emit(console.info.bind(console), obj, msg); - }, - warn(obj, msg) { - emit(console.warn.bind(console), obj, msg); - }, - error(obj, msg) { - emit(console.error.bind(console), obj, msg); - }, - debug(obj, msg) { - emit(console.debug.bind(console), obj, msg); - }, -}; +function consoleLogger(): Logger { + return { + info(obj, msg) { + emit(console.info.bind(console), obj, msg); + }, + warn(obj, msg) { + emit(console.warn.bind(console), obj, msg); + }, + error(obj, msg) { + emit(console.error.bind(console), obj, msg); + }, + debug(obj, msg) { + emit(console.debug.bind(console), obj, msg); + }, + }; +} + +export function createSanitizedLogger(sink: Logger): Logger { + const forward = ( + method: keyof Logger, + obj: Record | string, + msg?: string, + ): void => { + if (typeof obj === 'string') { + sink[method](safeMessage(obj)); + return; + } + sink[method](sanitizeLogRecord(obj), safeMessage(msg)); + }; + return { + info: (obj, msg) => forward('info', obj, msg), + warn: (obj, msg) => forward('warn', obj, msg), + error: (obj, msg) => forward('error', obj, msg), + debug: (obj, msg) => forward('debug', obj, msg), + }; +} + +/** Default logger never emits briefs, queries, URLs, bodies, or raw errors. */ +export const logger: Logger = consoleLogger(); + +export function safeLogger(loggerOverride: Logger | undefined): Logger { + return loggerOverride === undefined ? logger : createSanitizedLogger(loggerOverride); +} diff --git a/src/managed-research.ts b/src/managed-research.ts index 67449a3..6093f27 100644 --- a/src/managed-research.ts +++ b/src/managed-research.ts @@ -13,7 +13,7 @@ */ import { z } from 'zod'; -import { logger as defaultLogger } from './logger.js'; +import { safeLogger } from './logger.js'; import type { Logger } from './logger.js'; import { PERPLEXITY_API_URL, getPerplexityApiKey } from './providers/perplexity.js'; import { FinalReportSchema } from './types.js'; @@ -93,7 +93,7 @@ export interface RunManagedResearchResult { export async function runManagedResearch( input: RunManagedResearchInput, ): Promise { - const log: Logger = input.logger !== undefined ? input.logger : defaultLogger; + const log: Logger = safeLogger(input.logger); const apiKey = getPerplexityApiKey(); const timeoutMs = input.timeoutMs !== undefined ? input.timeoutMs : 600_000; diff --git a/src/pipeline-core.ts b/src/pipeline-core.ts index 23bc992..b27d78a 100644 --- a/src/pipeline-core.ts +++ b/src/pipeline-core.ts @@ -9,6 +9,12 @@ import { z } from 'zod'; import { logger } from './logger.js'; +import { + fetchPublicText, + outboundTargetFingerprint, + readBoundedWebResponseText, + resolvePublicHttpUrl, +} from './public-http.js'; // ============================================================================ // Types — Crawl4AI @@ -48,35 +54,35 @@ export interface CrawlStatusResponse { const CrawlTaskResponseSchema = z.object({ success: z.boolean(), - id: z.string().optional().default(''), + id: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/).optional().default(''), results: z.array(z.object({ - markdown: z.union([z.string(), z.object({ - raw_markdown: z.string().default(''), - markdown_with_citations: z.string().default(''), - references_markdown: z.string().default(''), - fit_markdown: z.string().default(''), + markdown: z.union([z.string().max(5_000_000), z.object({ + raw_markdown: z.string().max(5_000_000).default(''), + markdown_with_citations: z.string().max(5_000_000).default(''), + references_markdown: z.string().max(5_000_000).default(''), + fit_markdown: z.string().max(5_000_000).default(''), })]), metadata: z.object({ statusCode: z.number().optional().default(0), - url: z.string().optional().default(''), + url: z.string().max(4096).optional().default(''), }).passthrough(), - })).optional(), + })).max(4).optional(), }); const CrawlStatusResponseSchema = z.object({ - status: z.string(), + status: z.enum(['queued', 'processing', 'completed', 'failed']), data: z.array(z.object({ - markdown: z.union([z.string(), z.object({ - raw_markdown: z.string().default(''), - markdown_with_citations: z.string().default(''), - references_markdown: z.string().default(''), - fit_markdown: z.string().default(''), + markdown: z.union([z.string().max(5_000_000), z.object({ + raw_markdown: z.string().max(5_000_000).default(''), + markdown_with_citations: z.string().max(5_000_000).default(''), + references_markdown: z.string().max(5_000_000).default(''), + fit_markdown: z.string().max(5_000_000).default(''), })]), metadata: z.object({ statusCode: z.number().optional().default(0), - url: z.string().optional().default(''), + url: z.string().max(4096).optional().default(''), }).passthrough(), - })), + })).max(4), }); // ============================================================================ @@ -99,20 +105,82 @@ export const VOYAGE_API_URL = 'https://api.voyageai.com/v1/embeddings'; export const GEMINI_MAX_CONTENT_CHARS = 12000; export const EXTRACTION_MODEL = 'gemini-3.5-flash-lite'; +const CRAWL4AI_ALLOWED_DOMAINS_ENV = 'BRAINTIED_CRAWL4AI_ALLOWED_DOMAINS'; +const CRAWL4AI_NETWORK_GUARD_ENV = 'BRAINTIED_CRAWL4AI_NETWORK_GUARD'; +const CRAWL4AI_NETWORK_GUARD_VALUE = 'enforced-v1'; +const MAX_CRAWL_RESPONSE_BYTES = 8_000_000; + +function crawl4aiDomainAllowed(hostname: string): boolean { + const allowlist = (process.env[CRAWL4AI_ALLOWED_DOMAINS_ENV] ?? '') + .split(',') + .map((value) => value.trim().toLowerCase().replace(/\.$/, '')) + .filter((value) => value.length > 0); + const normalized = hostname.toLowerCase().replace(/\.$/, ''); + return allowlist.some((entry) => { + if (entry.startsWith('*.')) { + const suffix = entry.slice(2); + return suffix.length > 0 && normalized.endsWith(`.${suffix}`); + } + return normalized === entry; + }); +} + +async function crawlResultMatchesTarget(initial: URL, resultUrl: string): Promise { + if (resultUrl.length === 0) return false; + try { + const finalTarget = await resolvePublicHttpUrl(resultUrl); + if (finalTarget.url.hostname.toLowerCase() !== initial.hostname.toLowerCase()) return false; + if (initial.protocol === 'https:' && finalTarget.url.protocol !== 'https:') return false; + return crawl4aiDomainAllowed(finalTarget.url.hostname); + } catch { + return false; + } +} + // ============================================================================ // Helpers — Environment // ============================================================================ +export const GEMINI_KEY_ENV_NAMES = [ + 'GEMINI_RESEARCH_KEY', + 'GOOGLE_GEMINI_API_KEY', + 'GOOGLE_GENERATIVE_AI_API_KEY', + 'GEMINI_API_KEY', +] as const; + +export const GEMINI_KEY_NAME_ENV = 'BRAINTIED_GEMINI_KEY_NAME'; + export function getGeminiKey(): string { - // GEMINI_RESEARCH_KEY is the dedicated research key; GEMINI_API_KEY is the - // documented alias (README promises either works — previously only the - // synthesis path honored GEMINI_API_KEY, so planner/extraction failed on - // consumers that set just one). - const key = process.env.GEMINI_RESEARCH_KEY; - if (key !== undefined && key !== '') return key; - const alias = process.env.GEMINI_API_KEY; - if (alias !== undefined && alias !== '') return alias; - throw new Error('GEMINI_RESEARCH_KEY (or GEMINI_API_KEY) environment variable is not configured'); + // The package has consumers in both Braintied and Vercel AI SDK ecosystems. + // Keep one resolver so planning, extraction, and synthesis cannot select + // different/stale aliases for the same Gemini request path. + const configuredName = process.env[GEMINI_KEY_NAME_ENV]?.trim(); + if (configuredName !== undefined && configuredName.length > 0 + && !GEMINI_KEY_ENV_NAMES.includes(configuredName as (typeof GEMINI_KEY_ENV_NAMES)[number])) { + throw new Error( + `${GEMINI_KEY_NAME_ENV} must name one of: ${GEMINI_KEY_ENV_NAMES.join(', ')}`, + ); + } + + const candidates = GEMINI_KEY_ENV_NAMES.flatMap((name) => { + const value = process.env[name]; + return value === undefined || value.trim().length === 0 ? [] : [{ name, value }]; + }); + if (configuredName !== undefined && configuredName.length > 0) { + const selected = candidates.find((candidate) => candidate.name === configuredName); + if (selected === undefined) { + throw new Error(`${GEMINI_KEY_NAME_ENV} selects ${configuredName}, but that variable is not configured`); + } + return selected.value; + } + + if (new Set(candidates.map((candidate) => candidate.value)).size > 1) { + throw new Error( + `Conflicting Gemini aliases are configured (${candidates.map((candidate) => candidate.name).join(', ')}); set ${GEMINI_KEY_NAME_ENV}`, + ); + } + if (candidates[0] !== undefined) return candidates[0].value; + throw new Error(`One of ${GEMINI_KEY_ENV_NAMES.join(', ')} must be configured`); } export function getVoyageKey(): string { @@ -227,6 +295,7 @@ export async function fetchWithRetry( const response = await fetch(url, options); if (response.ok) return response; if (response.status === 429 || response.status >= 500) { + await response.body?.cancel().catch(() => undefined); const delay = baseDelayMs * Math.pow(2, attempt); logger.warn( { url: url.slice(0, 80), status: response.status, attempt, delayMs: delay }, @@ -238,7 +307,7 @@ export async function fetchWithRetry( // 4xx non-retry error — return as-is return response; } - throw new Error(`Failed after ${maxRetries} retries: ${url.slice(0, 100)}`); + throw new Error(`Request failed after ${maxRetries} retries`); } // ============================================================================ @@ -251,6 +320,18 @@ export async function fetchWithRetry( */ export async function crawlWithCrawl4AI(url: string): Promise { try { + if (process.env[CRAWL4AI_NETWORK_GUARD_ENV] !== CRAWL4AI_NETWORK_GUARD_VALUE) { + logger.debug({}, '[Crawl4AI] External browser crawler is disabled without an enforced network guard'); + return null; + } + const target = await resolvePublicHttpUrl(url); + if (!crawl4aiDomainAllowed(target.url.hostname)) { + logger.debug( + { target: outboundTargetFingerprint(url) }, + '[Crawl4AI] Target is not in the reviewed crawler domain allowlist', + ); + return null; + } const response = await fetchWithRetry( `${SCRAPER_BASE_URL}/crawl`, { @@ -262,7 +343,7 @@ export async function crawlWithCrawl4AI(url: string): Promise { // apply, so JS-rendered SPAs come back empty (verified in Sentigen // 2026-06-20: flat → 1 char, typed → 5,898 chars on the same URL). body: JSON.stringify({ - urls: [url], + urls: [target.url.toString()], browser_config: { type: 'BrowserConfig', params: { headless: true, java_script_enabled: true }, @@ -283,15 +364,22 @@ export async function crawlWithCrawl4AI(url: string): Promise { ); if (!response.ok) { - const body = await response.text(); - logger.warn({ url, status: response.status, body: body.slice(0, 100) }, '[Crawl4AI] Submission failed'); + await response.body?.cancel().catch(() => undefined); + logger.warn( + { target: outboundTargetFingerprint(url), status: response.status }, + '[Crawl4AI] Submission failed', + ); return null; } - const rawJson: unknown = await response.json(); + const responseText = await readBoundedWebResponseText(response, MAX_CRAWL_RESPONSE_BYTES); + const rawJson: unknown = JSON.parse(responseText); const parseResult = CrawlTaskResponseSchema.safeParse(rawJson); if (!parseResult.success) { - logger.warn({ url, errors: parseResult.error.message }, '[Crawl4AI] Invalid response shape'); + logger.warn( + { target: outboundTargetFingerprint(url), errors: parseResult.error.message }, + '[Crawl4AI] Invalid response shape', + ); return null; } const responseData = parseResult.data; @@ -300,19 +388,35 @@ export async function crawlWithCrawl4AI(url: string): Promise { if (responseData.results !== undefined && responseData.results.length > 0) { const first = responseData.results[0]; if (first !== undefined) { + if (!await crawlResultMatchesTarget(target.url, first.metadata.url)) { + logger.warn( + { target: outboundTargetFingerprint(url) }, + '[Crawl4AI] Result target did not match the reviewed source', + ); + return null; + } const md = extractMarkdown(first.markdown as string | MarkdownDict); if (md.length > 100) { - logger.info({ url: url.slice(0, 60), chars: md.length }, '[Crawl4AI] Sync success'); + logger.info( + { target: outboundTargetFingerprint(url), chars: md.length }, + '[Crawl4AI] Sync success', + ); return md; } } - logger.warn({ url }, '[Crawl4AI] Sync response had empty markdown'); + logger.warn( + { target: outboundTargetFingerprint(url) }, + '[Crawl4AI] Sync response had empty markdown', + ); return null; } // Handle async response (task ID) — older Crawl4AI versions if (!responseData.success || responseData.id.length === 0) { - logger.warn({ url }, '[Crawl4AI] No task ID and no inline results'); + logger.warn( + { target: outboundTargetFingerprint(url) }, + '[Crawl4AI] No task ID and no inline results', + ); return null; } @@ -323,9 +427,13 @@ export async function crawlWithCrawl4AI(url: string): Promise { const statusResp = await fetch(`${SCRAPER_BASE_URL}/task/${responseData.id}`, { signal: AbortSignal.timeout(10000), }); - if (!statusResp.ok) continue; + if (!statusResp.ok) { + await statusResp.body?.cancel().catch(() => undefined); + continue; + } - const statusRaw: unknown = await statusResp.json(); + const statusText = await readBoundedWebResponseText(statusResp, MAX_CRAWL_RESPONSE_BYTES); + const statusRaw: unknown = JSON.parse(statusText); const statusResult = CrawlStatusResponseSchema.safeParse(statusRaw); if (!statusResult.success) continue; const statusData = statusResult.data; @@ -334,6 +442,13 @@ export async function crawlWithCrawl4AI(url: string): Promise { if (statusData.data.length > 0) { const first = statusData.data[0]; if (first !== undefined) { + if (!await crawlResultMatchesTarget(target.url, first.metadata.url)) { + logger.warn( + { target: outboundTargetFingerprint(url) }, + '[Crawl4AI] Result target did not match the reviewed source', + ); + return null; + } const md = extractMarkdown(first.markdown as string | MarkdownDict); if (md.length > 100) return md; } @@ -343,11 +458,17 @@ export async function crawlWithCrawl4AI(url: string): Promise { if (statusData.status === 'failed') return null; } - logger.warn({ url, taskId: responseData.id }, '[Crawl4AI] Poll timed out'); + logger.warn( + { target: outboundTargetFingerprint(url), taskId: responseData.id }, + '[Crawl4AI] Poll timed out', + ); return null; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - logger.error({ url, error: msg }, '[Crawl4AI] Crawl error'); + logger.error( + { target: outboundTargetFingerprint(url), error: msg }, + '[Crawl4AI] Crawl error', + ); return null; } } @@ -358,17 +479,19 @@ export async function crawlWithCrawl4AI(url: string): Promise { */ export async function directFetchAsText(url: string): Promise { try { - const response = await fetch(url, { + const response = await fetchPublicText(url, { + acceptedContentTypes: ['text/html', 'application/xhtml+xml', 'text/plain'], headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', 'Accept': 'text/html,application/xhtml+xml', }, - signal: AbortSignal.timeout(15000), + maxBytes: 2_000_000, + timeoutMs: 15_000, }); if (!response.ok) return null; - const html = await response.text(); + const html = response.text; if (html.length < 200) return null; const titleMatch = html.match(/([^<]*)<\/title>/i); @@ -393,11 +516,17 @@ export async function directFetchAsText(url: string): Promise<string | null> { if (cleaned.length < 200) return null; const result = title.length > 0 ? `# ${title}\n\n${cleaned}` : cleaned; - logger.info({ url: url.slice(0, 60), chars: result.length }, '[DirectFetch] Success'); + logger.info( + { target: outboundTargetFingerprint(url), chars: result.length }, + '[DirectFetch] Success', + ); return result; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - logger.warn({ url, error: msg }, '[DirectFetch] Failed'); + logger.warn( + { target: outboundTargetFingerprint(url), error: msg }, + '[DirectFetch] Failed', + ); return null; } } @@ -412,27 +541,40 @@ export async function jinaReaderFetch(url: string): Promise<string | null> { if (key === undefined || key === '') return null; try { - const response = await fetch(`https://r.jina.ai/${url}`, { + const target = await resolvePublicHttpUrl(url); + const response = await fetchPublicText(`https://r.jina.ai/${target.url.toString()}`, { + acceptedContentTypes: ['text/markdown', 'text/plain'], headers: { Authorization: `Bearer ${key}`, Accept: 'text/plain', }, - signal: AbortSignal.timeout(30000), + maxBytes: 2_000_000, + maxRedirects: 0, + timeoutMs: 30_000, }); if (!response.ok) { - logger.warn({ url: url.slice(0, 80), status: response.status }, '[JinaReader] Non-OK response'); + logger.warn( + { target: outboundTargetFingerprint(target.url.toString()), status: response.status }, + '[JinaReader] Non-OK response', + ); return null; } - const markdown = await response.text(); + const markdown = response.text; if (markdown.trim().length < 200) return null; - logger.info({ url: url.slice(0, 60), chars: markdown.length }, '[JinaReader] Success'); + logger.info( + { target: outboundTargetFingerprint(target.url.toString()), chars: markdown.length }, + '[JinaReader] Success', + ); return markdown; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - logger.warn({ url, error: msg }, '[JinaReader] Failed'); + logger.warn( + { target: outboundTargetFingerprint(url), error: msg }, + '[JinaReader] Failed', + ); return null; } } diff --git a/src/planner.ts b/src/planner.ts index 07a2ae9..f29e6af 100644 --- a/src/planner.ts +++ b/src/planner.ts @@ -130,7 +130,7 @@ export interface PlannerUsage { async function callGemini(userMessage: string, systemPrompt: string): Promise<{ text: string; usage: PlannerUsage }> { const key = getGeminiKey(); - const url = `${GEMINI_API_BASE}/${EXTRACTION_MODEL}:generateContent?key=${key}`; + const url = `${GEMINI_API_BASE}/${EXTRACTION_MODEL}:generateContent`; const body = { system_instruction: { parts: [{ text: systemPrompt }] }, @@ -144,12 +144,15 @@ async function callGemini(userMessage: string, systemPrompt: string): Promise<{ const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': key, + }, body: JSON.stringify(body), }); if (!response.ok) { - throw new Error(`Gemini API error ${response.status}: ${await response.text()}`); + throw new Error(`Gemini API error: ${response.status}`); } const raw: unknown = await response.json(); @@ -325,7 +328,7 @@ export async function planSubqueries(input: PlanSubqueriesInput): Promise<Subque export async function summarizePromptBrief(promptMd: string): Promise<string> { const key = getGeminiKey(); - const url = `${GEMINI_API_BASE}/${EXTRACTION_MODEL}:generateContent?key=${key}`; + const url = `${GEMINI_API_BASE}/${EXTRACTION_MODEL}:generateContent`; const systemInstruction = 'You are a research coordinator. Distill the following research brief into a single paragraph of 2–4 sentences that captures the core question, audience, and desired output. Be precise and concrete. Return only the paragraph text — no labels, no markdown.'; @@ -341,12 +344,15 @@ export async function summarizePromptBrief(promptMd: string): Promise<string> { const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': key, + }, body: JSON.stringify(body), }); if (!response.ok) { - throw new Error(`Gemini summarize error ${response.status}: ${await response.text()}`); + throw new Error(`Gemini summarize error: ${response.status}`); } const raw: unknown = await response.json(); diff --git a/src/providers/gemini-extractor.ts b/src/providers/gemini-extractor.ts index 947ffd1..7ecfdd7 100644 --- a/src/providers/gemini-extractor.ts +++ b/src/providers/gemini-extractor.ts @@ -7,7 +7,15 @@ import { z } from 'zod'; import { getGeminiKey, fetchWithRetry, EXTRACTION_MODEL } from '../pipeline-core.js'; -import { ExtractedQuotesSchema, type ExtractedQuotes, type ProviderName } from '../types.js'; +import { + ExtractedQuotesSchema, + type ExtractedQuotes, + type ProviderName, +} from '../types.js'; +import { + isKeyClaimSupportedBySource, + isVerbatimQuoteSupportedBySource, +} from '../evidence-validation.js'; import { logger } from '../logger.js'; // ============================================================================= @@ -43,19 +51,21 @@ const GeminiResponseEnvelopeSchema = z.object({ // Raw quote schema (what Gemini returns per quote in its JSON array) // ============================================================================= +const SENTIMENT_VALUES = ['positive', 'negative', 'neutral', 'mixed'] as const; + const RawQuoteSchema = z.object({ - quote: z.string().default(''), + quote: z.string().min(1), context: z.string().default(''), source_url: z.string().default(''), author: z.string().optional(), - published_at: z.string().optional(), + published_at: z.string().datetime({ offset: true }).optional(), engagement: z.object({ upvotes: z.number().optional(), likes: z.number().optional(), replies: z.number().optional(), views: z.number().optional(), }).default({}), - sentiment: z.enum(['positive', 'negative', 'neutral', 'mixed']).optional(), + sentiment: z.enum(SENTIMENT_VALUES).optional(), category: z.string().optional(), }); @@ -64,14 +74,121 @@ const RawQuoteSchema = z.object({ // ============================================================================= const RawExtractedSchema = z.object({ - source_url: z.string().default(''), - source_provider: z.string().default('tavily'), - key_claims: z.array(z.string()).default([]), - verbatim_quotes: z.array(RawQuoteSchema).default([]), - dates_mentioned: z.array(z.string()).default([]), - entities_mentioned: z.array(z.string()).default([]), - themes: z.array(z.string()).default([]), -}); + // Treat model-generated collections as untrusted here, then salvage valid + // entries individually. One malformed optional field must not erase every + // valid quote and key claim extracted from the same page. + key_claims: z.unknown().optional(), + verbatim_quotes: z.unknown().optional(), + dates_mentioned: z.unknown().optional(), + entities_mentioned: z.unknown().optional(), + themes: z.unknown().optional(), +}).passthrough(); + +function stringEntries(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === 'string'); +} + +function parseRawQuote(value: unknown, input: GeminiExtractInput): z.infer<typeof RawQuoteSchema> | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record<string, unknown>; + if (typeof record.quote !== 'string' || record.quote.trim().length === 0) return null; + + const rawContext = typeof record.context === 'string' ? record.context : ''; + const candidate = { + quote: record.quote, + // Context is retained only when it is itself exact source text. Model + // authorship, dates, engagement, sentiment, categories, and anchors are + // not source facts and therefore never cross this normalization boundary. + context: isVerbatimQuoteSupportedBySource(rawContext, input.content) ? rawContext : '', + source_url: input.url, + engagement: {}, + }; + const parsed = RawQuoteSchema.safeParse(candidate); + return parsed.success ? parsed.data : null; +} + +export interface GeminiExtractionUsage { + promptTokenCount: number; + candidatesTokenCount: number; +} + +/** @internal Pure normalization boundary, exported for offline contract tests. */ +export function normalizeGeminiExtractionPayload( + payload: unknown, + input: GeminiExtractInput, + usageMeta?: GeminiExtractionUsage, +): ExtractedQuotes { + const rawResult = RawExtractedSchema.safeParse(payload); + if (!rawResult.success) { + logger.warn({ url: input.url, errors: rawResult.error.message }, '[GeminiExtractor] Invalid extracted schema'); + return buildEmptyResult(input); + } + + const rawQuotes = Array.isArray(rawResult.data.verbatim_quotes) + ? rawResult.data.verbatim_quotes + : []; + const structurallyValidQuotes = rawQuotes.flatMap((quote) => { + const parsedQuote = parseRawQuote(quote, input); + return parsedQuote === null ? [] : [parsedQuote]; + }); + const validQuotes = structurallyValidQuotes.filter((quote) => ( + isVerbatimQuoteSupportedBySource(quote.quote, input.content) + )); + const droppedQuotes = rawQuotes.length - validQuotes.length; + if (droppedQuotes > 0) { + logger.warn( + { url: input.url, dropped_quotes: droppedQuotes }, + '[GeminiExtractor] Dropped malformed or source-unsupported quote entries', + ); + } + + const rawClaims = stringEntries(rawResult.data.key_claims); + const validClaims = rawClaims.filter((claim) => ( + isKeyClaimSupportedBySource(claim, input.content) + )); + const droppedClaims = rawClaims.length - validClaims.length; + if (droppedClaims > 0) { + logger.warn( + { url: input.url, dropped_claims: droppedClaims }, + '[GeminiExtractor] Dropped source-unsupported key claims', + ); + } + + const assembled = { + source_url: input.url, + source_provider: input.provider, + usage: usageMeta !== undefined + ? { + prompt_tokens: usageMeta.promptTokenCount, + candidate_tokens: usageMeta.candidatesTokenCount, + } + : undefined, + key_claims: validClaims, + verbatim_quotes: validQuotes, + dates_mentioned: [], + entities_mentioned: [], + themes: [], + }; + + const final = ExtractedQuotesSchema.safeParse(assembled); + if (!final.success) { + logger.warn({ url: input.url, errors: final.error.message }, '[GeminiExtractor] Final schema validation failed'); + return buildEmptyResult(input); + } + + logger.info( + { + url: input.url.slice(0, 60), + mode: input.mode, + quotes: final.data.verbatim_quotes.length, + claims: final.data.key_claims.length, + }, + '[GeminiExtractor] Extraction complete', + ); + + return final.data; +} // ============================================================================= // Mode-specific prompt builders @@ -94,7 +211,7 @@ Return ONLY valid JSON matching this exact schema — no prose, no markdown fenc "source_url": "<permalink or anchored URL>", "author": "<author if known>", "engagement": { "upvotes": 0, "likes": 0 }, - "sentiment": "positive|negative|neutral|mixed", + "sentiment": "neutral", "category": "helpful|critical|scammy|promotional|informational" } ], @@ -103,6 +220,8 @@ Return ONLY valid JSON matching this exact schema — no prose, no markdown fenc "themes": [] } +For sentiment, emit exactly one of "positive", "negative", "neutral", or "mixed". Omit sentiment when it cannot be classified confidently. + Source URL: ${url}`; if (mode === 'reddit') { @@ -219,7 +338,7 @@ export async function extractQuotesWithGemini(input: GeminiExtractInput): Promis const geminiKey = getGeminiKey(); const prompt = buildPrompt(input); - const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${EXTRACTION_MODEL}:generateContent?key=${geminiKey}`; + const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${EXTRACTION_MODEL}:generateContent`; let rawJson: unknown; @@ -228,7 +347,10 @@ export async function extractQuotesWithGemini(input: GeminiExtractInput): Promis geminiUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': geminiKey, + }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { @@ -243,8 +365,7 @@ export async function extractQuotesWithGemini(input: GeminiExtractInput): Promis ); if (!response.ok) { - const body = await response.text(); - throw new Error(`Gemini API error: ${response.status} ${body.slice(0, 200)}`); + throw new Error(`Gemini API error: ${response.status}`); } rawJson = await response.json(); @@ -276,57 +397,7 @@ export async function extractQuotesWithGemini(input: GeminiExtractInput): Promis return buildEmptyResult(input); } - const rawResult = RawExtractedSchema.safeParse(parsed); - if (!rawResult.success) { - logger.warn({ url: input.url, errors: rawResult.error.message }, '[GeminiExtractor] Invalid extracted schema'); - return buildEmptyResult(input); - } - - const usageMeta = envelopeResult.data.usageMetadata; - - // Build and Zod-validate the final ExtractedQuotes - const assembled = { - source_url: input.url, - source_provider: input.provider, - usage: usageMeta !== undefined - ? { - prompt_tokens: usageMeta.promptTokenCount, - candidate_tokens: usageMeta.candidatesTokenCount, - } - : undefined, - key_claims: rawResult.data.key_claims, - verbatim_quotes: rawResult.data.verbatim_quotes.map(q => ({ - quote: q.quote, - context: q.context, - source_url: q.source_url.length > 0 ? q.source_url : input.url, - author: q.author, - published_at: q.published_at, - engagement: q.engagement, - sentiment: q.sentiment, - category: q.category, - })), - dates_mentioned: rawResult.data.dates_mentioned, - entities_mentioned: rawResult.data.entities_mentioned, - themes: rawResult.data.themes, - }; - - const final = ExtractedQuotesSchema.safeParse(assembled); - if (!final.success) { - logger.warn({ url: input.url, errors: final.error.message }, '[GeminiExtractor] Final schema validation failed'); - return buildEmptyResult(input); - } - - logger.info( - { - url: input.url.slice(0, 60), - mode: input.mode, - quotes: final.data.verbatim_quotes.length, - claims: final.data.key_claims.length, - }, - '[GeminiExtractor] Extraction complete', - ); - - return final.data; + return normalizeGeminiExtractionPayload(parsed, input, envelopeResult.data.usageMetadata); } function buildEmptyResult(input: GeminiExtractInput): ExtractedQuotes { diff --git a/src/providers/reddit.ts b/src/providers/reddit.ts index e8b8039..9ad616b 100644 --- a/src/providers/reddit.ts +++ b/src/providers/reddit.ts @@ -81,8 +81,7 @@ async function getAccessToken(): Promise<string> { }); if (!response.ok) { - const body = await response.text(); - throw new Error(`Reddit OAuth2 token error: ${response.status} ${body.slice(0, 200)}`); + throw new Error(`Reddit OAuth2 token error: ${response.status}`); } const TokenResponseSchema = z.object({ @@ -387,21 +386,49 @@ export const redditProvider: SearchProvider = { }, async fetch(url: string, signal?: AbortSignal): Promise<FetchResult> { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return FetchResultSchema.parse({ + provider: 'reddit', + url, + fetch_status: 'failed', + fetch_error: 'Reddit fetch requires a valid Reddit HTTPS URL', + }); + } + const redditHosts = new Set([ + 'reddit.com', + 'www.reddit.com', + 'old.reddit.com', + 'np.reddit.com', + 'oauth.reddit.com', + ]); + if (parsedUrl.protocol !== 'https:' + || parsedUrl.username !== '' + || parsedUrl.password !== '' + || parsedUrl.port !== '' + || !redditHosts.has(parsedUrl.hostname.toLowerCase())) { + return FetchResultSchema.parse({ + provider: 'reddit', + url, + fetch_status: 'failed', + fetch_error: 'Reddit fetch requires a canonical Reddit HTTPS URL', + }); + } + parsedUrl.hostname = 'oauth.reddit.com'; + if (!parsedUrl.pathname.endsWith('.json')) parsedUrl.pathname += '.json'; + parsedUrl.searchParams.set('limit', '200'); + + // Resolve credentials only after the destination is proven canonical. A + // caller-controlled URL must never cause a bearer token to be attached to + // an unreviewed origin, even if fetch/redirect behavior changes later. const token = await getAccessToken(); const userAgent = getUserAgent(); await rateLimit(); - // Normalize to API JSON endpoint - let jsonUrl = url; - if (!jsonUrl.endsWith('.json')) { - const separator = jsonUrl.includes('?') ? '&' : '?'; - jsonUrl = `${jsonUrl}.json${separator}limit=200`; - } - - // Ensure we're using oauth.reddit.com for authenticated requests - jsonUrl = jsonUrl.replace('www.reddit.com', 'oauth.reddit.com'); - - const response = await fetch(jsonUrl, { + const response = await fetch(parsedUrl, { + redirect: 'error', headers: { 'Authorization': `Bearer ${token}`, 'User-Agent': userAgent, @@ -410,12 +437,11 @@ export const redditProvider: SearchProvider = { }); if (!response.ok) { - const errorBody = await response.text(); const result = FetchResultSchema.parse({ provider: 'reddit', url, fetch_status: 'failed', - fetch_error: `Reddit fetch error: ${response.status} ${errorBody.slice(0, 100)}`, + fetch_error: `Reddit fetch error: ${response.status}`, }); return result; } diff --git a/src/public-http.ts b/src/public-http.ts new file mode 100644 index 0000000..ea85cfb --- /dev/null +++ b/src/public-http.ts @@ -0,0 +1,433 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { lookup } from 'node:dns/promises'; +import * as http from 'node:http'; +import * as https from 'node:https'; +import { BlockList, isIP } from 'node:net'; +import { performance } from 'node:perf_hooks'; + +const DEFAULT_MAX_BYTES = 2_000_000; +const DEFAULT_MAX_REDIRECTS = 5; +const DEFAULT_TIMEOUT_MS = 15_000; +const TARGET_FINGERPRINT_KEY = randomBytes(32); + +const blockedAddresses = new BlockList(); +const globallyRoutableIpv6 = new BlockList(); +globallyRoutableIpv6.addSubnet('2000::', 3, 'ipv6'); + +for (const [address, prefix] of [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.0.2.0', 24], + ['192.88.99.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['198.51.100.0', 24], + ['203.0.113.0', 24], + ['224.0.0.0', 4], + ['240.0.0.0', 4], +] as const) { + blockedAddresses.addSubnet(address, prefix, 'ipv4'); +} + +for (const [address, prefix] of [ + ['::', 96], + ['3fff::', 20], + ['5f00::', 16], + ['64:ff9b::', 96], + ['64:ff9b:1::', 48], + ['100::', 64], + ['2001::', 23], + ['2001:db8::', 32], + ['2002::', 16], + ['fec0::', 10], + ['fc00::', 7], + ['fe80::', 10], + ['ff00::', 8], +] as const) { + blockedAddresses.addSubnet(address, prefix, 'ipv6'); +} + +const blockedHostnameSuffixes = [ + '.home.arpa', + '.internal', + '.invalid', + '.local', + '.localhost', +] as const; + +export type ResolvedAddress = { + address: string; + family: 4 | 6; +}; + +export type PublicHostResolver = (hostname: string) => Promise<ResolvedAddress[]>; + +export interface ResolvedPublicUrl { + address: string; + family: 4 | 6; + url: URL; +} + +export interface PublicTextResponse { + finalUrl: string; + headers: http.IncomingHttpHeaders; + ok: boolean; + status: number; + text: string; +} + +export interface PublicTextFetchOptions { + acceptedContentTypes?: readonly string[]; + headers?: Readonly<Record<string, string>>; + maxBytes?: number; + maxRedirects?: number; + timeoutMs?: number; +} + +interface BoundResponse { + body: Buffer; + headers: http.IncomingHttpHeaders; + status: number; +} + +type BoundRequester = ( + target: ResolvedPublicUrl, + options: Required<Pick<PublicTextFetchOptions, 'maxBytes' | 'timeoutMs'>> & { + headers: Readonly<Record<string, string>>; + }, +) => Promise<BoundResponse>; + +export interface PublicTextFetchDependencies { + request?: BoundRequester; + resolve?: PublicHostResolver; +} + +/** Read an already-fetched web response without allowing decoded bytes to grow unbounded. */ +export async function readBoundedWebResponseText( + response: Response, + maxBytes: number, +): Promise<string> { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new RangeError('Invalid response byte limit'); + } + const declaredLength = Number(response.headers.get('content-length') ?? '0'); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + await response.body?.cancel().catch(() => undefined); + throw new UnsafeOutboundUrlError(); + } + if (response.body === null) return ''; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + bytes += next.value.byteLength; + if (bytes > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new UnsafeOutboundUrlError(); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + + const body = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(body); +} + +export class UnsafeOutboundUrlError extends Error { + constructor() { + super('Outbound URL is not an approved public HTTP target'); + this.name = 'UnsafeOutboundUrlError'; + } +} + +function normalizedHostname(url: URL): string { + return url.hostname + .replace(/^\[/, '') + .replace(/\]$/, '') + .replace(/\.$/, '') + .toLowerCase(); +} + +function hostnameIsBlocked(hostname: string): boolean { + return hostname === 'localhost' + || hostname.endsWith('.arpa') + || blockedHostnameSuffixes.some( + (suffix) => hostname === suffix.slice(1) || hostname.endsWith(suffix), + ); +} + +export function isPublicAddress(address: string, family?: 4 | 6): boolean { + const normalized = address.split('%', 1)[0] ?? address; + const detected = isIP(normalized); + if (detected !== 4 && detected !== 6) return false; + if (family !== undefined && detected !== family) return false; + // Public IPv6 destinations must be allocated from the global-unicast + // 2000::/3 space. This fail-closed positive gate rejects translated, + // mapped, site-local, unallocated, and future-use encodings before the + // narrower special-purpose denylist is consulted. + if (detected === 6 && !globallyRoutableIpv6.check(normalized, 'ipv6')) return false; + return !blockedAddresses.check(normalized, detected === 4 ? 'ipv4' : 'ipv6'); +} + +async function defaultResolver(hostname: string): Promise<ResolvedAddress[]> { + const results = await lookup(hostname, { all: true, verbatim: true }); + return results.flatMap((result): ResolvedAddress[] => + result.family === 4 || result.family === 6 + ? [{ address: result.address, family: result.family }] + : []); +} + +export async function resolvePublicHttpUrl( + rawUrl: string, + resolver: PublicHostResolver = defaultResolver, +): Promise<ResolvedPublicUrl> { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new UnsafeOutboundUrlError(); + } + if ((url.protocol !== 'http:' && url.protocol !== 'https:') + || url.username !== '' + || url.password !== '') { + throw new UnsafeOutboundUrlError(); + } + const expectedPort = url.protocol === 'https:' ? '443' : '80'; + if (url.port !== '' && url.port !== expectedPort) { + throw new UnsafeOutboundUrlError(); + } + + const hostname = normalizedHostname(url); + if (hostname === '' || hostnameIsBlocked(hostname)) { + throw new UnsafeOutboundUrlError(); + } + + const literalFamily = isIP(hostname); + const addresses: ResolvedAddress[] = literalFamily === 4 || literalFamily === 6 + ? [{ address: hostname, family: literalFamily }] + : await resolver(hostname); + if (addresses.length === 0 + || addresses.some((entry) => !isPublicAddress(entry.address, entry.family))) { + throw new UnsafeOutboundUrlError(); + } + + const preferred = addresses.find((entry) => entry.family === 4) ?? addresses[0]; + if (preferred === undefined) throw new UnsafeOutboundUrlError(); + return { ...preferred, url }; +} + +function boundRequest( + target: ResolvedPublicUrl, + options: Required<Pick<PublicTextFetchOptions, 'maxBytes' | 'timeoutMs'>> & { + headers: Readonly<Record<string, string>>; + }, +): Promise<BoundResponse> { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (result: BoundResponse): void => { + if (settled) return; + settled = true; + clearTimeout(deadline); + resolve(result); + }; + const fail = (error: unknown): void => { + if (settled) return; + settled = true; + clearTimeout(deadline); + reject(error); + }; + const transport = target.url.protocol === 'https:' ? https : http; + const request = transport.request({ + agent: false, + family: target.family, + headers: { + ...options.headers, + 'Accept-Encoding': 'identity', + Host: target.url.host, + }, + hostname: target.address, + method: 'GET', + path: `${target.url.pathname}${target.url.search}`, + port: target.url.protocol === 'https:' ? 443 : 80, + ...(target.url.protocol === 'https:' + ? { servername: normalizedHostname(target.url) } + : {}), + }, (response) => { + const chunks: Buffer[] = []; + let bytes = 0; + const declaredLength = Number(response.headers['content-length'] ?? '0'); + if (Number.isFinite(declaredLength) && declaredLength > options.maxBytes) { + response.destroy(); + fail(new UnsafeOutboundUrlError()); + return; + } + response.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > options.maxBytes) { + response.destroy(); + fail(new UnsafeOutboundUrlError()); + return; + } + chunks.push(buffer); + }); + response.on('end', () => { + finish({ + body: Buffer.concat(chunks), + headers: response.headers, + status: response.statusCode ?? 0, + }); + }); + response.on('error', fail); + }); + const deadline = setTimeout(() => { + request.destroy(new UnsafeOutboundUrlError()); + }, options.timeoutMs); + request.on('error', fail); + request.end(); + }); +} + +function firstHeader(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +const CROSS_ORIGIN_HEADER_ALLOWLIST = new Set([ + 'accept', + 'accept-language', + 'user-agent', +]); + +function headersAfterRedirect( + headers: Readonly<Record<string, string>>, + from: URL, + to: URL, +): Readonly<Record<string, string>> { + if (from.origin === to.origin) return headers; + return Object.fromEntries( + Object.entries(headers).filter(([name]) => ( + CROSS_ORIGIN_HEADER_ALLOWLIST.has(name.toLowerCase()) + )), + ); +} + +async function completeBeforeDeadline<T>( + operation: () => Promise<T>, + deadline: number, +): Promise<T> { + const remainingMs = Math.floor(deadline - performance.now()); + if (remainingMs < 1) throw new UnsafeOutboundUrlError(); + let timer: ReturnType<typeof setTimeout> | undefined; + try { + return await Promise.race([ + operation(), + new Promise<T>((_resolve, reject) => { + timer = setTimeout(() => reject(new UnsafeOutboundUrlError()), remainingMs); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +export async function fetchPublicText( + rawUrl: string, + options: PublicTextFetchOptions = {}, + dependencies: PublicTextFetchDependencies = {}, +): Promise<PublicTextResponse> { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 + || !Number.isSafeInteger(maxRedirects) || maxRedirects < 0 || maxRedirects > 10 + || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000) { + throw new RangeError('Invalid public text fetch limits'); + } + + const resolveHost = dependencies.resolve ?? defaultResolver; + const requestOnce = dependencies.request ?? boundRequest; + let current = rawUrl; + let currentHeaders: Readonly<Record<string, string>> = options.headers ?? {}; + const deadline = performance.now() + timeoutMs; + + for (let hop = 0; hop <= maxRedirects; hop += 1) { + const target = await completeBeforeDeadline( + () => resolvePublicHttpUrl(current, resolveHost), + deadline, + ); + const requestTimeoutMs = Math.max(1, Math.floor(deadline - performance.now())); + const response = await completeBeforeDeadline( + () => requestOnce(target, { + headers: currentHeaders, + maxBytes, + timeoutMs: requestTimeoutMs, + }), + deadline, + ); + if (response.body.length > maxBytes) throw new UnsafeOutboundUrlError(); + if ([301, 302, 303, 307, 308].includes(response.status)) { + const location = firstHeader(response.headers.location); + if (location === undefined || hop === maxRedirects) { + throw new UnsafeOutboundUrlError(); + } + const redirected = new URL(location, target.url); + if (target.url.protocol === 'https:' && redirected.protocol !== 'https:') { + throw new UnsafeOutboundUrlError(); + } + currentHeaders = headersAfterRedirect(currentHeaders, target.url, redirected); + current = redirected.toString(); + continue; + } + + const encoding = firstHeader(response.headers['content-encoding']); + if (encoding !== undefined && encoding.toLowerCase() !== 'identity') { + throw new UnsafeOutboundUrlError(); + } + const contentType = (firstHeader(response.headers['content-type']) ?? '') + .split(';', 1)[0] + ?.trim() + .toLowerCase() ?? ''; + const accepted = options.acceptedContentTypes ?? ['text/html', 'application/xhtml+xml', 'text/plain']; + if (!accepted.includes(contentType)) { + throw new UnsafeOutboundUrlError(); + } + return { + finalUrl: target.url.toString(), + headers: response.headers, + ok: response.status >= 200 && response.status < 300, + status: response.status, + text: response.body.toString('utf8'), + }; + } + + throw new UnsafeOutboundUrlError(); +} + +export function outboundTargetFingerprint(rawUrl: string): string { + let origin: string; + try { + origin = new URL(rawUrl).origin; + } catch { + origin = 'invalid'; + } + return createHmac('sha256', TARGET_FINGERPRINT_KEY) + .update(origin) + .digest('hex') + .slice(0, 16); +} diff --git a/src/synthesis.ts b/src/synthesis.ts index 4ff88e5..2444df0 100644 --- a/src/synthesis.ts +++ b/src/synthesis.ts @@ -12,8 +12,10 @@ import { GoogleGenAI } from '@google/genai'; import OpenAI from 'openai'; import { z } from 'zod'; import { logger } from './logger.js'; +import { getGeminiKey } from './pipeline-core.js'; import { recordGeminiUsage } from './cache-hit-measurement.js'; import { + canonicalizeUrl, SectionDraftSchema, FinalReportSchema, ProviderNameSchema, @@ -144,13 +146,7 @@ export async function synthesisGenerate(args: { const { system, user, model, maxTokens, telemetry } = args; if (model.startsWith('gemini-')) { - const apiKey = process.env.GEMINI_API_KEY; - if (apiKey === undefined || apiKey === '') { - throw new Error( - 'GEMINI_API_KEY environment variable is not configured — required for gemini-* models. ' - + 'Set it on the cortex-worker Fly.io app.', - ); - } + const apiKey = getGeminiKey(); const ai = new GoogleGenAI({ apiKey }); const response = await withTimeout( ai.models.generateContent({ @@ -717,6 +713,18 @@ export async function assembleFinalReport( }) .join('\n\n'); + // Validated comment/timestamp anchors intentionally differ from the fetched + // parent URL only by a fragment (or stripped tracking parameters). Make the + // parent search metadata available under that canonical identity so anchored + // citations do not lose their real provider/title/author provenance. + const sourceMetaByCanonical = new Map<string, NonNullable<typeof sourceMeta>[string]>(); + if (sourceMeta !== undefined) { + for (const [sourceUrl, metadata] of Object.entries(sourceMeta)) { + const canonical = canonicalizeUrl(sourceUrl); + if (!sourceMetaByCanonical.has(canonical)) sourceMetaByCanonical.set(canonical, metadata); + } + } + // Bibliography in global numeric order — bibliography[N-1] ⇔ [^N]. // Provenance (audit m3): fill provider/title/author from the search phase's // sourceMeta instead of hardcoding placeholders. Unknown providers fall back @@ -724,7 +732,9 @@ export async function assembleFinalReport( const bibliography = Array.from(urlToGlobal.entries()) .sort((a, b) => a[1] - b[1]) .map(([sourceUrl, num]) => { - const meta = sourceMeta !== undefined ? sourceMeta[sourceUrl] : undefined; + const meta = sourceMeta !== undefined + ? sourceMeta[sourceUrl] ?? sourceMetaByCanonical.get(canonicalizeUrl(sourceUrl)) + : undefined; const providerParse = ProviderNameSchema.safeParse( meta !== undefined && meta.provider !== undefined ? meta.provider : '', ); diff --git a/src/types.ts b/src/types.ts index ec7763b..f4bc287 100644 --- a/src/types.ts +++ b/src/types.ts @@ -445,3 +445,21 @@ export function canonicalizeUrl(url: string): string { return url; } } + +/** + * Preserve a comment/timestamp anchor only when it resolves to the same origin + * and canonical document as the fetched parent. Invalid, cross-origin, or + * different-document candidates fall back to the parent URL. + */ +export function resolveSameSourceCitationUrl(parentUrl: string, candidateUrl: string): string { + if (candidateUrl.length === 0) return parentUrl; + try { + const parent = new URL(parentUrl); + const resolved = new URL(candidateUrl, parent); + if (resolved.origin !== parent.origin) return parentUrl; + if (canonicalizeUrl(resolved.toString()) !== canonicalizeUrl(parent.toString())) return parentUrl; + return resolved.toString(); + } catch { + return parentUrl; + } +} diff --git a/test/gemini-extractor.test.ts b/test/gemini-extractor.test.ts new file mode 100644 index 0000000..7d61a58 --- /dev/null +++ b/test/gemini-extractor.test.ts @@ -0,0 +1,330 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { normalizeGeminiExtractionPayload } from '../src/providers/gemini-extractor.js'; +import { buildGroundingEvidenceChunks, validateGrounding } from '../src/grounding.js'; +import type { GeminiExtractInput } from '../src/providers/gemini-extractor.js'; + +const DEFAULT_SOURCE_CONTENT = [ + 'The source documents the behavior.', + 'Unknown labels do not destroy this quote.', + 'Mixed-case labels normalize safely.', + 'A valid claim survives.', + 'A second valid claim survives.', + 'A valid sibling survives.', + 'Malformed optional metadata is omitted, not fatal.', + 'A model-selected anchor cannot replace the long-form parent.', + 'A safe timestamp anchor is preserved.', + 'A safe relative timestamp anchor is preserved.', + 'A cross-origin URL is rejected.', + 'A different same-origin video is rejected.', +].join('\n'); + +function extractFixture( + payload: Record<string, unknown>, + input: Partial<GeminiExtractInput> = {}, +) { + return normalizeGeminiExtractionPayload( + payload, + { + provider: 'tavily', + url: 'https://docs.example/article', + content: DEFAULT_SOURCE_CONTENT, + mode: 'longform', + ...input, + }, + { promptTokenCount: 100, candidatesTokenCount: 50 }, + ); +} + +test('model-inferred quote metadata is discarded even when it looks well formed', () => { + const result = extractFixture({ + key_claims: ['The source documents the behavior.'], + verbatim_quotes: [ + { + quote: 'Unknown labels do not destroy this quote.', + source_url: 'https://docs.example/article', + sentiment: 'informational', + }, + { + quote: 'Mixed-case labels normalize safely.', + source_url: 'https://docs.example/article', + sentiment: ' PoSiTiVe ', + }, + ], + }); + + assert.equal(result.verbatim_quotes.length, 2); + assert.equal(result.verbatim_quotes[0]?.sentiment, undefined); + assert.equal(result.verbatim_quotes[1]?.sentiment, undefined); + assert.equal(result.verbatim_quotes[1]?.category, undefined); + assert.deepEqual(result.verbatim_quotes[1]?.engagement, {}); +}); + +test('one malformed quote or optional field cannot erase valid siblings and key claims', () => { + const result = extractFixture({ + key_claims: ['A valid claim survives.', 123, 'A second valid claim survives.'], + verbatim_quotes: [ + { + quote: 'A valid sibling survives.', + sentiment: 'neutral', + }, + { + quote: 123, + sentiment: 'positive', + }, + { + quote: 'Malformed optional metadata is omitted, not fatal.', + author: 42, + published_at: 'not-a-date', + engagement: 'not-an-object', + sentiment: { unexpected: true }, + category: false, + }, + ], + }); + + assert.deepEqual(result.key_claims, [ + 'A valid claim survives.', + 'A second valid claim survives.', + ]); + assert.deepEqual( + result.verbatim_quotes.map((quote) => quote.quote), + [ + 'A valid sibling survives.', + 'Malformed optional metadata is omitted, not fatal.', + ], + ); + assert.deepEqual(result.verbatim_quotes[1]?.engagement, {}); + assert.equal(result.verbatim_quotes[1]?.author, undefined); + assert.equal(result.verbatim_quotes[1]?.published_at, undefined); + assert.equal(result.verbatim_quotes[1]?.sentiment, undefined); + assert.equal(result.verbatim_quotes[1]?.category, undefined); +}); + +test('a fabricated quote and claim from an unrelated source cannot circularly pass grounding', () => { + const sourceUrl = 'https://docs.example/article'; + const fabricatedClaim = 'The platform guarantees a 91% reduction in checkout failures.'; + const extracted = extractFixture({ + key_claims: [fabricatedClaim], + verbatim_quotes: [{ + quote: fabricatedClaim, + source_url: sourceUrl, + sentiment: 'positive', + }], + }, { + url: sourceUrl, + content: 'This source discusses orchard irrigation schedules and soil moisture.', + }); + + assert.deepEqual(extracted.key_claims, []); + assert.deepEqual(extracted.verbatim_quotes, []); + + const chunks = buildGroundingEvidenceChunks({ + sourceQuotesByUrl: { [sourceUrl]: extracted.verbatim_quotes }, + claimsBySection: { + 'A.1': extracted.key_claims.map((claim) => ({ + claim, + source_url: sourceUrl, + provider: 'tavily', + })), + }, + }); + const grounding = validateGrounding({ + fullMarkdown: `${fabricatedClaim} [^1].`, + bibliography: [{ + citation_anchor: '[^1]', + source_url: sourceUrl, + title: 'Unrelated source', + author: '', + provider: 'tavily', + }], + chunks, + }); + + assert.equal(chunks.length, 0); + assert.notEqual(grounding.quality, 'strong'); + assert.equal(grounding.passed, false); +}); + +test('a model quote cannot splice sentences or omit a source qualifier', () => { + const result = extractFixture({ + verbatim_quotes: [ + { quote: 'The product is safe Children died during independent clinical testing.' }, + { quote: 'Evidence shows the product is safe for unsupervised use.' }, + ], + }, { + content: [ + 'The product is safe. Children died during independent clinical testing.', + 'Preliminary evidence shows the product is safe for unsupervised use.', + ].join('\n'), + }); + + assert.deepEqual(result.verbatim_quotes, []); +}); + +test('verbatim validation preserves punctuation that changes meaning', () => { + const result = extractFixture({ + verbatim_quotes: [ + { quote: "Let's eat Grandma." }, + { quote: 'No refunds are allowed.' }, + ], + }, { + content: "Let's eat, Grandma. No, refunds are allowed.", + }); + + assert.deepEqual(result.verbatim_quotes, []); +}); + +test('valid verbatim quotes survive Unicode punctuation and whitespace normalization', () => { + const quote = `The caf\u00e9's "night ritual" uses hand-forged silver.`; + const result = extractFixture({ + verbatim_quotes: [{ quote }], + }, { + content: 'The cafe\u0301\u2019s \u201cnight ritual\u201d\u00a0uses hand\u2011forged silver.', + }); + + assert.deepEqual(result.verbatim_quotes.map((entry) => entry.quote), [quote]); +}); + +test('complete source sentences survive fail-closed fetched-content validation', () => { + const result = extractFixture({ + key_claims: [ + 'The city council unanimously approved the accessibility rules as an international standard.', + ], + }, { + content: 'The city council unanimously approved the accessibility rules as an international standard.', + }); + + assert.deepEqual(result.key_claims, [ + 'The city council unanimously approved the accessibility rules as an international standard.', + ]); +}); + +test('source validation preserves numeric units instead of treating punctuation as decoration', () => { + const result = extractFixture({ + key_claims: ['Checkout failures fell by 91%.'], + verbatim_quotes: [{ quote: 'Checkout failures fell by 91%.' }], + }, { + content: 'Checkout failures fell by 91 incidents during the pilot.', + }); + + assert.deepEqual(result.key_claims, []); + assert.deepEqual(result.verbatim_quotes, []); +}); + +test('source-near overlap cannot hide a reversed predicate or negation', () => { + const result = extractFixture({ + key_claims: [ + 'The council rejected the accessibility rules as an international standard.', + 'The council did not approve the accessibility rules as an international standard.', + ], + }, { + content: 'The council approved the accessibility rules as an international standard.', + }); + + assert.deepEqual(result.key_claims, []); +}); + +test('long overlap cannot hide swapped entity roles or one changed predicate', () => { + const result = extractFixture({ + key_claims: [ + 'Bob sold the rare ceremonial artifact to Alice in London yesterday after a private appraisal.', + 'The council rejected the comprehensive accessibility standard after extensive independent review by five experts.', + 'Treatment B outperformed Treatment A in every measured cohort during the controlled clinical evaluation.', + ], + }, { + content: [ + 'Alice sold the rare ceremonial artifact to Bob in London yesterday after a private appraisal.', + 'The council approved the comprehensive accessibility standard after extensive independent review by five experts.', + 'Treatment A outperformed Treatment B in every measured cohort during the controlled clinical evaluation.', + ].join('\n'), + }); + + assert.deepEqual(result.key_claims, []); +}); + +test('source overlap cannot erase modality or quantifier differences', () => { + const result = extractFixture({ + key_claims: [ + 'The treatment will reduce severe symptoms during monitored recovery.', + 'All patients experienced durable remission during the extended clinical follow-up.', + 'The product is safe for unsupervised daily use by adolescents.', + 'The policy applies to all international contractors in regulated markets.', + ], + }, { + content: [ + 'The treatment may reduce severe symptoms during monitored recovery.', + 'Some patients experienced durable remission during the extended clinical follow-up.', + 'The product may be safe for unsupervised daily use by adolescents.', + 'The policy applies only to some international contractors in regulated markets.', + ].join('\n'), + }); + + assert.deepEqual(result.key_claims, []); +}); + +test('omitted qualifiers cannot turn a source fragment into a verified key claim', () => { + const result = extractFixture({ + key_claims: [ + 'Evidence shows the product is safe for unsupervised daily use.', + 'The report confirms the product is safe for unsupervised daily use.', + 'The system prevents leakage during ordinary operation.', + ], + }, { + content: [ + 'Preliminary evidence shows the product is safe for unsupervised daily use.', + 'The unverified report confirms the product is safe for unsupervised daily use.', + 'The system reportedly prevents leakage during ordinary operation.', + ].join('\n'), + }); + + assert.deepEqual(result.key_claims, []); +}); + +test('model-selected citation anchors are always bound to the fetched parent URL', () => { + const parentUrl = 'https://docs.example/article'; + const result = extractFixture({ + verbatim_quotes: [{ + quote: 'A model-selected anchor cannot replace the long-form parent.', + source_url: `${parentUrl}#details`, + }], + }, { url: parentUrl, mode: 'longform' }); + + assert.equal(result.verbatim_quotes[0]?.source_url, parentUrl); +}); + +test('social and video model-selected anchors cannot override the fetched parent', () => { + const parentUrl = 'https://www.youtube.com/watch?v=abc123&utm_source=test'; + const result = extractFixture({ + verbatim_quotes: [ + { + quote: 'A safe timestamp anchor is preserved.', + source_url: 'https://www.youtube.com/watch?v=abc123#t=42', + }, + { + quote: 'A safe relative timestamp anchor is preserved.', + source_url: '#t=84', + }, + { + quote: 'A cross-origin URL is rejected.', + source_url: 'https://attacker.example/watch?v=abc123#t=42', + }, + { + quote: 'A different same-origin video is rejected.', + source_url: 'https://www.youtube.com/watch?v=other#t=42', + }, + ], + }, { provider: 'youtube', url: parentUrl, mode: 'youtube' }); + + assert.equal( + result.verbatim_quotes[0]?.source_url, + parentUrl, + ); + assert.equal( + result.verbatim_quotes[1]?.source_url, + parentUrl, + ); + assert.equal(result.verbatim_quotes[2]?.source_url, parentUrl); + assert.equal(result.verbatim_quotes[3]?.source_url, parentUrl); +}); diff --git a/test/grounding-quality.test.ts b/test/grounding-quality.test.ts index 0dcc4a7..7d8b17e 100644 --- a/test/grounding-quality.test.ts +++ b/test/grounding-quality.test.ts @@ -95,12 +95,61 @@ test('grounding evidence mirrors the quotes and source-bound key claims offered ); }); -test('a conservative paraphrase of a source-bound key claim validates without lowering the threshold', () => { +test('grounding preserves a validated anchored quote URL instead of its parent map key', () => { + const parentUrl = 'https://www.youtube.com/watch?v=abc123'; + const anchoredUrl = `${parentUrl}#t=42`; + const claim = 'The speaker describes a durable workflow boundary'; + const chunks = buildGroundingEvidenceChunks({ + sourceQuotesByUrl: { + [parentUrl]: [{ + quote: claim, + context: '', + source_url: anchoredUrl, + engagement: {}, + }], + }, + claimsBySection: {}, + }); + + assert.equal(chunks[0]?.source_url, anchoredUrl); + const result = validateGrounding({ + fullMarkdown: `${claim} [^1].`, + bibliography: [{ + citation_anchor: '[^1]', + source_url: anchoredUrl, + title: 'Video source', + author: '', + provider: 'youtube', + }], + chunks, + }); + assert.equal(result.ratio, 1); + assert.equal(result.passed, true); +}); + +test('grounding falls back to the fetched parent for a cross-source quote URL', () => { + const parentUrl = 'https://docs.example/source'; + const chunks = buildGroundingEvidenceChunks({ + sourceQuotesByUrl: { + [parentUrl]: [{ + quote: 'Evidence remains attached to the page that was fetched.', + context: '', + source_url: 'https://attacker.example/redirected-evidence', + engagement: {}, + }], + }, + claimsBySection: {}, + }); + + assert.equal(chunks[0]?.source_url, parentUrl); +}); + +test('a complete source-bound key claim validates', () => { const chunks = buildGroundingEvidenceChunks({ sourceQuotesByUrl: {}, claimsBySection: { 'A.1': [{ - claim: 'WCAG 2.2 was approved as ISO/IEC 40500:2025, an international standard.', + claim: 'The international standards body formally recognizes WCAG 2.2 under ISO/IEC 40500:2025.', source_url: 'https://docs.example/source', provider: 'searxng', }], @@ -117,6 +166,43 @@ test('a conservative paraphrase of a source-bound key claim validates without lo assert.equal(result.passed, true); }); +test('tiny reverse evidence cannot validate a much larger fabricated claim', () => { + const result = validateGrounding({ + fullMarkdown: 'The FDA approved a miracle cancer cure with no side effects [^1].', + bibliography, + chunks: [quoteChunk('approved')], + }); + + assert.equal(result.ratio, 0); + assert.equal(result.quality, 'weak'); + assert.equal(result.passed, false); +}); + +test('role and predicate reversals cannot validate through shared vocabulary', () => { + for (const [claim, evidence] of [ + [ + 'Bob sold the rare ceremonial artifact to Alice in London yesterday after a private appraisal', + 'Alice sold the rare ceremonial artifact to Bob in London yesterday after a private appraisal', + ], + [ + 'The council rejected the comprehensive accessibility standard after extensive independent review by five experts', + 'The council approved the comprehensive accessibility standard after extensive independent review by five experts', + ], + [ + 'Treatment B outperformed Treatment A in every measured cohort during the controlled clinical evaluation', + 'Treatment A outperformed Treatment B in every measured cohort during the controlled clinical evaluation', + ], + ]) { + const result = validateGrounding({ + fullMarkdown: `${claim} [^1].`, + bibliography, + chunks: [quoteChunk(evidence)], + }); + assert.equal(result.ratio, 0, claim); + assert.equal(result.passed, false, claim); + } +}); + test('lexically similar evidence cannot validate a changed number', () => { const result = validateGrounding({ fullMarkdown: 'Industry research found that AI adoption among designers reached 81 percent [^1].', @@ -160,8 +246,8 @@ test('source-near wording for the canary evidence is verifiable with its exact s 'answer that is completely wrong for your domain.'; const result = validateGrounding({ fullMarkdown: - 'Traditional software has well-defined pass/fail criteria, while AI systems have none by default; ' + - 'an LLM can return a 200 response and still hallucinate or contradict its own context [^1].', + 'An LLM can return a 200 response in under a second and still hallucinate, contradict its own context, ' + + 'leak PII, or give a technically correct answer that is completely wrong for your domain [^1].', bibliography, chunks: [quoteChunk(evidence)], }); diff --git a/test/logging-security.test.ts b/test/logging-security.test.ts new file mode 100644 index 0000000..c43a473 --- /dev/null +++ b/test/logging-security.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { sanitizePipelineUsageMetadata } from '../src/index.js'; +import { + createSanitizedLogger, + type Logger, +} from '../src/logger.js'; + +test('sanitized logger never forwards raw briefs, queries, URLs, bodies, or errors', () => { + const calls: unknown[][] = []; + const sink: Logger = { + info: (obj, msg) => calls.push([obj, msg]), + warn: (obj, msg) => calls.push([obj, msg]), + error: (obj, msg) => calls.push([obj, msg]), + debug: (obj, msg) => calls.push([obj, msg]), + }; + const logger = createSanitizedLogger(sink); + logger.warn( + { + brief: 'private Burning Man shopping preferences', + query: 'private user query', + url: 'https://example.com/path?token=top-secret', + body: 'private provider response', + error: 'request failed at https://api.test/?key=top-secret', + providerName: 'tavily', + count: 4, + }, + '[test] Provider failed', + ); + logger.error('raw private payload that must not be emitted'); + + const serialized = JSON.stringify(calls); + for (const secret of [ + 'private Burning Man', + 'private user query', + 'top-secret', + 'private provider response', + 'raw private payload', + ]) { + assert.equal(serialized.includes(secret), false, secret); + } + assert.equal(serialized.includes('tavily'), true); + assert.equal(serialized.includes('[test] Provider failed'), false); + assert.equal(serialized.includes('[research] event'), true); +}); + +test('sanitized logger does not trust a known message prefix or safe-looking value field', () => { + const calls: unknown[][] = []; + const sink: Logger = { + info: (obj, msg) => calls.push([obj, msg]), + warn: (obj, msg) => calls.push([obj, msg]), + error: (obj, msg) => calls.push([obj, msg]), + debug: (obj, msg) => calls.push([obj, msg]), + }; + const logger = createSanitizedLogger(sink); + logger.info( + { + category: 'secret-acquisition-target', + operation: 'private user taste profile', + provider: 'api_key_SECRET', + query: 'secret', + secret_number: 8675309, + status: 'customer-medical-status', + 'private user query': true, + error: { 'secret-key-material': 7 }, + }, + '[Tavily] private user taste profile', + ); + + const serialized = JSON.stringify(calls); + assert.equal(serialized.includes('private user taste profile'), false); + assert.equal(serialized.includes('api_key_SECRET'), false); + assert.equal(serialized.includes('customer-medical-status'), false); + assert.equal(serialized.includes('secret-acquisition-target'), false); + assert.equal(serialized.includes('8675309'), false); + assert.equal(serialized.includes('private user query'), false); + assert.equal(serialized.includes('secret-key-material'), false); + assert.equal(serialized.includes('[Tavily] event'), true); +}); + +test('usage metadata is an allowlisted telemetry contract', () => { + const metadata = sanitizePipelineUsageMetadata({ + model: 'gemini-3.5-flash-lite', + operation: 'extract-input', + results: 8, + source_mode: 'web', + query: 'private brief-derived query', + url: 'https://example.com/?signature=private', + body: 'provider response', + nested: { prompt: 'private' }, + secret_number: 8675309, + }); + assert.deepEqual(metadata, { + model: 'gemini-3.5-flash-lite', + operation: 'extract-input', + results: 8, + source_mode: 'web', + }); + + assert.deepEqual(sanitizePipelineUsageMetadata({ + model: 'gemini-private_customer_name', + operation: 'private-user-segment', + source_mode: 'private-customer-name', + sort: 'private-query', + }), {}); +}); diff --git a/test/pipeline-core-gemini.test.ts b/test/pipeline-core-gemini.test.ts new file mode 100644 index 0000000..6a7baa3 --- /dev/null +++ b/test/pipeline-core-gemini.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getGeminiKey } from '../src/pipeline-core.js'; + +const geminiKeyNames = [ + 'GEMINI_RESEARCH_KEY', + 'GOOGLE_GEMINI_API_KEY', + 'GOOGLE_GENERATIVE_AI_API_KEY', + 'GEMINI_API_KEY', +] as const; +const geminiSelectorName = 'BRAINTIED_GEMINI_KEY_NAME' as const; +const geminiEnvironmentNames = [...geminiKeyNames, geminiSelectorName] as const; + +function withGeminiEnvironment( + values: Partial<Record<(typeof geminiEnvironmentNames)[number], string>>, + callback: () => void, +): void { + const previous = new Map(geminiEnvironmentNames.map((name) => [name, process.env[name]])); + try { + for (const name of geminiEnvironmentNames) delete process.env[name]; + for (const [name, value] of Object.entries(values)) process.env[name] = value; + callback(); + } finally { + for (const name of geminiEnvironmentNames) { + const value = previous.get(name); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } +} + +test('shared Gemini resolver accepts the Google Gemini alias', () => { + withGeminiEnvironment({ GOOGLE_GEMINI_API_KEY: 'google-gemini-test-key' }, () => { + assert.equal(getGeminiKey(), 'google-gemini-test-key'); + }); +}); + +test('shared Gemini resolver fails on conflicting aliases without an explicit selector', () => { + withGeminiEnvironment({ + GOOGLE_GEMINI_API_KEY: 'working-google-gemini-test-key', + GOOGLE_GENERATIVE_AI_API_KEY: 'legacy-google-generative-test-key', + }, () => { + assert.throws(() => getGeminiKey(), /Conflicting Gemini aliases/); + }); +}); + +test('shared Gemini resolver honors an explicit alias selector', () => { + withGeminiEnvironment({ + BRAINTIED_GEMINI_KEY_NAME: 'GOOGLE_GEMINI_API_KEY', + GOOGLE_GEMINI_API_KEY: 'working-google-gemini-test-key', + GOOGLE_GENERATIVE_AI_API_KEY: 'legacy-google-generative-test-key', + }, () => { + assert.equal(getGeminiKey(), 'working-google-gemini-test-key'); + }); +}); diff --git a/test/pipeline-security.test.ts b/test/pipeline-security.test.ts new file mode 100644 index 0000000..fa590db --- /dev/null +++ b/test/pipeline-security.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + crawlWithCrawl4AI, + fetchWithRetry, +} from '../src/pipeline-core.js'; + +test('retryable responses are cancelled before the next request', async () => { + const originalFetch = globalThis.fetch; + let requests = 0; + let cancellations = 0; + globalThis.fetch = (async () => { + requests += 1; + const stream = new ReadableStream<Uint8Array>({ + cancel() { + cancellations += 1; + }, + }); + return new Response(stream, { status: 503 }); + }) as typeof fetch; + + try { + await assert.rejects( + fetchWithRetry('https://fixed-provider.example/api', {}, 2, 0), + /Request failed after 2 retries/, + ); + assert.equal(requests, 2); + assert.equal(cancellations, 2); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('external browser crawling is disabled before any network request by default', async () => { + const originalFetch = globalThis.fetch; + const previousGuard = process.env.BRAINTIED_CRAWL4AI_NETWORK_GUARD; + let requests = 0; + delete process.env.BRAINTIED_CRAWL4AI_NETWORK_GUARD; + globalThis.fetch = (async () => { + requests += 1; + throw new Error('unexpected request'); + }) as typeof fetch; + + try { + assert.equal(await crawlWithCrawl4AI('https://example.com/'), null); + assert.equal(requests, 0); + } finally { + globalThis.fetch = originalFetch; + if (previousGuard === undefined) delete process.env.BRAINTIED_CRAWL4AI_NETWORK_GUARD; + else process.env.BRAINTIED_CRAWL4AI_NETWORK_GUARD = previousGuard; + } +}); diff --git a/test/public-http-security.test.ts b/test/public-http-security.test.ts new file mode 100644 index 0000000..8f882f8 --- /dev/null +++ b/test/public-http-security.test.ts @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + fetchPublicText, + isPublicAddress, + readBoundedWebResponseText, + resolvePublicHttpUrl, + UnsafeOutboundUrlError, + type PublicHostResolver, + type ResolvedPublicUrl, +} from '../src/public-http.js'; + +const publicResolver: PublicHostResolver = async () => [ + { address: '93.184.216.34', family: 4 }, +]; + +test('public-address policy blocks local, private, link-local, mapped, and reserved ranges', () => { + for (const address of [ + '0.0.0.0', + '10.0.0.1', + '100.64.0.1', + '127.0.0.1', + '169.254.169.254', + '172.16.0.1', + '192.168.0.1', + '198.18.0.1', + '224.0.0.1', + '::', + '::1', + '::ffff:127.0.0.1', + '::ffff:0:10.0.0.1', + '::ffff:0:127.0.0.1', + '3fff::1', + '4000::1', + '5f00::1', + '64:ff9b::a00:1', + '64:ff9b::7f00:1', + '8000::1', + 'fc00::1', + 'fec0::1', + 'fe80::1', + 'ff02::1', + '2001:db8::1', + ]) { + assert.equal(isPublicAddress(address), false, address); + } + assert.equal(isPublicAddress('93.184.216.34'), true); + assert.equal(isPublicAddress('2606:4700:4700::1111'), true); +}); + +test('bounded web-response reads reject declared and streamed overflow', async () => { + await assert.rejects( + readBoundedWebResponseText(new Response('small', { + headers: { 'content-length': '1000' }, + }), 10), + UnsafeOutboundUrlError, + ); + await assert.rejects( + readBoundedWebResponseText(new Response('x'.repeat(11)), 10), + UnsafeOutboundUrlError, + ); + assert.equal( + await readBoundedWebResponseText(new Response('bounded'), 10), + 'bounded', + ); +}); + +test('URL resolution rejects unsafe schemes, credentials, ports, names, and DNS answers', async () => { + for (const url of [ + 'file:///etc/passwd', + 'ftp://example.com/file', + 'https://user:password@example.com/', + 'https://example.com:8443/', + 'http://localhost/', + 'http://ora-auth.internal/', + 'http://169.254.169.254/latest/meta-data', + 'http://[::1]/', + ]) { + await assert.rejects(resolvePublicHttpUrl(url, publicResolver), UnsafeOutboundUrlError); + } + + await assert.rejects( + resolvePublicHttpUrl('https://rebind.example/', async () => [ + { address: '93.184.216.34', family: 4 }, + { address: '10.0.0.8', family: 4 }, + ]), + UnsafeOutboundUrlError, + ); +}); + +test('fetch pins the validated address and blocks a private redirect before a second request', async () => { + const requested: ResolvedPublicUrl[] = []; + await assert.rejects( + fetchPublicText( + 'https://public.example/start', + {}, + { + resolve: publicResolver, + request: async (target) => { + requested.push(target); + return { + body: Buffer.alloc(0), + headers: { location: 'http://127.0.0.1/private' }, + status: 302, + }; + }, + }, + ), + UnsafeOutboundUrlError, + ); + assert.equal(requested.length, 1); + assert.equal(requested[0]?.address, '93.184.216.34'); +}); + +test('fetch revalidates every redirect against fresh DNS answers', async () => { + let resolution = 0; + let requests = 0; + await assert.rejects( + fetchPublicText( + 'https://rebind.example/start', + {}, + { + resolve: async () => { + resolution += 1; + return resolution === 1 + ? [{ address: '93.184.216.34', family: 4 }] + : [{ address: '10.0.0.8', family: 4 }]; + }, + request: async () => { + requests += 1; + return { + body: Buffer.alloc(0), + headers: { location: '/next' }, + status: 302, + }; + }, + }, + ), + UnsafeOutboundUrlError, + ); + assert.equal(resolution, 2); + assert.equal(requests, 1); +}); + +test('cross-origin redirects cannot forward credentials or arbitrary headers', async () => { + const observedHeaders: Array<Readonly<Record<string, string>>> = []; + let requestNumber = 0; + const response = await fetchPublicText( + 'https://a.example/start', + { + headers: { + Accept: 'text/plain', + Authorization: 'Bearer private-token', + Cookie: 'session=private', + 'User-Agent': 'ResearchTest/1.0', + 'X-Api-Key': 'private-key', + }, + }, + { + resolve: publicResolver, + request: async (_target, requestOptions) => { + observedHeaders.push(requestOptions.headers); + requestNumber += 1; + if (requestNumber === 1) { + return { + body: Buffer.alloc(0), + headers: { location: 'https://b.example/final' }, + status: 302, + }; + } + return { + body: Buffer.from('bounded public response'), + headers: { 'content-type': 'text/plain' }, + status: 200, + }; + }, + }, + ); + + assert.equal(response.ok, true); + assert.equal(observedHeaders.length, 2); + assert.equal(observedHeaders[0]?.Authorization, 'Bearer private-token'); + assert.deepEqual(observedHeaders[1], { + Accept: 'text/plain', + 'User-Agent': 'ResearchTest/1.0', + }); +}); + +test('one monotonic deadline covers DNS and every redirect hop', async () => { + await assert.rejects( + fetchPublicText('https://slow-dns.example/', { timeoutMs: 10 }, { + resolve: async () => new Promise((resolve) => { + setTimeout(() => resolve([{ address: '93.184.216.34', family: 4 }]), 50); + }), + }), + UnsafeOutboundUrlError, + ); + + let requests = 0; + await assert.rejects( + fetchPublicText('https://redirect.example/start', { timeoutMs: 25 }, { + resolve: publicResolver, + request: async () => { + requests += 1; + await new Promise((resolve) => setTimeout(resolve, 15)); + return { + body: Buffer.alloc(0), + headers: { location: `/hop-${requests}` }, + status: 302, + }; + }, + }), + UnsafeOutboundUrlError, + ); + assert.ok(requests < 5); +}); + +test('fetch enforces response bytes, content type, encoding, and redirect downgrade', async () => { + const baseDependencies = { + resolve: publicResolver, + }; + await assert.rejects( + fetchPublicText('https://public.example/', { maxBytes: 10 }, { + ...baseDependencies, + request: async () => ({ + body: Buffer.alloc(11), + headers: { 'content-type': 'text/html' }, + status: 200, + }), + }), + UnsafeOutboundUrlError, + ); + await assert.rejects( + fetchPublicText('https://public.example/', {}, { + ...baseDependencies, + request: async () => ({ + body: Buffer.from('{}'), + headers: { 'content-type': 'application/octet-stream' }, + status: 200, + }), + }), + UnsafeOutboundUrlError, + ); + await assert.rejects( + fetchPublicText('https://public.example/', {}, { + ...baseDependencies, + request: async () => ({ + body: Buffer.from('<p>compressed</p>'), + headers: { 'content-encoding': 'gzip', 'content-type': 'text/html' }, + status: 200, + }), + }), + UnsafeOutboundUrlError, + ); + await assert.rejects( + fetchPublicText('https://public.example/', {}, { + ...baseDependencies, + request: async () => ({ + body: Buffer.alloc(0), + headers: { location: 'http://public.example/' }, + status: 302, + }), + }), + UnsafeOutboundUrlError, + ); +}); + +test('fetch returns bounded text for an approved pinned response', async () => { + const body = '<html><body>' + 'public research '.repeat(30) + '</body></html>'; + const response = await fetchPublicText('https://public.example/article', {}, { + resolve: publicResolver, + request: async (target) => { + assert.equal(target.address, '93.184.216.34'); + return { + body: Buffer.from(body), + headers: { 'content-type': 'text/html; charset=utf-8' }, + status: 200, + }; + }, + }); + assert.equal(response.ok, true); + assert.equal(response.text, body); +}); diff --git a/test/reddit-security.test.ts b/test/reddit-security.test.ts new file mode 100644 index 0000000..ae8840e --- /dev/null +++ b/test/reddit-security.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { redditProvider } from '../src/providers/reddit.js'; + +test('Reddit fetch rejects non-canonical targets before loading credentials', async () => { + const previousId = process.env.REDDIT_CLIENT_ID; + const previousSecret = process.env.REDDIT_CLIENT_SECRET; + const previousAgent = process.env.REDDIT_USER_AGENT; + delete process.env.REDDIT_CLIENT_ID; + delete process.env.REDDIT_CLIENT_SECRET; + delete process.env.REDDIT_USER_AGENT; + + try { + for (const url of [ + 'https://evil.example/r/research/comments/abc', + 'https://reddit.com.evil.example/r/research/comments/abc', + 'https://reddit.com@evil.example/r/research/comments/abc', + 'http://www.reddit.com/r/research/comments/abc', + 'https://www.reddit.com:8443/r/research/comments/abc', + ]) { + const result = await redditProvider.fetch(url); + assert.equal(result.fetch_status, 'failed'); + assert.match(result.fetch_error ?? '', /canonical Reddit HTTPS URL/); + } + } finally { + if (previousId === undefined) delete process.env.REDDIT_CLIENT_ID; + else process.env.REDDIT_CLIENT_ID = previousId; + if (previousSecret === undefined) delete process.env.REDDIT_CLIENT_SECRET; + else process.env.REDDIT_CLIENT_SECRET = previousSecret; + if (previousAgent === undefined) delete process.env.REDDIT_USER_AGENT; + else process.env.REDDIT_USER_AGENT = previousAgent; + } +}); diff --git a/test/runner-preflight.test.ts b/test/runner-preflight.test.ts index b266eae..6a661b6 100644 --- a/test/runner-preflight.test.ts +++ b/test/runner-preflight.test.ts @@ -1,17 +1,60 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import { parseAllowlistedEnvFile } from '../skills/run-braintied-research/scripts/research-env-file.mjs'; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const runner = path.join(packageRoot, 'skills/run-braintied-research/scripts/run-research.mjs'); +const geminiKeyNames = [ + 'GEMINI_RESEARCH_KEY', + 'GOOGLE_GEMINI_API_KEY', + 'GOOGLE_GENERATIVE_AI_API_KEY', + 'GEMINI_API_KEY', +]; -test('Gemini-backed standard research does not require Anthropic or Voyage', () => { +function cleanRunnerEnvironment(): NodeJS.ProcessEnv { const env = { ...process.env }; - delete env.ANTHROPIC_API_KEY; - delete env.VOYAGE_API_KEY; - delete env.GEMINI_RESEARCH_KEY; + for (const name of [ + ...geminiKeyNames, + 'BRAINTIED_GEMINI_KEY_NAME', + 'BRAINTIED_RESEARCH_ENV_FILE', + 'TAVILY_API_KEY', + 'SERPER_API_KEY', + 'ANTHROPIC_API_KEY', + 'VOYAGE_API_KEY', + ]) { + delete env[name]; + } + return env; +} + +test('dotenv parser returns only allowlisted names using the documented single-line grammar', () => { + const parsed = parseAllowlistedEnvFile([ + 'export TAVILY_API_KEY=plain-value # comment', + 'SERPER_API_KEY=`value#inside-backticks`', + 'GEMINI_API_KEY="line-one\\nline-two"', + 'UNRELATED_PRIVATE_KEY=must-not-load', + '', + ].join('\n'), '/test/research.env', new Set([ + 'TAVILY_API_KEY', + 'SERPER_API_KEY', + 'GEMINI_API_KEY', + ])); + + assert.deepEqual([...parsed.entries()], [ + ['TAVILY_API_KEY', 'plain-value'], + ['SERPER_API_KEY', 'value#inside-backticks'], + ['GEMINI_API_KEY', 'line-one\nline-two'], + ]); + assert.equal(parsed.has('UNRELATED_PRIVATE_KEY'), false); +}); + +test('Gemini-backed standard research does not require Anthropic or Voyage', () => { + const env = cleanRunnerEnvironment(); env.GEMINI_API_KEY = 'test-only-key'; env.SEARXNG_URLS = 'https://search.example'; @@ -30,3 +73,326 @@ test('Gemini-backed standard research does not require Anthropic or Voyage', () assert.ok(preflight.warnings.some((warning) => warning.includes('critique'))); assert.ok(preflight.warnings.some((warning) => warning.includes('reranking'))); }); + +test('secure env file overrides stale inherited research settings and ignores unrelated names', () => { + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), 'braintied-research-env-')); + const envFile = path.join(temporaryDirectory, 'research.env'); + try { + writeFileSync(envFile, [ + 'GEMINI_RESEARCH_KEY="file-gemini-key"', + 'TAVILY_API_KEY=file-tavily-key', + 'UNRELATED_PRIVATE_KEY=must-not-load', + '', + ].join('\n'), { mode: 0o600 }); + chmodSync(envFile, 0o600); + + const env = cleanRunnerEnvironment(); + env.GEMINI_RESEARCH_KEY = 'stale-inherited-key'; + env.TAVILY_API_KEY = ''; + delete env.ANTHROPIC_API_KEY; + delete env.VOYAGE_API_KEY; + + const result = spawnSync(process.execPath, [ + runner, + '--check', + '--kind', 'standard', + '--max-cost-usd', '1', + '--synthesis-model', 'gemini-3-flash-preview', + '--sources', 'web', + '--require-providers', 'tavily', + '--as-of', '2026-07-23', + '--research-env-file', envFile, + ], { cwd: packageRoot, env, encoding: 'utf8' }); + + assert.equal(result.status, 0, result.stderr); + const preflight = JSON.parse(result.stdout) as { + ready: boolean; + configured_key_names: string[]; + runtime_environment: { + source: string; + loaded_names: string[]; + overridden_names: string[]; + env_files: string[]; + resolved_gemini_key_name: string; + }; + }; + assert.equal(preflight.ready, true); + assert.equal(preflight.runtime_environment.source, 'env-file'); + assert.deepEqual(preflight.runtime_environment.env_files, [envFile]); + assert.ok(preflight.runtime_environment.loaded_names.includes('GEMINI_RESEARCH_KEY')); + assert.ok(preflight.runtime_environment.loaded_names.includes('TAVILY_API_KEY')); + assert.ok(preflight.runtime_environment.overridden_names.includes('GEMINI_RESEARCH_KEY')); + assert.ok(preflight.runtime_environment.overridden_names.includes('TAVILY_API_KEY')); + assert.equal(preflight.runtime_environment.resolved_gemini_key_name, 'GEMINI_RESEARCH_KEY'); + assert.ok(!preflight.configured_key_names.includes('UNRELATED_PRIVATE_KEY')); + } finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +test('BRAINTIED_RESEARCH_ENV_FILE makes the secure allowlisted source portable across workspaces', () => { + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), 'braintied-research-shared-env-')); + const envFile = path.join(temporaryDirectory, 'research.env'); + try { + writeFileSync(envFile, [ + 'GOOGLE_GENERATIVE_AI_API_KEY=portable-gemini-key', + 'BRAINTIED_GEMINI_KEY_NAME=GOOGLE_GENERATIVE_AI_API_KEY', + 'SERPER_API_KEY=portable-serper-key', + '', + ].join('\n'), { mode: 0o600 }); + chmodSync(envFile, 0o600); + + const env = cleanRunnerEnvironment(); + env.BRAINTIED_RESEARCH_ENV_FILE = envFile; + env.GEMINI_RESEARCH_KEY = 'stale-inherited-key'; + + const result = spawnSync(process.execPath, [ + runner, + '--check', + '--kind', 'quick', + '--max-cost-usd', '1', + '--synthesis-model', 'gemini-3-flash-preview', + ], { cwd: packageRoot, env, encoding: 'utf8' }); + + assert.equal(result.status, 0, result.stderr); + const preflight = JSON.parse(result.stdout) as { + ready: boolean; + enabled_search_providers: string[]; + runtime_environment: { + source: string; + env_files: string[]; + resolved_gemini_key_name: string; + }; + }; + assert.equal(preflight.ready, true); + assert.ok(preflight.enabled_search_providers.includes('serper')); + assert.equal(preflight.runtime_environment.source, 'env-file'); + assert.deepEqual(preflight.runtime_environment.env_files, [envFile]); + assert.equal( + preflight.runtime_environment.resolved_gemini_key_name, + 'GOOGLE_GENERATIVE_AI_API_KEY', + ); + } finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +test('every env file must be owner-only, even when recognized entries are not secrets', () => { + if (process.platform === 'win32') return; + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), 'braintied-research-insecure-env-')); + const envFile = path.join(temporaryDirectory, 'research.env'); + try { + writeFileSync(envFile, [ + 'SEARXNG_URLS=https://search.example', + 'DATABASE_URL=must-never-load', + '', + ].join('\n'), { mode: 0o644 }); + chmodSync(envFile, 0o644); + + const result = spawnSync(process.execPath, [ + runner, + '--check', + '--kind', 'quick', + '--max-cost-usd', '1', + '--research-env-file', envFile, + ], { cwd: packageRoot, env: cleanRunnerEnvironment(), encoding: 'utf8' }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /must have owner-only permissions/); + assert.ok(!result.stderr.includes('must-never-load')); + } finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +test('blank env-file assignment masks inherited provider credentials', () => { + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), 'braintied-research-mask-env-')); + const envFile = path.join(temporaryDirectory, 'research.env'); + const fakeShell = path.join(temporaryDirectory, 'zsh'); + try { + writeFileSync(envFile, 'TAVILY_API_KEY=\n', { mode: 0o600 }); + chmodSync(envFile, 0o600); + writeFileSync(fakeShell, [ + '#!/bin/sh', + "printf 'TAVILY_API_KEY=shell-tavily-key\\0'", + '', + ].join('\n'), { mode: 0o700 }); + chmodSync(fakeShell, 0o700); + + const env = cleanRunnerEnvironment(); + env.TAVILY_API_KEY = 'stale-tavily-key'; + env.GEMINI_API_KEY = 'test-gemini-key'; + env.SEARXNG_URLS = 'https://search.example'; + env.SHELL = fakeShell; + const result = spawnSync(process.execPath, [ + runner, + '--check', + '--kind', 'quick', + '--max-cost-usd', '1', + '--sources', 'web', + '--require-providers', 'tavily', + '--as-of', '2026-07-23', + '--research-env-file', envFile, + '--load-shell-env', + ], { cwd: packageRoot, env, encoding: 'utf8' }); + + assert.equal(result.status, 2, result.stderr); + const preflight = JSON.parse(result.stdout) as { + configured_key_names: string[]; + runtime_environment: { masked_names: string[] }; + missing: string[]; + }; + assert.ok(!preflight.configured_key_names.includes('TAVILY_API_KEY')); + assert.ok(preflight.runtime_environment.masked_names.includes('TAVILY_API_KEY')); + assert.ok(preflight.missing.some((entry) => entry.includes('required provider unavailable: tavily'))); + } finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +test('conflicting Gemini aliases fail unless one alias is selected explicitly', () => { + const env = cleanRunnerEnvironment(); + env.GOOGLE_GENERATIVE_AI_API_KEY = 'wrong-test-key'; + env.GOOGLE_GEMINI_API_KEY = 'working-test-key'; + env.SEARXNG_URLS = 'https://search.example'; + + const baseArguments = [ + runner, + '--check', + '--kind', 'quick', + '--max-cost-usd', '1', + ]; + const conflicted = spawnSync(process.execPath, baseArguments, { + cwd: packageRoot, + env, + encoding: 'utf8', + }); + assert.equal(conflicted.status, 1); + assert.match(conflicted.stderr, /Conflicting Gemini aliases/); + assert.ok(!conflicted.stderr.includes('wrong-test-key')); + assert.ok(!conflicted.stderr.includes('working-test-key')); + + const selected = spawnSync(process.execPath, [ + ...baseArguments, + '--gemini-key-name', 'GOOGLE_GEMINI_API_KEY', + ], { cwd: packageRoot, env, encoding: 'utf8' }); + assert.equal(selected.status, 0, selected.stderr); + const preflight = JSON.parse(selected.stdout) as { + ready: boolean; + runtime_environment: { resolved_gemini_key_name: string }; + }; + assert.equal(preflight.ready, true); + assert.equal(preflight.runtime_environment.resolved_gemini_key_name, 'GOOGLE_GEMINI_API_KEY'); +}); + +test('relative env-file paths are rejected', () => { + const result = spawnSync(process.execPath, [ + runner, + '--check', + '--kind', 'quick', + '--max-cost-usd', '1', + '--research-env-file', 'relative/research.env', + ], { cwd: packageRoot, env: cleanRunnerEnvironment(), encoding: 'utf8' }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /must use an absolute path/); +}); + +test("Node's built-in env-file preloads are detected and refused", () => { + const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number); + if (nodeMajor < 20 || (nodeMajor === 20 && nodeMinor < 6)) return; + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), 'braintied-node-env-file-')); + const envFile = path.join(temporaryDirectory, 'unsafe.env'); + try { + writeFileSync(envFile, 'UNRELATED_PRIVATE_KEY=must-not-appear\n', { mode: 0o600 }); + const flags = [`--env-file=${envFile}`]; + if (nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 9)) { + flags.push(`--env-file-if-exists=${envFile}`); + } + for (const flag of flags) { + const result = spawnSync(process.execPath, [ + flag, + runner, + '--check', + '--kind', 'quick', + '--max-cost-usd', '1', + ], { cwd: packageRoot, env: cleanRunnerEnvironment(), encoding: 'utf8' }); + + assert.equal(result.status, 1, `${flag}: ${result.stderr}`); + assert.match(result.stderr, /bypasses the research allowlist/); + assert.ok(!result.stderr.includes('must-not-appear')); + } + } finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +test('load-shell-env can discover the shared absolute env-file pointer', () => { + if (process.platform === 'win32') return; + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), 'braintied-research-shell-pointer-')); + const envFile = path.join(temporaryDirectory, 'research.env'); + const fakeShell = path.join(temporaryDirectory, 'zsh'); + try { + writeFileSync(envFile, [ + 'GEMINI_API_KEY=shell-pointer-gemini-key', + 'SERPER_API_KEY=shell-pointer-serper-key', + '', + ].join('\n'), { mode: 0o600 }); + chmodSync(envFile, 0o600); + writeFileSync(fakeShell, [ + '#!/bin/sh', + "printf 'BRAINTIED_RESEARCH_ENV_FILE=%s\\0' \"$TEST_RESEARCH_ENV_FILE\"", + '', + ].join('\n'), { mode: 0o700 }); + chmodSync(fakeShell, 0o700); + + const env = cleanRunnerEnvironment(); + env.SHELL = fakeShell; + env.TEST_RESEARCH_ENV_FILE = envFile; + const result = spawnSync(process.execPath, [ + runner, + '--check', + '--kind', 'quick', + '--max-cost-usd', '1', + '--load-shell-env', + ], { cwd: packageRoot, env, encoding: 'utf8' }); + + assert.equal(result.status, 0, result.stderr); + const preflight = JSON.parse(result.stdout) as { + ready: boolean; + enabled_search_providers: string[]; + runtime_environment: { env_files: string[]; source: string }; + }; + assert.equal(preflight.ready, true); + assert.ok(preflight.enabled_search_providers.includes('serper')); + assert.deepEqual(preflight.runtime_environment.env_files, [envFile]); + assert.equal(preflight.runtime_environment.source, 'env-file+interactive-shell'); + } finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +test('symlinked env files are rejected before their contents are read', () => { + if (process.platform === 'win32') return; + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), 'braintied-research-symlink-env-')); + const targetFile = path.join(temporaryDirectory, 'target.env'); + const linkedFile = path.join(temporaryDirectory, 'linked.env'); + try { + writeFileSync(targetFile, 'GEMINI_API_KEY=must-not-appear\n', { mode: 0o600 }); + chmodSync(targetFile, 0o600); + symlinkSync(targetFile, linkedFile); + const result = spawnSync(process.execPath, [ + runner, + '--check', + '--kind', 'quick', + '--max-cost-usd', '1', + '--research-env-file', linkedFile, + ], { cwd: packageRoot, env: cleanRunnerEnvironment(), encoding: 'utf8' }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /must not be a symbolic link/); + assert.ok(!result.stderr.includes('must-not-appear')); + } finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); + } +});