UNPKG

ai-sdk-guardrails

Version:

Input and output guardrails middleware for Vercel AI SDK.

570 lines (566 loc) 18.4 kB
import { extractContent } from "./chunk-WC2PXTRI.js"; import { createOutputGuardrail } from "./chunk-F7POYYOU.js"; // src/guardrails/tool-egress-scan.ts var BASE64_RUN = /(?:[A-Za-z0-9+/]{4}){8,}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?/g; var URL_PATTERN = /https?:\/\/[^\s"'<>]+|ftp:\/\/[^\s"'<>]+|file:\/\/[^\s"'<>]+/gi; function isRecord(value) { return Boolean(value) && typeof value === "object"; } function stringifyToolArgs(value) { if (typeof value === "string") return value; try { return JSON.stringify(value); } catch { return String(value); } } function extractToolCallPayloads(result) { const payloads = []; const resultRecord = result; if (Array.isArray(resultRecord.content)) { for (const item of resultRecord.content) { if (!isRecord(item) || item.type !== "tool-call") continue; const toolName = typeof item.toolName === "string" ? item.toolName : typeof item.name === "string" ? item.name : "unknown"; const rawInput = item.input ?? item.args ?? item.parameters ?? {}; payloads.push({ toolName, argsText: stringifyToolArgs(rawInput) }); } } if (Array.isArray(resultRecord.toolCalls)) { for (const call of resultRecord.toolCalls) { if (!isRecord(call)) continue; const toolName = typeof call.toolName === "string" ? call.toolName : typeof call.name === "string" ? call.name : "unknown"; const rawInput = call.input ?? call.args ?? call.parameters ?? {}; payloads.push({ toolName, argsText: stringifyToolArgs(rawInput) }); } } return payloads; } function hostMatches(host, patterns) { return patterns.some( (pattern) => typeof pattern === "string" ? host === pattern : pattern.test(host) ); } function isRegistryWriteTool(toolName) { return /upload|write|publish|put/i.test(toolName); } function scanTextEgressViolations(text, options, prefix) { const violations = []; const { allowedHosts = [], blockedHosts = [], suspiciousFilenamePatterns = [], blockBase64Payloads = false, minBase64Length = 64, allowFileUrls = false, allowLocalhost = false } = options; for (const pattern of suspiciousFilenamePatterns) { if (pattern.test(text)) { violations.push( `${prefix}: suspicious coordination pattern (${pattern})` ); } } const urls = text.match(URL_PATTERN) ?? []; for (const url of urls) { try { const parsed = new URL(url); if (parsed.protocol === "file:" && !allowFileUrls) { violations.push(`${prefix}: file URL not allowed: ${url}`); continue; } const host = parsed.hostname; if (!allowLocalhost && (host === "localhost" || host === "127.0.0.1" || /^10\.|^172\.(1[6-9]|2[0-9]|3[0-1])\.|^192\.168\.|^169\.254\./.test( host ))) { violations.push(`${prefix}: local/private URL not allowed: ${url}`); continue; } if (blockedHosts.length > 0 && hostMatches(host, blockedHosts)) { violations.push(`${prefix}: blocked host ${host}`); } if (allowedHosts.length > 0 && !hostMatches(host, allowedHosts)) { violations.push(`${prefix}: host ${host} outside allowlist`); } } catch { violations.push(`${prefix}: invalid URL format: ${url}`); } } if (blockBase64Payloads) { const runs = text.match(BASE64_RUN) ?? []; for (const run of runs) { if (run.length >= minBase64Length) { violations.push( `${prefix}: base64 gadget payload (${run.length} chars)` ); } } } return violations; } function scanToolCallEgress(payloads, options) { const { registryTools = [], denyRegistryWrites = false, denySuspiciousFilenames = true, sharedStoreId, suspiciousFilenamePatterns = [], ...scanOptions } = options; const violations = []; const storeTag = sharedStoreId ? `[store:${sharedStoreId}] ` : ""; for (const payload of payloads) { const prefix = `${storeTag}tool:${payload.toolName}`; if (denyRegistryWrites && registryTools.includes(payload.toolName) && isRegistryWriteTool(payload.toolName)) { violations.push(`${prefix}: registry writes denied in eval sandbox`); continue; } violations.push( ...scanTextEgressViolations( payload.argsText, { ...scanOptions, suspiciousFilenamePatterns: denySuspiciousFilenames ? suspiciousFilenamePatterns : [] }, prefix ) ); if (denySuspiciousFilenames && registryTools.includes(payload.toolName) && isRegistryWriteTool(payload.toolName)) { for (const pattern of suspiciousFilenamePatterns) { if (pattern.test(payload.argsText)) { violations.push( `${prefix}: registry write looks like cross-agent message board` ); } } } } return violations; } // src/guardrails/tools.ts function defaultMarker(tool) { return `TOOL_USED: ${tool}`; } function isRecord2(value) { return Boolean(value) && typeof value === "object"; } function pushIfString(value, target) { if (typeof value === "string" && value.trim().length > 0) { target.push(value); } } function extractFromContentArray(content) { const names = []; for (const contentItem of content) { if (!isRecord2(contentItem)) { continue; } if (contentItem.type !== "tool-call") { continue; } pushIfString(contentItem.toolName, names); } return names; } function extractFromToolCallsArray(toolCalls) { const names = []; for (const toolCall of toolCalls) { if (!isRecord2(toolCall)) { continue; } const potentialName = typeof toolCall.toolName === "string" ? toolCall.toolName : typeof toolCall.name === "string" ? toolCall.name : void 0; pushIfString(potentialName, names); } return names; } function extractToolNameFromItem(item) { if ("name" in item && typeof item.name === "string") { return item.name; } if ("tool" in item && typeof item.tool === "string") { return item.tool; } if ("type" in item && typeof item.type === "string") { return item.type; } if ("toolName" in item && typeof item.toolName === "string") { return item.toolName; } return void 0; } function extractFromCandidateArrays(candidates) { const names = []; for (const candidate of candidates) { if (!Array.isArray(candidate)) { continue; } for (const item of candidate) { if (!isRecord2(item)) { continue; } pushIfString(extractToolNameFromItem(item), names); } } return names; } function extractToolNamesFromResult(result) { const names = /* @__PURE__ */ new Set(); const resultWithUnknownProps = result; const md = resultWithUnknownProps?.experimental_providerMetadata ?? resultWithUnknownProps?.providerMetadata ?? {}; if (Array.isArray(resultWithUnknownProps.content)) { const contentNames = extractFromContentArray( resultWithUnknownProps.content ); for (const name of contentNames) { names.add(name); } } if (Array.isArray(resultWithUnknownProps.toolCalls)) { const toolCallNames = extractFromToolCallsArray( resultWithUnknownProps.toolCalls ); for (const name of toolCallNames) { names.add(name); } } const candidates = [ md.toolCalls, resultWithUnknownProps.toolCalls, md.tools, resultWithUnknownProps.tools, md.calledTools, resultWithUnknownProps.calledTools ].filter(Boolean); const candidateNames = extractFromCandidateArrays(candidates); for (const name of candidateNames) { names.add(name); } return [...names]; } function extractProviderTools(result, mode, providerExtractor) { if (mode === "auto" || mode === "provider") { const providerTools = providerExtractor ? providerExtractor(result) : extractToolNamesFromResult(result); if (providerTools.length > 0) { return { tools: providerTools, usedProvider: true }; } } return { tools: [], usedProvider: false }; } function getToolMarkers(tool, textMarkers) { if (Array.isArray(textMarkers)) { return textMarkers; } if (typeof textMarkers === "function") { const built = textMarkers(tool); return Array.isArray(built) ? built : [built]; } return [defaultMarker(tool)]; } function extractMarkerTools(result, expected, mode, textMarkers, observedToolsLength = 0) { if (mode === "auto" && observedToolsLength === 0 || mode === "marker") { const { text } = extractContent(result); const observedMarkers = []; for (const tool of expected) { const markers = getToolMarkers(tool, textMarkers); const found = markers.find((m) => m && text.includes(m)); if (found) { observedMarkers.push(found); } } return { markers: observedMarkers, usedMarkers: observedMarkers.length > 0 }; } return { markers: [], usedMarkers: false }; } function determineDetectionType(usedProvider, usedMarkers) { if (usedProvider && usedMarkers) { return "mixed"; } if (usedProvider) { return "provider"; } if (usedMarkers) { return "marker"; } return "none"; } function expectedToolUse(options) { const { tools, requireAll = true, mode = "auto", textMarkers, providerExtractor, retry: retryConfig } = options; const expected = Array.isArray(tools) ? tools : [tools]; return { name: "expected-tool-use", retry: retryConfig, execute: (context) => { const { result } = context; const { tools: observedTools, usedProvider } = extractProviderTools( result, mode, providerExtractor ); const { markers: observedMarkers, usedMarkers } = extractMarkerTools( result, expected, mode, textMarkers, observedTools.length ); const detectedTools = /* @__PURE__ */ new Set([ ...observedTools, ...observedMarkers.map((m) => m.replace(/^TOOL_USED:\s*/, "")) ]); const missing = expected.filter((t) => !detectedTools.has(t)); const passed = requireAll ? missing.length === 0 : detectedTools.size > 0; const metadata = { expectedTools: expected, observedTools: [...new Set(observedTools)], observedMarkers, missingTools: missing, detection: determineDetectionType(usedProvider, usedMarkers) }; if (!passed) { return { tripwireTriggered: true, severity: "medium", message: expected.length === 1 ? `Expected tool not used: ${expected[0]}` : `Expected tool(s) missing: ${missing.join(", ")}`, metadata, info: { guardrailName: "expected-tool-use", expectedTools: expected, observedTools: [...new Set(observedTools)], missingTools: missing, detectionMethod: determineDetectionType(usedProvider, usedMarkers) } }; } return { tripwireTriggered: false, metadata: { ...metadata, missingTools: [] }, info: { guardrailName: "expected-tool-use", expectedTools: expected, observedTools: [...new Set(observedTools)], missingTools: [], detectionMethod: determineDetectionType(usedProvider, usedMarkers) } }; }, getRetryInstruction: (ctx) => { const missingTools = ctx.result.metadata?.missingTools ?? expected; const attempt = ctx.attempt; const toolName = missingTools[0] ?? "the required tool"; if (missingTools.length === 1) { const urgency = attempt > 1 ? "CRITICAL: " : ""; return { message: `${urgency}Your previous response did not call any tools. You MUST call the "${toolName}" tool function NOW. Do not respond with text - invoke the tool first. This is attempt ${attempt}.`, context: { missingTools, attempt } }; } return { message: `REQUIRED: You must call these tool functions: ${missingTools.join(", ")}. Do not respond with text until you have called the tools. Attempt ${attempt}.`, context: { missingTools, attempt } }; } }; } function toolEgressPolicy(options = {}) { const { allowedTools = [], deniedTools = [], allowedHosts = [], blockedHosts = [], parameterRules = {}, scanForUrls = true, allowFileUrls = false, allowLocalhost = false } = options; return createOutputGuardrail( "tool-egress-policy", (context) => { const { result } = context; const { text } = extractContent(result); const { tools: observedTools } = extractProviderTools(result, "auto"); const violations = []; const detectedIssues = []; for (const tool of observedTools) { if (deniedTools.includes(tool)) { violations.push(`Tool '${tool}' is explicitly denied`); detectedIssues.push({ tool, issue: "explicitly denied", severity: "high" }); continue; } if (allowedTools.length > 0 && !allowedTools.includes(tool)) { violations.push(`Tool '${tool}' is not in allowlist`); detectedIssues.push({ tool, issue: "not in allowlist", severity: "medium" }); continue; } const rules = parameterRules[tool]; if (rules && rules.maxParamLength) { const toolMention = text.match( new RegExp(String.raw`${tool}.*?(?=\n|$)`, "i") ); if (toolMention && toolMention[0].length > rules.maxParamLength) { violations.push(`Tool '${tool}' parameter length exceeds limit`); detectedIssues.push({ tool, issue: "parameter length exceeded", severity: "medium" }); } } } if (scanForUrls) { const blockedPatterns = blockedHosts.map( (h) => typeof h === "string" ? h : h ); for (const payload of extractToolCallPayloads(result)) { const argViolations = scanTextEgressViolations( payload.argsText, { allowedHosts, blockedHosts: blockedPatterns, allowFileUrls, allowLocalhost }, `tool:${payload.toolName}` ); for (const v of argViolations) { violations.push(v); detectedIssues.push({ tool: payload.toolName, issue: v, severity: "high" }); } } } if (scanForUrls) { const urlPattern = /https?:\/\/[^\s]+|ftp:\/\/[^\s]+|file:\/\/[^\s]+/gi; const urls = text.match(urlPattern) || []; for (const url of urls) { try { const parsed = new URL(url); if (parsed.protocol === "file:" && !allowFileUrls) { violations.push(`File URL detected and not allowed: ${url}`); detectedIssues.push({ tool: "url-scan", issue: "file URL not allowed", severity: "high" }); continue; } if (!allowLocalhost && (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname.startsWith("192.168.") || parsed.hostname.startsWith("10.") || parsed.hostname.startsWith("172."))) { violations.push(`Local URL detected and not allowed: ${url}`); detectedIssues.push({ tool: "url-scan", issue: "local URL not allowed", severity: "high" }); continue; } if (blockedHosts.includes(parsed.hostname)) { violations.push(`Blocked host detected: ${parsed.hostname}`); detectedIssues.push({ tool: "url-scan", issue: "blocked host", severity: "high" }); continue; } if (allowedHosts.length > 0 && !allowedHosts.includes(parsed.hostname)) { violations.push(`Host not in allowlist: ${parsed.hostname}`); detectedIssues.push({ tool: "url-scan", issue: "host not in allowlist", severity: "medium" }); } } catch { violations.push(`Invalid URL format detected: ${url}`); detectedIssues.push({ tool: "url-scan", issue: "invalid URL format", severity: "low" }); } } } if (violations.length > 0) { const highSeverityCount = detectedIssues.filter( (i) => i.severity === "high" ).length; const mediumSeverityCount = detectedIssues.filter( (i) => i.severity === "medium" ).length; return { tripwireTriggered: true, message: `Tool egress policy violations: ${violations.join("; ")}`, severity: highSeverityCount > 0 ? "critical" : mediumSeverityCount > 0 ? "high" : "medium", metadata: { violationCount: violations.length, violations, detectedIssues, observedTools, allowedTools, deniedTools, urlsScanned: scanForUrls }, suggestion: "Review tool usage and URL access patterns for security compliance", info: { guardrailName: "tool-egress-policy", violationCount: violations.length, violations, detectedIssues, observedTools, allowedTools, deniedTools, urlsScanned: scanForUrls } }; } return { tripwireTriggered: false, metadata: { observedTools, urlsScanned: scanForUrls, policyEnforced: true }, info: { guardrailName: "tool-egress-policy", observedTools, urlsScanned: scanForUrls, policyEnforced: true } }; } ); } export { extractToolCallPayloads, scanToolCallEgress, extractToolNamesFromResult, expectedToolUse, toolEgressPolicy };