vibeflow-n8n/src/vibeflow.mjs
Felipe Domingues f62a78faaf
Add outcome-aware preflight contracts for v0.9 (#6)
Add VF010-VF013, structural outcome contracts, adversarial fixtures, documentation, and Friday release materials.
2026-07-22 13:58:46 -03:00

1301 lines
54 KiB
JavaScript

import { access, readFile, readdir, stat } from "node:fs/promises";
import path from "node:path";
export const VERSION = "0.9.0";
export const MAX_WORKFLOW_BYTES = 25 * 1024 * 1024;
export const MAX_CONFIG_BYTES = 256 * 1024;
export const MAX_WORKFLOW_FILES = 1_000;
export const MAX_NODES = 5_000;
export const MAX_EDGES = 20_000;
export const MAX_FINDINGS_PER_FILE = 1_000;
export const MAX_EXECUTION_TIMEOUT_SECONDS = 3_600;
export const RULES = Object.freeze({
VF000: {
name: "invalid-workflow",
severity: "error",
description: "The input must be valid JSON with an n8n nodes array.",
remediation: "Export the workflow again and pass a valid n8n workflow JSON file.",
},
VF001: {
name: "embedded-secret",
severity: "error",
description: "Literal credentials must not be embedded in node parameters.",
remediation: "Move the value to n8n credentials, a secret store, or an environment expression.",
},
VF002: {
name: "dangerous-node",
severity: "error",
description: "Host-level command, SSH, and local-file nodes expand the blast radius.",
remediation: "Remove the node or explicitly change the bannedNodeTypes policy after review.",
},
VF003: {
name: "unprotected-webhook",
severity: "warning",
description: "Public webhooks should authenticate callers at the trust boundary.",
remediation: "Configure header or basic authentication, or document and suppress the rule.",
},
VF004: {
name: "missing-error-handling",
severity: "warning",
description: "External side effects need an explicit failure path.",
remediation: "Connect a continueErrorOutput branch or configure workflow.settings.errorWorkflow.",
},
VF005: {
name: "missing-idempotency",
severity: "warning",
description: "Inbound workflows with side effects need duplicate-event protection.",
remediation: "Use an atomic claim that emits no item for duplicates, such as INSERT ... ON CONFLICT DO NOTHING ... RETURNING.",
},
VF006: {
name: "missing-kill-switch",
severity: "error",
description: "Customer-facing AI paths need an upstream off switch.",
remediation: "Gate every AI Agent path with an enabled/status/kill-switch check before inference.",
},
VF007: {
name: "missing-human-handoff",
severity: "warning",
description: "AI Agent paths should expose a downstream human fallback.",
remediation: "Add a confidence or exception branch that creates a ticket or hands off to a human.",
},
VF008: {
name: "missing-timeout",
severity: "warning",
description: "AI and external-action workflows should have a bounded execution time.",
remediation: `Set workflow.settings.executionTimeout between 1 and ${MAX_EXECUTION_TIMEOUT_SECONDS} seconds.`,
},
VF009: {
name: "unsafe-retry",
severity: "warning",
description: "Retries need idempotency, bounded attempts, and backoff.",
remediation: "Add a deduplication guard, keep maxTries between 2 and 5, and wait at least 100 ms.",
},
VF010: {
name: "uncontracted-critical-outcome",
severity: "error",
description: "Money and privileged actions need an explicit, structurally verified outcome contract.",
remediation: "Define an outcomeContracts entry with approval, audit, idempotency, limits, failure notification, and recovery evidence.",
},
VF011: {
name: "uncontracted-external-outcome",
severity: "warning",
description: "Customer communications and destructive data writes need an explicit outcome contract.",
remediation: "Define an outcomeContracts entry that identifies the impact and its audit, notification, and recovery nodes.",
},
VF012: {
name: "silent-error-path",
severity: "warning",
description: "A connected error path should notify or escalate to an operator.",
remediation: "Route the error output to a recognized alert, incident, ticket, or human escalation node.",
},
VF013: {
name: "missing-recovery-contract",
severity: "warning",
description: "High-impact writes need a declared compensation, rollback, or replay path.",
remediation: "Declare a recovery strategy and reference the workflow node that implements it.",
},
});
const DEFAULT_CONFIG = Object.freeze({
rules: Object.fromEntries(Object.entries(RULES).map(([id, rule]) => [id, rule.severity])),
terms: {
killSwitch: ["agent-off", "kill switch", "agent enabled", "ai enabled", "agent status", "pause ai"],
humanHandoff: ["human handoff", "handoff", "human review", "escalate", "manual review", "chatwoot", "ticket"],
idempotency: ["idempotency", "idempotent", "dedupe", "deduplicate", "duplicate", "event ledger", "event id"],
approval: ["approval", "approved", "authorize", "authorized", "human review"],
audit: ["audit", "ledger", "journal", "event log", "history"],
failureNotification: ["error alert", "failure alert", "notify failure", "incident", "pager", "escalate error"],
outcomeMoney: ["refund", "payment", "payout", "charge", "transfer funds", "withdraw"],
outcomeCustomer: ["customer message", "customer notification", "notify customer", "client message", "patient message"],
outcomePrivileged: ["delete account", "revoke access", "grant access", "change role", "disable user", "publish"],
outcomeData: ["delete record", "drop table", "truncate", "purge", "overwrite data"],
recovery: ["compensate", "rollback", "replay", "reverse", "restore", "recovery"],
},
bannedNodeTypes: [
"n8n-nodes-base.executecommand",
"n8n-nodes-base.ssh",
"n8n-nodes-base.readwritefile",
"n8n-nodes-base.localfiletrigger",
],
outcomeContracts: {},
});
const SIDE_EFFECT_SUFFIXES = [
".httprequest",
".slack",
".gmail",
".emailsend",
".postgres",
".mysql",
".microsoftsql",
".redis",
".supabase",
".stripe",
".hubspot",
".telegram",
".twilio",
".salesforce",
".notion",
".googlesheets",
".s3",
".github",
".airtable",
".baserow",
".mongodb",
".dynamodb",
".googledrive",
".microsoftonedrive",
".microsoftoutlook",
".microsoftteams",
".discord",
".zendesk",
".freshdesk",
".intercom",
".servicenow",
".jira",
".linear",
".trello",
".asana",
".clickup",
".woocommerce",
".shopify",
".sendgrid",
".mailchimp",
".rabbitmq",
".kafka",
".mqtt",
".graphql",
];
const HANDOFF_SUFFIXES = [
".slack",
".gmail",
".emailsend",
".telegram",
".twilio",
".microsoftteams",
".discord",
".zendesk",
".freshdesk",
".intercom",
".servicenow",
".jira",
];
const DURABLE_AUDIT_SUFFIXES = [
".postgres",
".mysql",
".microsoftsql",
".supabase",
".datatable",
".mongodb",
".dynamodb",
".s3",
".airtable",
];
const COMMUNICATION_SUFFIXES = [
".httprequest",
".slack",
".gmail",
".emailsend",
".telegram",
".twilio",
".microsoftteams",
".discord",
".zendesk",
".freshdesk",
".intercom",
".servicenow",
".jira",
];
const TRIGGER_SUFFIXES = [".webhook", ".formtrigger", ".chattrigger", ".telegramtrigger", ".stripetrigger"];
const SAFE_LITERAL = /^(?:<[^>]+>|redacted|change[-_ ]?me|your[-_ ].*|example(?:[-_ ].*)?|placeholder(?:[-_ ].*)?)$/i;
export async function loadConfig(explicitPath = null, cwd = process.cwd(), locked = false) {
let configPath = explicitPath;
if (!configPath) {
const candidate = path.join(cwd, ".vibeflow.json");
try {
await access(candidate);
configPath = candidate;
} catch {
return cloneDefaults();
}
}
const details = await stat(configPath);
if (details.size > MAX_CONFIG_BYTES) {
throw new Error(`Config exceeds the ${MAX_CONFIG_BYTES} byte safety limit`);
}
const raw = await readFile(configPath, "utf8");
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`Invalid config JSON at ${configPath}: ${error.message}`);
}
return normalizeConfig(parsed, { locked });
}
export function normalizeConfig(input = {}, { locked = false } = {}) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new Error("Vibeflow config must be a JSON object");
}
const allowedKeys = new Set(["$schema", "rules", "terms", "bannedNodeTypes", "outcomeContracts"]);
const unknownKey = Object.keys(input).find((key) => !allowedKeys.has(key));
if (unknownKey) throw new Error(`Unknown config key: ${unknownKey}`);
const config = cloneDefaults();
if (input.rules !== undefined) {
if (!input.rules || typeof input.rules !== "object" || Array.isArray(input.rules)) {
throw new Error("config.rules must be an object");
}
for (const [ruleId, severity] of Object.entries(input.rules)) {
if (!RULES[ruleId] || ruleId === "VF000") throw new Error(`Unknown configurable rule: ${ruleId}`);
if (!["error", "warning", "off"].includes(severity)) {
throw new Error(`Rule ${ruleId} must be error, warning, or off`);
}
if (locked && severityRank(severity) < severityRank(RULES[ruleId].severity)) {
throw new Error(`Locked policy cannot weaken ${ruleId}`);
}
config.rules[ruleId] = severity;
}
}
if (input.terms !== undefined) {
if (!input.terms || typeof input.terms !== "object" || Array.isArray(input.terms)) {
throw new Error("config.terms must be an object");
}
for (const key of Object.keys(input.terms)) {
if (!Object.hasOwn(config.terms, key)) throw new Error(`Unknown terms group: ${key}`);
const values = input.terms[key];
if (!Array.isArray(values) || values.length > 100 || values.some((value) => typeof value !== "string" || !value.trim() || value.length > 100)) {
throw new Error(`config.terms.${key} must contain at most 100 non-empty strings of at most 100 characters`);
}
if (locked && !sameStringSet(values, DEFAULT_CONFIG.terms[key])) {
throw new Error(`Locked policy cannot change config.terms.${key}`);
}
config.terms[key] = values.map((value) => value.toLowerCase());
}
}
if (input.bannedNodeTypes !== undefined) {
if (!Array.isArray(input.bannedNodeTypes) || input.bannedNodeTypes.length > 1_000 || input.bannedNodeTypes.some((value) => typeof value !== "string" || !value.trim() || value.length > 200)) {
throw new Error("config.bannedNodeTypes must contain at most 1000 non-empty strings of at most 200 characters");
}
const normalizedTypes = input.bannedNodeTypes.map((value) => value.toLowerCase());
if (locked && DEFAULT_CONFIG.bannedNodeTypes.some((value) => !normalizedTypes.includes(value))) {
throw new Error("Locked policy cannot remove default bannedNodeTypes");
}
config.bannedNodeTypes = normalizedTypes;
}
if (input.outcomeContracts !== undefined) {
config.outcomeContracts = normalizeOutcomeContracts(input.outcomeContracts);
}
return config;
}
export async function checkPaths(inputs, config = cloneDefaults()) {
const files = await collectWorkflowFiles(inputs);
if (!files.length) throw new Error("No workflow files found");
if (files.length > MAX_WORKFLOW_FILES) {
throw new Error(`Input exceeds the ${MAX_WORKFLOW_FILES} workflow file safety limit`);
}
const results = [];
for (const file of files) results.push(await checkFile(file, config));
return buildReport(results);
}
export async function checkFile(file, config = cloneDefaults()) {
let workflow;
try {
const details = await stat(file);
if (details.size > MAX_WORKFLOW_BYTES) {
return {
file,
workflowName: null,
findings: [invalidFinding(`Workflow exceeds the ${MAX_WORKFLOW_BYTES} byte safety limit`)],
};
}
workflow = JSON.parse(await readFile(file, "utf8"));
} catch (error) {
return { file, workflowName: null, findings: [invalidFinding(`Invalid JSON: ${error.message}`)] };
}
const shapeError = validateWorkflowShape(workflow);
if (shapeError) return { file, workflowName: workflow?.name ?? null, findings: [invalidFinding(shapeError)] };
return {
file,
workflowName: typeof workflow.name === "string" ? workflow.name : null,
findings: inspectWorkflow(workflow, config),
};
}
export function inspectWorkflow(workflow, config = cloneDefaults()) {
const shapeError = validateWorkflowShape(workflow);
if (shapeError) return [invalidFinding(shapeError)];
const nodes = workflow.nodes;
const { adjacency, reverse } = buildGraph(workflow.connections ?? {});
const nodesByName = new Map(nodes.map((node) => [node.name, node]));
const aiNodes = nodes.filter(isAiAgent);
const triggerNodes = nodes.filter(isInboundTrigger);
const sideEffectNodes = nodes.filter(isSideEffect);
const entryNames = collectEntryNames(nodes, reverse);
const aiNames = new Set(aiNodes.map((node) => node.name));
const killSwitchNames = new Set(nodes
.filter((node) => isKillSwitchGuard(node, config.terms.killSwitch, workflow.connections ?? {}, adjacency, aiNames))
.map((node) => node.name));
const handoffNames = nodes.filter((node) => isHumanHandoff(node, config.terms.humanHandoff)).map((node) => node.name);
const idempotencyNames = new Set(nodes.filter((node) => isAtomicIdempotencyGuard(node, config.terms.idempotency)).map((node) => node.name));
const failureNotificationNames = new Set(nodes
.filter((node) => isFailureNotification(node, config.terms.failureNotification))
.map((node) => node.name));
const allReachable = traverseGraph(adjacency, entryNames);
const reachableWithoutKillSwitch = traverseGraph(adjacency, entryNames, killSwitchNames);
const canReachHandoff = traverseGraph(reverse, handoffNames);
const triggerNames = triggerNodes.map((node) => node.name);
const triggerReachable = traverseGraph(adjacency, triggerNames);
const workflowHasErrorHandler = typeof workflow.settings?.errorWorkflow === "string" && workflow.settings.errorWorkflow.trim();
const findings = [];
let findingsTruncated = false;
const add = (ruleId, message, node = null, parameterPath = null) => {
const severity = config.rules[ruleId];
if (severity === "off" || findingsTruncated) return;
if (findings.length >= MAX_FINDINGS_PER_FILE) {
findingsTruncated = true;
return;
}
findings.push({
ruleId,
rule: RULES[ruleId].name,
severity,
message,
remediation: RULES[ruleId].remediation,
node: node ? { id: node.id ?? null, name: node.name ?? null, type: node.type ?? null } : null,
path: parameterPath,
});
};
for (const node of nodes) {
const secretResult = findSecretPaths(node.parameters ?? {}, MAX_FINDINGS_PER_FILE - findings.length);
for (const secretPath of secretResult.paths) {
add("VF001", `Literal secret-like value at parameters.${secretPath}`, node, `parameters.${secretPath}`);
}
if (secretResult.truncated || findingsTruncated) {
findingsTruncated = true;
break;
}
if (config.bannedNodeTypes.includes(lowerType(node))) {
add("VF002", `Node type ${node.type} is blocked by the default policy`, node);
}
if (lowerType(node).endsWith(".webhook")) {
const authIssue = webhookAuthenticationIssue(node);
if (authIssue) add("VF003", authIssue, node, "parameters.authentication");
}
if (isSideEffect(node) && !workflowHasErrorHandler && !hasConnectedErrorPath(node, workflow.connections ?? {})) {
add("VF004", "External-action node has no connected error output or workflow error handler", node);
}
if (isSideEffect(node)
&& !failureNotificationNames.has(node.name)
&& !workflowHasErrorHandler
&& hasConnectedErrorPath(node, workflow.connections ?? {})
&& !errorBranchCanReach(node.name, failureNotificationNames, workflow.connections ?? {}, adjacency)) {
add("VF012", "Connected error output terminates without a recognized operator notification or escalation", node);
}
if (isSideEffect(node) && node.retryOnFail === true) {
const maxTries = Number(node.maxTries ?? 3);
const waitBetweenTries = Number(node.waitBetweenTries ?? 0);
if (!idempotencyNames.has(node.name)
&& !hasProtectedIdempotencyPath(node.name, entryNames, idempotencyNames, adjacency, workflow.connections ?? {})) {
add("VF009", "Retry is enabled on a path without an atomic idempotency guard", node);
}
if (!Number.isFinite(maxTries) || maxTries < 2 || maxTries > 5) {
add("VF009", `Retry budget is outside the safe range: maxTries=${node.maxTries ?? "invalid"}`, node, "maxTries");
}
if (!Number.isFinite(waitBetweenTries) || waitBetweenTries < 100) {
add("VF009", `Retry backoff is too small: waitBetweenTries=${node.waitBetweenTries ?? 0}`, node, "waitBetweenTries");
}
}
}
if (!findingsTruncated) {
for (const section of ["pinData", "staticData"]) {
if (workflow[section] === undefined) continue;
const secretResult = findSecretPaths(workflow[section], MAX_FINDINGS_PER_FILE - findings.length);
for (const secretPath of secretResult.paths) {
const fullPath = secretPath ? `${section}.${secretPath}` : section;
add("VF001", `Literal secret-like value at ${fullPath}`, null, fullPath);
}
if (secretResult.truncated || findingsTruncated) {
findingsTruncated = true;
break;
}
}
}
if (findingsTruncated) {
return finalizeFindings(findings, true);
}
for (const sideEffectNode of sideEffectNodes) {
if (!failureNotificationNames.has(sideEffectNode.name)
&& !idempotencyNames.has(sideEffectNode.name)
&& triggerReachable.has(sideEffectNode.name)
&& !hasProtectedIdempotencyPath(sideEffectNode.name, triggerNames, idempotencyNames, adjacency, workflow.connections ?? {})) {
add("VF005", "Inbound path reaches this external side effect without an atomic idempotency guard", sideEffectNode);
}
}
inspectOutcomeContracts({
nodes,
nodesByName,
config,
adjacency,
connections: workflow.connections ?? {},
entryNames,
allReachable,
idempotencyNames,
failureNotificationNames,
add,
});
for (const aiNode of aiNodes) {
if (!allReachable.has(aiNode.name) || reachableWithoutKillSwitch.has(aiNode.name)) {
add("VF006", "At least one entry path reaches the AI Agent without a structural kill-switch gate", aiNode);
}
if (!canReachHandoff.has(aiNode.name)) {
add("VF007", "AI Agent has no reachable external-action node identified as human handoff", aiNode);
}
}
const executionTimeout = Number(workflow.settings?.executionTimeout ?? 0);
if ((aiNodes.length || sideEffectNodes.length)
&& (typeof workflow.settings?.executionTimeout !== "number"
|| !Number.isFinite(executionTimeout) || executionTimeout <= 0 || executionTimeout > MAX_EXECUTION_TIMEOUT_SECONDS)) {
add("VF008", `Workflow execution timeout must be between 1 and ${MAX_EXECUTION_TIMEOUT_SECONDS} seconds`, null, "settings.executionTimeout");
}
return finalizeFindings(findings, findingsTruncated);
}
export function formatText(report) {
const lines = [];
for (const file of report.files) {
const fileName = safeDisplay(file.file);
const workflowName = file.workflowName ? ` (${safeDisplay(file.workflowName)})` : "";
lines.push(`${file.findings.length ? "✖" : "✓"} ${fileName}${workflowName}`);
for (const finding of file.findings) {
const node = finding.node?.name ? ` [${safeDisplay(finding.node.name)}]` : "";
lines.push(` ${finding.severity.toUpperCase()} ${finding.ruleId}${node} ${safeDisplay(finding.message)}`);
lines.push(` Fix: ${finding.remediation}`);
}
}
lines.push("");
lines.push(`Checked ${report.summary.files} workflow(s): ${report.summary.errors} error(s), ${report.summary.warnings} warning(s)`);
return lines.join("\n");
}
export function formatJson(report) {
return JSON.stringify(report, null, 2);
}
export function formatSarif(report) {
const sarif = {
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
version: "2.1.0",
runs: [{
tool: {
driver: {
name: "Vibeflow",
version: VERSION,
informationUri: "https://github.com/domfelipe/vibeflow-n8n",
rules: Object.entries(RULES).map(([id, rule]) => ({
id,
name: rule.name,
shortDescription: { text: rule.description },
help: { text: rule.remediation },
defaultConfiguration: { level: sarifLevel(rule.severity) },
})),
},
},
results: report.files.flatMap((file) => file.findings.map((finding) => ({
ruleId: finding.ruleId,
level: sarifLevel(finding.severity),
message: { text: `${finding.message}${finding.node?.name ? ` (node: ${finding.node.name})` : ""}` },
locations: [{
physicalLocation: {
artifactLocation: { uri: relativeUri(file.file) },
region: { startLine: 1 },
},
}],
}))),
}],
};
return JSON.stringify(sarif, null, 2);
}
function cloneDefaults() {
return {
rules: { ...DEFAULT_CONFIG.rules },
terms: Object.fromEntries(Object.entries(DEFAULT_CONFIG.terms).map(([key, values]) => [key, [...values]])),
bannedNodeTypes: [...DEFAULT_CONFIG.bannedNodeTypes],
outcomeContracts: {},
};
}
function normalizeOutcomeContracts(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new Error("config.outcomeContracts must be an object keyed by exact node name");
}
const entries = Object.entries(input);
if (entries.length > 1_000) throw new Error("config.outcomeContracts must contain at most 1000 entries");
const normalizedEntries = [];
for (const [nodeName, value] of entries) {
if (!nodeName.trim() || nodeName.length > 200) {
throw new Error("Outcome contract node names must contain 1 to 200 characters");
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Outcome contract for ${safeDisplay(nodeName)} must be an object`);
}
const allowedKeys = new Set([
"impact", "approvalNode", "auditNode", "failureNotificationNode",
"amountGuard", "counterpartyGuard", "recovery",
]);
const unknownKey = Object.keys(value).find((key) => !allowedKeys.has(key));
if (unknownKey) throw new Error(`Unknown outcome contract key for ${safeDisplay(nodeName)}: ${safeDisplay(unknownKey)}`);
if (!["money", "customer", "privileged", "data"].includes(value.impact)) {
throw new Error(`Outcome contract for ${safeDisplay(nodeName)} must declare impact as money, customer, privileged, or data`);
}
for (const field of ["approvalNode", "auditNode", "failureNotificationNode"]) {
if (value[field] !== undefined) validateNodeReference(value[field], `${nodeName}.${field}`);
}
if (value.amountGuard !== undefined) {
validateNestedContract(value.amountGuard, `${nodeName}.amountGuard`, ["node", "maximum", "currency"]);
validateNodeReference(value.amountGuard.node, `${nodeName}.amountGuard.node`);
if (typeof value.amountGuard.maximum !== "number" || !Number.isFinite(value.amountGuard.maximum) || value.amountGuard.maximum <= 0) {
throw new Error(`Outcome contract ${safeDisplay(nodeName)}.amountGuard.maximum must be a positive finite number`);
}
if (typeof value.amountGuard.currency !== "string" || !/^[A-Z]{3}$/.test(value.amountGuard.currency)) {
throw new Error(`Outcome contract ${safeDisplay(nodeName)}.amountGuard.currency must be a three-letter uppercase currency code`);
}
}
if (value.counterpartyGuard !== undefined) {
validateNestedContract(value.counterpartyGuard, `${nodeName}.counterpartyGuard`, ["node", "allowed"]);
validateNodeReference(value.counterpartyGuard.node, `${nodeName}.counterpartyGuard.node`);
if (!Array.isArray(value.counterpartyGuard.allowed)
|| !value.counterpartyGuard.allowed.length
|| value.counterpartyGuard.allowed.length > 100
|| value.counterpartyGuard.allowed.some((item) => typeof item !== "string" || !item.trim() || item.length > 100)) {
throw new Error(`Outcome contract ${safeDisplay(nodeName)}.counterpartyGuard.allowed must contain 1 to 100 non-empty strings`);
}
}
if (value.recovery !== undefined) {
validateNestedContract(value.recovery, `${nodeName}.recovery`, ["strategy", "node"]);
validateNodeReference(value.recovery.node, `${nodeName}.recovery.node`);
if (!["compensate", "rollback", "replay"].includes(value.recovery.strategy)) {
throw new Error(`Outcome contract ${safeDisplay(nodeName)}.recovery.strategy must be compensate, rollback, or replay`);
}
}
normalizedEntries.push([nodeName, structuredClone(value)]);
}
return Object.fromEntries(normalizedEntries);
}
function validateNestedContract(value, pathName, allowedKeys) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Outcome contract ${safeDisplay(pathName)} must be an object`);
}
const unknownKey = Object.keys(value).find((key) => !allowedKeys.includes(key));
if (unknownKey) throw new Error(`Unknown outcome contract key ${safeDisplay(pathName)}.${safeDisplay(unknownKey)}`);
}
function validateNodeReference(value, pathName) {
if (typeof value !== "string" || !value.trim() || value.length > 200) {
throw new Error(`Outcome contract ${safeDisplay(pathName)} must be a non-empty node name of at most 200 characters`);
}
}
async function collectWorkflowFiles(inputs) {
const files = new Set();
for (const input of inputs) {
const absolute = path.resolve(input);
let details;
try {
details = await stat(absolute);
} catch {
throw new Error(`Input not found: ${input}`);
}
if (details.isFile()) addWorkflowFile(files, absolute);
else if (details.isDirectory()) await collectDirectory(absolute, files);
else throw new Error(`Unsupported input: ${input}`);
}
return [...files].sort();
}
async function collectDirectory(directory, files) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if ([".git", "node_modules"].includes(entry.name)) continue;
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) await collectDirectory(absolute, files);
else if (entry.isFile() && entry.name.endsWith(".workflow.json")) addWorkflowFile(files, absolute);
}
}
function addWorkflowFile(files, file) {
files.add(file);
if (files.size > MAX_WORKFLOW_FILES) {
throw new Error(`Input exceeds the ${MAX_WORKFLOW_FILES} workflow file safety limit`);
}
}
function invalidFinding(message) {
return {
ruleId: "VF000",
rule: RULES.VF000.name,
severity: "error",
message,
remediation: RULES.VF000.remediation,
node: null,
path: null,
};
}
function buildReport(files) {
const normalizedFiles = files.map((file) => ({ ...file, file: relativeUri(file.file) }));
const findings = normalizedFiles.flatMap((file) => file.findings);
return {
tool: { name: "vibeflow", version: VERSION },
files: normalizedFiles,
summary: {
files: normalizedFiles.length,
errors: findings.filter((finding) => finding.severity === "error").length,
warnings: findings.filter((finding) => finding.severity === "warning").length,
},
};
}
function buildGraph(connections) {
const adjacency = new Map();
const reverse = new Map();
for (const [source, outputs] of Object.entries(connections)) {
for (const connectionsAtIndex of outputs.main ?? []) {
if (!connectionsAtIndex) continue;
for (const connection of connectionsAtIndex) {
if (connection.type !== "main") continue;
if (!adjacency.has(source)) adjacency.set(source, new Set());
if (!reverse.has(connection.node)) reverse.set(connection.node, new Set());
adjacency.get(source).add(connection.node);
reverse.get(connection.node).add(source);
}
}
}
return { adjacency, reverse };
}
function traverseGraph(graph, starts, blockers = new Set()) {
const queue = [...starts];
const seen = new Set();
for (let cursor = 0; cursor < queue.length; cursor += 1) {
const name = queue[cursor];
if (seen.has(name) || blockers.has(name)) continue;
seen.add(name);
for (const next of graph.get(name) ?? []) queue.push(next);
}
return seen;
}
function collectEntryNames(nodes, reverse) {
const names = new Set(nodes.filter(isEntryTrigger).map((node) => node.name));
for (const node of nodes) {
if (!(reverse.get(node.name)?.size)) names.add(node.name);
}
return [...names];
}
function findSecretPaths(parameters, limit = MAX_FINDINGS_PER_FILE) {
const findings = new Set();
let truncated = false;
walk(parameters, "", (value, key, object, currentPath) => {
if (typeof value === "string" && ((isSensitiveKey(key) && isLiteralSecret(value)) || looksLikeEmbeddedSecret(value))) {
findings.add(currentPath);
}
if (object && typeof object === "object" && !Array.isArray(object)) {
const label = object.name ?? object.key ?? object.headerName;
if (typeof label === "string" && isSensitiveKey(label) && key === "value" && isLiteralSecret(value)) {
findings.add(currentPath);
}
}
if (findings.size >= limit) {
truncated = true;
return false;
}
return true;
});
return { paths: [...findings].sort(), truncated };
}
function walk(value, currentPath, visitor, parent = null) {
const stack = [{ value, currentPath, parent }];
while (stack.length) {
const item = stack.pop();
if (Array.isArray(item.value)) {
for (let index = item.value.length - 1; index >= 0; index -= 1) {
stack.push({ value: item.value[index], currentPath: `${item.currentPath}[${index}]`, parent: item.value });
}
continue;
}
if (!item.value || typeof item.value !== "object") continue;
const entries = Object.entries(item.value);
for (let index = entries.length - 1; index >= 0; index -= 1) {
const [key, child] = entries[index];
const childPath = item.currentPath ? `${item.currentPath}.${key}` : key;
if (visitor(child, key, item.value, childPath, item.parent) === false) return false;
stack.push({ value: child, currentPath: childPath, parent: item.value });
}
}
return true;
}
function isSensitiveKey(key) {
const normalized = String(key).replace(/[^a-z0-9]/gi, "").toLowerCase();
if (/(url|uri|name|type|id|path|field)$/.test(normalized)) return false;
return /(apikey|accesstoken|refreshtoken|token|password|secret|authorization)/.test(normalized);
}
function isLiteralSecret(value) {
const normalized = stripExpressions(value).trim();
return normalized.length >= 8 && !SAFE_LITERAL.test(normalized);
}
function looksLikeEmbeddedSecret(value) {
const original = String(value).trim();
if (/\bbearer\s+[a-z0-9._~+/=-]{8,}/i.test(original)
|| /(?:access[_-]?token|refresh[_-]?token|api[_-]?key|client[_-]?secret|authorization|password)["'\s:=]+[a-z0-9._~+/=-]{8,}/i.test(original)
|| /^[a-z][a-z0-9+.-]*:\/\/[^/\s:]+:[^@\s]{8,}@/i.test(original)) return true;
const normalized = stripExpressions(original).trim();
if (normalized.length < 8 || SAFE_LITERAL.test(normalized)) return false;
return /\bbearer\s+[a-z0-9._~+/=-]{8,}/i.test(normalized)
|| /(?:access[_-]?token|refresh[_-]?token|api[_-]?key|client[_-]?secret|authorization|password)["'\s:=]+[a-z0-9._~+/=-]{8,}/i.test(normalized)
|| /^[a-z][a-z0-9+.-]*:\/\/[^/\s:]+:[^@\s]{8,}@/i.test(normalized);
}
function stripExpressions(value) {
return String(value)
.replace(/\{\{[\s\S]*?\}\}/g, "")
.replace(/\$\{[^}]*\}/g, "")
.replace(/\$(?:env|credentials|secrets)(?:\.[a-z0-9_]+|\[[^\]]+\])/gi, "");
}
function lowerType(node) {
return String(node?.type ?? "").toLowerCase();
}
function isAiAgent(node) {
const type = lowerType(node);
return type.endsWith(".agent") || type.endsWith(".chainllm") || type.endsWith(".questionandanswerchain");
}
function isInboundTrigger(node) {
const type = lowerType(node);
if (TRIGGER_SUFFIXES.some((suffix) => type.endsWith(suffix))) return true;
if (!type.endsWith("trigger")) return false;
return ![".manualtrigger", ".scheduletrigger", ".errortrigger", ".executeworkflowtrigger", ".localfiletrigger"].some((suffix) => type.endsWith(suffix));
}
function isEntryTrigger(node) {
const type = lowerType(node);
return isInboundTrigger(node)
|| [".manualtrigger", ".scheduletrigger", ".executeworkflowtrigger", ".errortrigger", ".cron", ".start"].some((suffix) => type.endsWith(suffix));
}
function isSideEffect(node) {
const type = lowerType(node);
if (SIDE_EFFECT_SUFFIXES.some((suffix) => type.endsWith(suffix))) return true;
const operation = String(node.parameters?.operation ?? node.parameters?.resourceOperation ?? "").toLowerCase();
return /^(?:create|update|delete|append|insert|upsert|send|post|publish|upload|move|copy|add|remove|executequery)$/.test(operation);
}
function webhookAuthenticationIssue(node) {
const authentication = String(node.parameters?.authentication ?? "none").toLowerCase();
if (["none", "", "undefined"].includes(authentication)) {
return "Webhook accepts requests without n8n authentication";
}
if (!["basicauth", "headerauth", "jwtauth"].includes(authentication)) {
return `Webhook uses an unrecognized authentication mode: ${safeDisplay(authentication)}`;
}
const credentials = node.credentials;
const expectedCredentialKeys = {
basicauth: ["httpbasicauth"],
headerauth: ["httpheaderauth"],
jwtauth: ["jwtauth"],
}[authentication];
const hasReference = credentials && typeof credentials === "object" && !Array.isArray(credentials)
&& Object.entries(credentials).some(([key, reference]) => expectedCredentialKeys.includes(key.toLowerCase())
&& reference && typeof reference === "object" && !Array.isArray(reference)
&& [reference.id, reference.name].some((value) => typeof value === "string" && value.trim()));
if (!hasReference) {
return `Webhook declares ${authentication} but has no credential reference`;
}
return null;
}
function hasConnectedErrorPath(node, connections) {
if (node.onError !== "continueErrorOutput") return false;
const mainOutputs = connections[node.name]?.main;
return Array.isArray(mainOutputs) && mainOutputs.slice(1).some((output) => Array.isArray(output)
&& output.some((connection) => reachesTerminalWithout(connection.node, node.name, connections)));
}
function inspectOutcomeContracts({
nodes,
nodesByName,
config,
adjacency,
connections,
entryNames,
allReachable,
idempotencyNames,
failureNotificationNames,
add,
}) {
const contracts = config.outcomeContracts ?? {};
const evidenceNodeNames = new Set(Object.values(contracts).flatMap((contract) => [
contract.auditNode,
contract.failureNotificationNode,
contract.recovery?.node,
]).filter(Boolean));
const inspected = new Set();
for (const node of nodes) {
const contract = contracts[node.name];
const impact = contract?.impact ?? (evidenceNodeNames.has(node.name) ? null : classifyOutcome(node, config.terms));
if (!impact) continue;
inspected.add(node.name);
const ruleId = ["money", "privileged"].includes(impact) ? "VF010" : "VF011";
if (!contract) {
add(ruleId, `Detected ${impact} outcome has no outcomeContracts entry`, node);
add("VF013", `Detected ${impact} outcome has no declared recovery strategy`, node);
continue;
}
const issues = [];
const recoveryIssues = [];
const auditNode = nodesByName.get(contract.auditNode);
if (!auditNode) {
issues.push("missing auditNode reference");
} else if (!isDurableAuditNode(auditNode, config.terms.audit)) {
issues.push(`auditNode ${contract.auditNode} is not recognized as a durable audit write`);
} else if (!dominatesTarget(contract.auditNode, node.name, adjacency, entryNames, allReachable)) {
issues.push(`auditNode ${contract.auditNode} does not guard every entry path to the action`);
} else if (errorBranchCanReach(contract.auditNode, new Set([node.name]), connections, adjacency)) {
issues.push(`auditNode ${contract.auditNode} can reach the action after an audit failure`);
}
const notificationNode = nodesByName.get(contract.failureNotificationNode);
if (!notificationNode || contract.failureNotificationNode === node.name) {
issues.push("missing failureNotificationNode reference");
} else if (!failureNotificationNames.has(contract.failureNotificationNode)) {
issues.push(`failureNotificationNode ${contract.failureNotificationNode} is not recognized as an operator alert`);
} else if (!errorBranchCanReach(node.name, new Set([contract.failureNotificationNode]), connections, adjacency)) {
issues.push(`failureNotificationNode ${contract.failureNotificationNode} is not reachable from the action error output`);
}
if (!hasProtectedIdempotencyPath(node.name, entryNames, idempotencyNames, adjacency, connections)) {
issues.push("an entry path reaches the action without an atomic idempotency claim");
}
if (["money", "privileged"].includes(impact)) {
const approvalNode = nodesByName.get(contract.approvalNode);
if (!approvalNode) {
issues.push("missing approvalNode reference");
} else if (!isStructuralGuard(approvalNode, node.name, adjacency, connections, entryNames, allReachable)
|| !lowerType(approvalNode).endsWith(".if")
|| !hasPositiveDynamicBooleanCondition(approvalNode.parameters ?? {}, config.terms.approval)) {
issues.push(`approvalNode ${contract.approvalNode} is not a dominating approval gate with a deny branch`);
}
}
if (impact === "money") {
const amountNode = nodesByName.get(contract.amountGuard?.node);
if (!amountNode) {
issues.push("missing amountGuard node reference");
} else if (!isStructuralGuard(amountNode, node.name, adjacency, connections, entryNames, allReachable)
|| !hasDynamicUpperBound(amountNode.parameters ?? {}, contract.amountGuard.maximum)
|| !nodeContainsExactValue(amountNode.parameters ?? {}, contract.amountGuard.currency)) {
issues.push(`amountGuard ${contract.amountGuard.node} does not structurally enforce the declared maximum and currency`);
}
const counterpartyNode = nodesByName.get(contract.counterpartyGuard?.node);
if (!counterpartyNode) {
issues.push("missing counterpartyGuard node reference");
} else if (!isStructuralGuard(counterpartyNode, node.name, adjacency, connections, entryNames, allReachable)
|| !valueContainsDirectDynamicReference(counterpartyNode.parameters ?? {})
|| !contract.counterpartyGuard.allowed.every((value) => nodeContainsExactValue(counterpartyNode.parameters ?? {}, value))) {
issues.push(`counterpartyGuard ${contract.counterpartyGuard.node} does not structurally enforce every allowed counterparty`);
}
}
const recoveryNode = nodesByName.get(contract.recovery?.node);
if (!recoveryNode || contract.recovery?.node === node.name) {
recoveryIssues.push("missing recovery node reference");
} else if (!nodeContainsTerms(recoveryNode, config.terms.recovery)) {
recoveryIssues.push(`recovery node ${contract.recovery.node} is not recognized as compensation, rollback, or replay logic`);
} else if (["compensate", "rollback"].includes(contract.recovery.strategy)
&& !traverseGraph(adjacency, [node.name]).has(contract.recovery.node)) {
recoveryIssues.push(`${contract.recovery.strategy} node ${contract.recovery.node} is not downstream from the action`);
}
if (issues.length) add(ruleId, `Outcome contract is incomplete: ${issues.join("; ")}`, node);
if (recoveryIssues.length) add("VF013", `Recovery contract is incomplete: ${recoveryIssues.join("; ")}`, node);
}
for (const [nodeName, contract] of Object.entries(contracts)) {
if (inspected.has(nodeName) || nodesByName.has(nodeName)) continue;
const ruleId = ["money", "privileged"].includes(contract.impact) ? "VF010" : "VF011";
add(ruleId, `Outcome contract references missing action node: ${nodeName}`);
}
}
function classifyOutcome(node, terms) {
if (!isOutcomeAction(node)) return null;
if (nodeContainsTerms(node, terms.outcomeMoney)) return "money";
if (nodeContainsTerms(node, terms.outcomePrivileged)) return "privileged";
if (isDestructiveDataAction(node) || nodeContainsTerms(node, terms.outcomeData)) return "data";
if (isCommunicationNode(node) && nodeContainsTerms(node, terms.outcomeCustomer)) return "customer";
return null;
}
function isCommunicationNode(node) {
return COMMUNICATION_SUFFIXES.some((suffix) => lowerType(node).endsWith(suffix));
}
function isOutcomeAction(node) {
if (!isSideEffect(node)) return false;
if (!lowerType(node).endsWith(".httprequest")) return true;
return ["POST", "PUT", "PATCH", "DELETE"].includes(String(node.parameters?.method ?? "GET").toUpperCase());
}
function isDestructiveDataAction(node) {
if (!DURABLE_AUDIT_SUFFIXES.some((suffix) => lowerType(node).endsWith(suffix))) return false;
const operation = String(node.parameters?.operation ?? node.parameters?.resourceOperation ?? "").toLowerCase();
if (["delete", "remove", "truncate", "drop"].includes(operation)) return true;
return nodeContainsSqlStatement(node.parameters ?? {}, /^\s*(?:delete\s+from|drop\s+(?:table|schema|database)|truncate\b)/i);
}
function isFailureNotification(node, terms) {
if (!isCommunicationNode(node) || !nodeContainsTerms(node, terms)) return false;
if (!lowerType(node).endsWith(".httprequest")) return true;
return ["POST", "PUT", "PATCH"].includes(String(node.parameters?.method ?? "GET").toUpperCase());
}
function errorBranchCanReach(nodeName, targetNames, connections, adjacency) {
const branches = connections[nodeName]?.main;
if (!Array.isArray(branches)) return false;
const starts = branches.slice(1).flatMap((branch) => (branch ?? []))
.filter((connection) => connection.type === "main")
.map((connection) => connection.node);
if (!starts.length) return false;
const reachable = traverseGraph(adjacency, starts);
return [...targetNames].some((target) => reachable.has(target));
}
function isDurableAuditNode(node, terms) {
if (!DURABLE_AUDIT_SUFFIXES.some((suffix) => lowerType(node).endsWith(suffix))
|| !nodeContainsTerms(node, terms)
|| node.continueOnFail === true
|| node.onError === "continueRegularOutput") return false;
const operation = String(node.parameters?.operation ?? node.parameters?.resourceOperation ?? "").toLowerCase();
if (["create", "update", "append", "insert", "upsert", "add", "put"].includes(operation)) return true;
if (operation && operation !== "executequery") return false;
return nodeContainsSqlStatement(node.parameters ?? {}, /^\s*(?:insert|update|merge)\b/i);
}
function dominatesTarget(blockerName, targetName, adjacency, entryNames, allReachable) {
if (blockerName === targetName || !allReachable.has(targetName)) return false;
return !traverseGraph(adjacency, entryNames, new Set([blockerName])).has(targetName);
}
function isStructuralGuard(node, targetName, adjacency, connections, entryNames, allReachable) {
if (![".if", ".switch"].some((suffix) => lowerType(node).endsWith(suffix))) return false;
if (!dominatesTarget(node.name, targetName, adjacency, entryNames, allReachable)) return false;
const branches = connections[node.name]?.main;
if (!Array.isArray(branches) || branches.length < 2) return false;
const reachesTarget = branches.map((branch) => {
const starts = (branch ?? []).filter((connection) => connection.type === "main").map((connection) => connection.node);
return starts.length > 0 && traverseGraph(adjacency, starts).has(targetName);
});
return reachesTarget.some(Boolean) && reachesTarget.some((value) => !value);
}
function nodeContainsExactValue(input, expected) {
const normalizedExpected = typeof expected === "string" ? expected.trim().toLowerCase() : expected;
const stack = [input];
while (stack.length) {
const value = stack.pop();
if (typeof value === "string" && typeof normalizedExpected === "string" && value.trim().toLowerCase() === normalizedExpected) return true;
if (typeof value === "number" && typeof normalizedExpected === "number" && value === normalizedExpected) return true;
if (Array.isArray(value)) {
for (const child of value) stack.push(child);
} else if (value && typeof value === "object") {
for (const child of Object.values(value)) stack.push(child);
}
}
return false;
}
function hasDynamicUpperBound(input, maximum) {
const stack = [input];
const allowedOperations = new Set(["smaller", "smallerequal", "lessthan", "lessthanorequal", "lte"]);
while (stack.length) {
const value = stack.pop();
if (Array.isArray(value)) {
for (const child of value) stack.push(child);
continue;
}
if (!value || typeof value !== "object") continue;
const values = Object.values(value);
const operation = String(value.operation ?? "").toLowerCase().replace(/[^a-z]/g, "");
if (allowedOperations.has(operation)
&& values.some((child) => typeof child === "string" && isDirectDynamicReference(child))
&& values.some((child) => typeof child === "number" && child === maximum)) return true;
for (const child of values) stack.push(child);
}
return false;
}
function valueContainsDirectDynamicReference(input) {
const stack = [input];
while (stack.length) {
const value = stack.pop();
if (typeof value === "string" && isDirectDynamicReference(value)) return true;
if (Array.isArray(value)) {
for (const child of value) stack.push(child);
} else if (value && typeof value === "object") {
for (const child of Object.values(value)) stack.push(child);
}
}
return false;
}
function reachesTerminalWithout(start, excluded, connections) {
const stack = [start];
const seen = new Set();
while (stack.length) {
const current = stack.pop();
if (current === excluded || seen.has(current)) continue;
seen.add(current);
const next = (connections[current]?.main ?? []).flatMap((output) => (output ?? []))
.filter((connection) => connection.type === "main")
.map((connection) => connection.node);
if (!next.length) return true;
for (const name of next) stack.push(name);
}
return false;
}
function isKillSwitchGuard(node, terms, connections, adjacency, aiNames) {
if (!lowerType(node).endsWith(".if") || !hasPositiveDynamicBooleanCondition(node.parameters ?? {}, terms)) return false;
const branches = connections[node.name]?.main;
if (!Array.isArray(branches) || branches.length < 2) return false;
const trueTargets = (branches[0] ?? []).filter((connection) => connection.type === "main").map((connection) => connection.node);
const falseTargets = branches.slice(1).flatMap((branch) => (branch ?? [])
.filter((connection) => connection.type === "main").map((connection) => connection.node));
const trueReachable = traverseGraph(adjacency, trueTargets);
const falseReachable = traverseGraph(adjacency, falseTargets);
return [...aiNames].some((name) => trueReachable.has(name))
&& ![...aiNames].some((name) => falseReachable.has(name));
}
function hasPositiveDynamicBooleanCondition(parameters, terms) {
const stack = [parameters];
while (stack.length) {
const value = stack.pop();
if (Array.isArray(value)) {
for (const child of value) stack.push(child);
continue;
}
if (!value || typeof value !== "object") continue;
const dynamicValue = Object.values(value).find((child) => typeof child === "string"
&& isDirectDynamicReference(child)
&& valueContainsTerms(child, terms));
const operation = String(value.operation ?? "").toLowerCase().replace(/[^a-z]/g, "");
const positiveOperation = !operation || ["equal", "equals", "istrue", "true"].includes(operation);
const positiveExpectation = value.value2 === true || ["istrue", "true"].includes(operation);
if (dynamicValue && positiveOperation && positiveExpectation) return true;
for (const child of Object.values(value)) stack.push(child);
}
return false;
}
function isDirectDynamicReference(value) {
return /^\s*=?\{\{\s*\$(?:json|vars|env|workflow)(?:\.[a-z0-9_]+|\[['"][^'"]+['"]\])+\s*\}\}\s*$/i.test(value);
}
function isHumanHandoff(node, terms) {
if (!nodeContainsTerms(node, terms)) return false;
const type = lowerType(node);
if (HANDOFF_SUFFIXES.some((suffix) => type.endsWith(suffix))) return true;
if (type.endsWith(".httprequest")) {
return ["POST", "PUT", "PATCH"].includes(String(node.parameters?.method ?? "GET").toUpperCase());
}
return false;
}
function isAtomicIdempotencyGuard(node, terms) {
if (!nodeContainsTerms(node, terms) || node.continueOnFail === true || node.onError === "continueRegularOutput") return false;
const type = lowerType(node);
const parameters = node.parameters ?? {};
return type.endsWith(".postgres")
&& nodeContainsSqlStatement(parameters, /^\s*insert\b[\s\S]*\bon\s+conflict\b[\s\S]*\bdo\s+nothing\b[\s\S]*\breturning\b/i);
}
function hasProtectedIdempotencyPath(targetName, starts, idempotencyNames, adjacency, connections) {
const validGuards = new Set([...idempotencyNames].filter((guardName) =>
!errorBranchCanReach(guardName, new Set([targetName]), connections, adjacency)));
if (!validGuards.size || !traverseGraph(adjacency, starts).has(targetName)) return false;
return !traverseGraph(adjacency, starts, validGuards).has(targetName);
}
function nodeContainsSqlStatement(value, pattern) {
let matches = false;
walk({ value }, "", (child) => {
if (typeof child !== "string") return true;
const withoutComments = child.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\r\n]*/g, " ");
const withoutLiterals = withoutComments
.replace(/\$\$[\s\S]*?\$\$/g, "''")
.replace(/'(?:''|[^'])*'/g, "''")
.replace(/"(?:""|[^"])*"/g, '""');
const firstStatement = withoutLiterals.split(";", 1)[0];
if (pattern.test(firstStatement)) {
matches = true;
return false;
}
return true;
});
return matches;
}
function nodeContainsTerms(node, terms) {
return valueContainsTerms([node.name ?? "", node.type ?? "", node.parameters ?? {}], terms);
}
function valueContainsTerms(input, terms) {
const normalize = (value) => value.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
const needles = terms.map((term) => normalize(term));
const stack = [input];
while (stack.length) {
const value = stack.pop();
if (typeof value === "string") {
const normalized = normalize(value);
if (needles.some((term) => normalized.includes(term))) return true;
} else if (Array.isArray(value)) {
for (const child of value) stack.push(child);
} else if (value && typeof value === "object") {
for (const child of Object.values(value)) stack.push(child);
}
}
return false;
}
function sarifLevel(severity) {
return severity === "error" ? "error" : severity === "warning" ? "warning" : "note";
}
function relativeUri(file) {
const relative = path.isAbsolute(file) ? path.relative(process.cwd(), file) : file;
return (relative || path.basename(file)).split(path.sep).join("/");
}
function validateWorkflowShape(workflow) {
if (!workflow || typeof workflow !== "object" || Array.isArray(workflow) || !Array.isArray(workflow.nodes)) {
return "Expected an object with a nodes array";
}
if (workflow.nodes.length > MAX_NODES) return `Workflow exceeds the ${MAX_NODES} node safety limit`;
const names = new Set();
for (const node of workflow.nodes) {
if (!node || typeof node !== "object" || typeof node.name !== "string" || !node.name || typeof node.type !== "string" || !node.type) {
return "Every node must be an object with non-empty string name and type fields";
}
if (names.has(node.name)) return `Duplicate node name: ${safeDisplay(node.name)}`;
if (node.parameters !== undefined && (!node.parameters || typeof node.parameters !== "object" || Array.isArray(node.parameters))) {
return `Node parameters must be an object: ${safeDisplay(node.name)}`;
}
names.add(node.name);
}
if (workflow.connections !== undefined && (!workflow.connections || typeof workflow.connections !== "object" || Array.isArray(workflow.connections))) {
return "connections must be an object when present";
}
let edgeCount = 0;
for (const [source, outputs] of Object.entries(workflow.connections ?? {})) {
if (!names.has(source)) return `Connection source does not exist: ${safeDisplay(source)}`;
if (!outputs || typeof outputs !== "object" || Array.isArray(outputs)) {
return `Connection outputs must be an object: ${safeDisplay(source)}`;
}
for (const [outputType, outputGroups] of Object.entries(outputs)) {
if (outputType !== "main" && !/^ai_[a-z][a-z0-9_]*$/i.test(outputType)) {
return `Unsupported connection output type: ${safeDisplay(outputType)}`;
}
if (!Array.isArray(outputGroups)) return `Connection output ${safeDisplay(outputType)} must be an array`;
for (const connectionsAtIndex of outputGroups) {
if (connectionsAtIndex === null) continue;
if (!Array.isArray(connectionsAtIndex)) return `Connection index under ${safeDisplay(outputType)} must be an array`;
for (const connection of connectionsAtIndex) {
if (!connection || typeof connection !== "object" || Array.isArray(connection)
|| typeof connection.node !== "string" || typeof connection.type !== "string"
|| !Number.isInteger(connection.index) || connection.index < 0) {
return `Malformed connection from ${safeDisplay(source)}`;
}
if (connection.type !== outputType) return `Connection type does not match output ${safeDisplay(outputType)}`;
if (!names.has(connection.node)) return `Connection target does not exist: ${safeDisplay(connection.node)}`;
edgeCount += 1;
if (edgeCount > MAX_EDGES) return `Workflow exceeds the ${MAX_EDGES} edge safety limit`;
}
}
}
}
return null;
}
function severityRank(severity) {
return severity === "error" ? 2 : severity === "warning" ? 1 : 0;
}
function sameStringSet(left, right) {
const normalizedLeft = [...new Set(left.map((value) => value.toLowerCase()))].sort();
const normalizedRight = [...new Set(right.map((value) => value.toLowerCase()))].sort();
return normalizedLeft.length === normalizedRight.length
&& normalizedLeft.every((value, index) => value === normalizedRight[index]);
}
function sortFindings(findings) {
return findings.sort((a, b) =>
a.ruleId.localeCompare(b.ruleId)
|| String(a.node?.name ?? "").localeCompare(String(b.node?.name ?? ""))
|| String(a.path ?? "").localeCompare(String(b.path ?? ""))
);
}
function finalizeFindings(findings, truncated) {
if (truncated && !findings.some((finding) => finding.ruleId === "VF000")) {
findings.push(invalidFinding(`Analysis stopped after ${MAX_FINDINGS_PER_FILE} findings`));
}
return sortFindings(findings);
}
export function safeDisplay(value) {
return String(value).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, " ");
}