// lib/validators/query-privacy-gate.mjs // Inspect an outbound research query before it leaves the machine. Called // only from the new high-effort steps (Phase 4.5 dimension discovery + the // bounded Phase 5 loop turns) — the existing single-pass Phase 5 path is // unchanged (Step 6, plan-v2). // // Two-tier, same shape as lib/exporters/endpoint-validator.mjs's SSRF gate: // - WARN tier — absolute filesystem paths, repo-internal identifiers. // Operator-overridable via `strict: false` / `--soft` (matches // lib/validators/research-validator.mjs's strict/soft convention), and // fully bypassable via the VOYAGE_QUERY_PRIVACY_ALLOW=1 opt-in. // - HARD-BLOCK tier — secret-shaped tokens. NEVER overridable by strict, // --soft, or the opt-in env var — mirrors endpoint-validator.mjs's // HARD_BLOCKED_HOSTS, where an opt-in widens the warn tier but never // unlocks the permanently-blocked one. // // CLI shim: // node lib/validators/query-privacy-gate.mjs [--soft] "" // → JSON {valid, errors, warnings}; exit 0 valid, 1 invalid. import { issue } from '../util/result.mjs'; // WARN tier — absolute filesystem paths (leaks local directory layout). export const ABSOLUTE_PATH_PATTERNS = Object.freeze([ /\/Users\/[^\s"'`]+/, /\/home\/[^\s"'`]+/, /[A-Za-z]:\\[^\s"'`]+/, /\$\{?HOME\}?\/[^\s"'`]+/, ]); // WARN tier — repo-internal identifiers that don't need to leave the // machine in a generic research query. export const REPO_IDENTIFIER_PATTERNS = Object.freeze([ /git\.fromaitochitta\.com[^\s"'`]*/, /\bktg-plugin-marketplace\b/, /\bplugins\/cache\/[^\s"'`]+/, ]); // HARD-BLOCK tier — secret-shaped strings. Never operator-overridable. export const SECRET_SHAPED_PATTERNS = Object.freeze([ /\bsk-[A-Za-z0-9]{20,}\b/, // OpenAI/Anthropic-style API keys /\bAKIA[0-9A-Z]{16}\b/, // AWS access key ID /\bghp_[A-Za-z0-9]{36,}\b/, // GitHub personal access token /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, // Slack token /-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM private key block ]); function findMatch(patterns, text) { for (const re of patterns) { const m = re.exec(text); if (m) return m[0]; } return null; } /** * @param {string} text * @param {{strict?: boolean, env?: object}} [opts] * @returns {{valid: boolean, errors: import('../util/result.mjs').Issue[], warnings: import('../util/result.mjs').Issue[]}} */ export function validateOutboundQuery(text, opts = {}) { const strict = opts.strict !== false; const env = opts.env || process.env; // Bypasses the WARN tier entirely — never affects the hard-block tier below. const allowWarnTier = env.VOYAGE_QUERY_PRIVACY_ALLOW === '1'; if (typeof text !== 'string' || text.length === 0) { return { valid: false, errors: [issue('PRIVACY_EMPTY_QUERY', 'Outbound query must be a non-empty string')], warnings: [] }; } const errors = []; const warnings = []; // Hard-block tier — checked unconditionally; no opt-in reaches this branch. const secretMatch = findMatch(SECRET_SHAPED_PATTERNS, text); if (secretMatch) { errors.push(issue('PRIVACY_SECRET_SHAPED', `Outbound query contains a secret-shaped token: ${secretMatch}`)); } if (!allowWarnTier) { const pathMatch = findMatch(ABSOLUTE_PATH_PATTERNS, text); if (pathMatch) { const issueObj = issue('PRIVACY_ABSOLUTE_PATH', `Outbound query contains an absolute filesystem path: ${pathMatch}`); if (strict) errors.push(issueObj); else warnings.push(issueObj); } const repoMatch = findMatch(REPO_IDENTIFIER_PATTERNS, text); if (repoMatch) { const issueObj = issue('PRIVACY_REPO_IDENTIFIER', `Outbound query contains a repo-internal identifier: ${repoMatch}`); if (strict) errors.push(issueObj); else warnings.push(issueObj); } } return { valid: errors.length === 0, errors, warnings }; } // ---- CLI shim ---------------------------------------------------------------- if (import.meta.url === `file://${process.argv[1]}`) { const args = process.argv.slice(2); const strict = !args.includes('--soft'); const text = args.find(a => !a.startsWith('--')); if (text === undefined) { process.stderr.write('Usage: query-privacy-gate.mjs [--soft] ""\n'); process.exit(2); } const r = validateOutboundQuery(text, { strict }); process.stdout.write(JSON.stringify(r) + '\n'); process.exit(r.valid ? 0 : 1); }