UNPKG

lynkr

Version:

Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.

200 lines (184 loc) 7.09 kB
/** * Risk Analyzer * * Scores a request along a risk axis that is orthogonal to complexity. * A trivially short edit to `auth/middleware.ts` is still high risk and * should not be served by a cheap local model. * * @module routing/risk-analyzer */ const { extractContent } = require('./complexity-analyzer'); // Substring keywords found in file paths or instruction text. // Matched case-insensitively as raw substrings, so "auth" hits // "src/auth/login.ts" and "authentication". // NOTE: keywords are matched as case-insensitive *substrings* against file // paths, so overly generic terms cause false positives. 'session' and 'token' // were removed because they match benign paths (src/sessions/*, tokenizer.js, // token-budget.js) and were force-escalating ordinary requests to COMPLEX — // real secrets/credentials are still covered by the keywords below. const PROTECTED_PATH_KEYWORDS = [ 'auth', 'oauth', 'jwt', 'security', 'permission', 'rbac', 'payment', 'payments', 'billing', 'invoice', 'subscription', 'migration', 'migrations', 'schema', 'infra', 'terraform', 'kustomize', 'helm', 'kubernetes', '.github/workflows', '.env', 'secret', 'credential', 'api-key', 'api_key', 'apikey', 'webhook', 'admin', ]; // Whole-word instruction keywords that signal sensitive intent regardless // of which files are involved. Higher signal than path keywords because // they reflect what the user is *asking for*. const HIGH_RISK_INSTRUCTION_KEYWORDS = [ 'authentication', 'authorization', 'permission', 'security', 'payment', 'billing', 'migration', 'database schema', 'encrypt', 'decrypt', 'secret', 'credential', 'api key', 'production', 'deploy', 'rollout', 'rollback', ]; // Path-extracting patterns. We look at: // 1. Anything that looks like a file path inside the instruction text. // 2. Explicit path-like fields in tool inputs (e.g. tool_use blocks). const PATH_LIKE_RE = /(?:^|[\s`'"([])([./a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,8})(?=[\s`'")\]:,;]|$)/g; const SLASHED_PATH_RE = /(?:^|[\s`'"([])((?:[a-zA-Z0-9_.-]+\/)+[a-zA-Z0-9_.-]+)(?=[\s`'")\]:,;]|$)/g; /** * Pull every path-shaped substring out of free-form text. * @param {string} text * @returns {string[]} */ function extractPathsFromText(text) { if (!text) return []; const out = new Set(); let m; while ((m = PATH_LIKE_RE.exec(text)) !== null) { out.add(m[1]); } while ((m = SLASHED_PATH_RE.exec(text)) !== null) { out.add(m[1]); } return Array.from(out); } /** * Walk every tool_use block in the conversation and collect any string * inputs that look like paths. Catches cases where the model already * called an Edit/Read tool on a sensitive file. * @param {object} payload * @returns {string[]} */ function extractPathsFromToolUses(payload) { const out = new Set(); const messages = payload?.messages; if (!Array.isArray(messages)) return []; for (const msg of messages) { if (!Array.isArray(msg?.content)) continue; for (const block of msg.content) { if (block?.type !== 'tool_use' || !block.input) continue; const stack = [block.input]; while (stack.length) { const node = stack.pop(); if (typeof node === 'string') { if (node.includes('/') || node.includes('.')) { // Treat short tool-input strings that look path-y as paths. if (node.length <= 200) out.add(node); } } else if (Array.isArray(node)) { for (const v of node) stack.push(v); } else if (node && typeof node === 'object') { for (const v of Object.values(node)) stack.push(v); } } } } return Array.from(out); } /** * Find which keywords from `keywords` appear (case-insensitively) inside * any of `haystack`. Substring match — by design — so "auth" matches * both "src/auth/login.ts" and the word "authorization". * @param {string[]} keywords * @param {string[]} haystack * @returns {string[]} hit keywords, sorted */ function findHits(keywords, haystack) { const hits = new Set(); const joined = haystack.join('\n').toLowerCase(); for (const kw of keywords) { if (joined.includes(kw.toLowerCase())) hits.add(kw); } return Array.from(hits).sort(); } /** * Analyze the risk level of a request. * * Risk is orthogonal to complexity: * - low → no protected paths or sensitive keywords detected * - medium → protected paths *or* a read-only task on a protected area * - high → instruction explicitly names sensitive domain logic, * or protected paths combined with a write-intent task * * @param {object} payload - Anthropic-format request payload * @returns {{ level: 'low'|'medium'|'high', * reason: string, * pathHits: string[], * instructionHits: string[], * paths: string[] }} */ function analyzeRisk(payload) { const instructionText = extractContent(payload) || ''; const lowText = instructionText.toLowerCase(); const textPaths = extractPathsFromText(instructionText); const toolPaths = extractPathsFromToolUses(payload); const allPaths = Array.from(new Set([...textPaths, ...toolPaths])); // Instruction-level hits scan the raw text. Path-level hits scan only // the extracted path strings so phrases like "authentication is hard" // don't double-fire as a path hit. const instructionHits = findHits(HIGH_RISK_INSTRUCTION_KEYWORDS, [instructionText]); const pathHits = findHits(PROTECTED_PATH_KEYWORDS, allPaths.length ? allPaths : []); // Also let path keywords match against the instruction text — covers // "update the auth flow" with no path mentioned. const textPathHits = findHits(PROTECTED_PATH_KEYWORDS, [instructionText]); const mergedPathHits = Array.from(new Set([...pathHits, ...textPathHits])).sort(); if (instructionHits.length > 0) { return { level: 'high', reason: 'High-risk instruction keyword detected.', pathHits: mergedPathHits, instructionHits, paths: allPaths, }; } if (mergedPathHits.length > 0) { // Read-only intent on a protected area is medium, not high. // Heuristic: presence of explain/summarize/read verbs. const readOnly = /\b(explain|summarize|describe|what does|walk me through|read|show|list|search|find|grep|locate)\b/i.test(lowText); if (readOnly) { return { level: 'medium', reason: 'Protected paths involved but task appears read-only.', pathHits: mergedPathHits, instructionHits: [], paths: allPaths, }; } return { level: 'high', reason: 'Protected path referenced with write-capable intent.', pathHits: mergedPathHits, instructionHits: [], paths: allPaths, }; } return { level: 'low', reason: 'No risk signals detected.', pathHits: [], instructionHits: [], paths: allPaths, }; } module.exports = { analyzeRisk, PROTECTED_PATH_KEYWORDS, HIGH_RISK_INSTRUCTION_KEYWORDS, // Exposed for tests extractPathsFromText, extractPathsFromToolUses, };