ai-sdk-guardrails
Version:
Input and output guardrails middleware for Vercel AI SDK.
570 lines (566 loc) • 18.4 kB
JavaScript
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
};